Compare commits

..

2 Commits

Author SHA1 Message Date
Mihir Kandoi
98df30d1da test(accounts): cover rejected material value on a stock updating invoice 2026-09-22 10:59:40 +05:30
Mihir Kandoi
6c3046121d fix(stock): stop valuing rejected material on a stock updating invoice
A Purchase Receipt books rejected material against Stock Received But Not
Billed, so the supplier still owes an invoice for it and the value has a
source. A Purchase Invoice that updates stock bills the accepted quantity
alone, yet Set Valuation Rate For Rejected Materials gave its rejected
material the invoice rate as well. The rejected warehouse then received
stock value that no GL entry backed: ten units at 100 with four rejected
moved 1000 into stock and booked 600, leaving the ledgers 400 apart.

Read the setting through is_rejected_material_valued, which excludes the
invoice, from both the plain rows in update_stock_ledger and the tracked
rows in the bundle. Internal transfers are unaffected: their inward rate is
anchored to the delivery note in stock_ledger.process_sle.
2026-09-22 10:59:39 +05:30
53 changed files with 588 additions and 4618 deletions

View File

@@ -47,13 +47,10 @@ class ERPNextAddress(Address):
super().on_update()
address_display = get_address_display(self.as_dict())
customers = frappe.db.get_all(
"Customer", filters={"customer_primary_address": self.name}, pluck="name"
)
for customer in customers:
frappe.db.set_value(
"Customer", customer, "primary_address", address_display, update_modified=False
)
filters = {"customer_primary_address": self.name}
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)
for customer_name in customers:
frappe.db.set_value("Customer", customer_name[0], "primary_address", address_display)
@frappe.whitelist()

View File

@@ -253,10 +253,6 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
def post_process(source, target) -> None:
target.reversal_of = source.name
target.naming_series = source.naming_series
if source.voucher_type == "Bank Entry":
target.cheque_no = source.cheque_no
target.cheque_date = source.cheque_date
doclist = get_mapped_doc(
"Journal Entry",

View File

@@ -280,13 +280,12 @@ class PeriodClosingVoucher(AccountsController):
data = self.get_data_for_mapreduce()
mapreduce(
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.process_date_range",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.aggregate_partial_result",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
data,
self.doctype,
self.name,
f"Closing FY {self.fiscal_year}",
)
def on_cancel(self):
@@ -331,8 +330,8 @@ class PeriodClosingVoucher(AccountsController):
def make_gl_entries(self):
if frappe.db.estimate_count("GL Entry") > 100_000:
frappe.enqueue_task(
method=process_gl_and_closing_entries,
frappe.enqueue(
process_gl_and_closing_entries,
doc=self,
timeout=1800,
)
@@ -835,7 +834,7 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
return prev_closed_period_end_date
def process_date_range(val):
def mapper(val):
start_date = val.from_date
end_date = val.to_date
pcv = val.pcv
@@ -882,7 +881,7 @@ def process_date_range(val):
return res
def aggregate_partial_result(final, partial_res):
def reducer(final, partial_res):
if final is None:
final = []

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.listview_settings["Period Closing Voucher"] = {
add_fields: ["gle_processing_status"],
get_indicator: function (doc) {
const status_colors = {
Draft: "red",
Submitted: "blue",
Cancelled: "red",
};
const gle_processing_status = {
"In Progress": [__("Processing GL Entries"), "blue"],
Completed: [__("Period Closed"), "green"],
Failed: [__("Period Closing Failed"), "red"],
};
if (doc.docstatus != 0) {
return [
gle_processing_status[doc.gle_processing_status][0],
gle_processing_status[doc.gle_processing_status][1],
"gle_processing_status,=," + doc.gle_processing_status,
];
}
return [__(doc.docstatus), status_colors[doc.docstatus], "docstatus,=," + doc.docstatus];
},
};

View File

@@ -216,11 +216,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
if doc.is_internal_supplier and item.valuation_rate:
credit_amount = flt(item.valuation_rate * item.stock_qty)
rejected_amount = self.make_rejected_warehouse_gl_entry(
gl_entries, item, voucher_wise_stock_value, inventory_account_map
)
credit_amount += rejected_amount
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
gl_entries.append(
self.get_gl_dict(
@@ -256,10 +251,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
)
else:
self.make_rejected_warehouse_gl_entry(
gl_entries, item, voucher_wise_stock_value, inventory_account_map
)
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
@@ -573,49 +564,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
return stock_asset_rbnb or item.expense_account
def make_rejected_warehouse_gl_entry(
self, gl_entries, item, voucher_wise_stock_value, inventory_account_map
) -> float:
"""Book the material the invoice moved into the rejected warehouse.
An internal transfer carries the value credited out of the in-transit warehouse along with
the accepted material, so the entry against it is that warehouse, and the caller credits it
for both. On an ordinary invoice the supplier entry already holds the cost.
"""
doc = self.doc
if not (item.rejected_warehouse and flt(item.rejected_qty)):
return 0.0
transfers_rejected_material = doc.is_internal_transfer()
rejected_amount = flt(
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)),
item.precision("base_net_amount"),
)
if not rejected_amount:
return 0.0
rejected_account = doc.get_inventory_account_dict(item, inventory_account_map, "rejected_warehouse")
gl_entries.append(
self.get_gl_dict(
{
"account": rejected_account["account"],
"against": item.expense_account if transfers_rejected_material else doc.supplier,
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": rejected_amount,
"debit_in_transaction_currency": flt(
rejected_amount / doc.conversion_rate, item.precision("net_amount")
),
},
rejected_account["account_currency"],
item=item,
)
)
return rejected_amount if transfers_rejected_material else 0.0
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
doc = self.doc
net_amt_precision = item.precision("base_net_amount")
@@ -629,24 +577,16 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
net_rate = item.base_net_amount
if item.sales_incoming_rate:
# Material of a transfer goes back at the rate it came in with, the rejected
# material along with the accepted.
net_rate = (flt(item.qty) + flt(item.rejected_qty)) * item.sales_incoming_rate
net_rate = item.qty * item.sales_incoming_rate
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
warehouse_debit_amount = flt(
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
)
# The rejected warehouse carries the rest of what the invoice paid for, and is booked
# by its own entry, so it is not a variance.
returned_stock_value = warehouse_debit_amount + flt(
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)), net_amt_precision
)
if flt(stock_amount, net_amt_precision) != flt(returned_stock_value, net_amt_precision):
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_stock_variance_account(item)
stock_adjustment_amt = stock_amount - returned_stock_value
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
self.get_gl_dict(

View File

@@ -844,9 +844,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name, do_not_save=True)
pi.credit_to = "_Test Payable USD - _TC"
pi.submit()
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
self.assertEqual(
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
existing_purchase_cost + 15000,
@@ -858,14 +856,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
existing_purchase_cost + 15500,
)
pi1.reload()
pi1.cancel()
self.assertEqual(
frappe.db.get_value("Project", project.name, "total_purchase_cost"),
existing_purchase_cost + 15000,
)
pi.reload()
pi.cancel()
self.assertEqual(
frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost
@@ -2628,543 +2624,9 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
def test_internal_transfer_invoice_with_rejected_qty(self):
"""An invoice that updates stock moves rejected material out of the in-transit warehouse and
books it, like a receipt does."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_gl_entries,
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Invoice Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Invoice Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Invoice Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Invoice Transfer Rejected", company=company)
item_doc = create_item("Test Invoice Internal Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item_doc.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 7
pi.items[0].rejected_qty = 3
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 7)
self.assertEqual(stock_qty[rejected_warehouse], 3)
booked_value = {}
for entry in get_gl_entries("Purchase Invoice", pi.name, skip_cancelled=True):
booked_value.setdefault(entry.account, 0)
booked_value[entry.account] += flt(entry.debit) - flt(entry.credit)
self.assertEqual(flt(sum(booked_value.values()), 2), 0)
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
self.assertEqual(booked_value[get_inventory_account(company, to_warehouse)], 700)
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 300)
def test_internal_transfer_invoice_with_rejected_batch_qty(self):
"""Batch material rejected on a stock updating internal transfer invoice gets its own package."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Batch Invoice Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Batch Invoice Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Batch Invoice Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Batch Invoice Transfer Rejected", company=company)
item = make_item(
"Test Invoice Internal Transfer Batch Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TIITB-.####",
},
)
make_purchase_receipt(
item_code=item.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 7
pi.items[0].rejected_qty = 3
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
row = pi.items[0]
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty"), 7
)
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "total_qty"),
3,
)
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertEqual(moved_qty[transit_warehouse], -10)
self.assertEqual(moved_qty[to_warehouse], 7)
self.assertEqual(moved_qty[rejected_warehouse], 3)
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
self.assertEqual(flt(moved_value[to_warehouse]), 700)
self.assertEqual(flt(moved_value[rejected_warehouse]), 300)
def test_internal_transfer_invoice_with_every_unit_rejected(self):
"""An invoice may reject a whole row, and the in-transit warehouse is emptied all the same."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Rejected Invoice From", company=company)
transit_warehouse = create_warehouse("_Test Rejected Invoice Transit", company=company)
to_warehouse = create_warehouse("_Test Rejected Invoice To", company=company)
rejected_warehouse = create_warehouse("_Test Rejected Invoice Rejected", company=company)
item_doc = create_item("Test Fully Rejected Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item_doc.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 0
pi.items[0].rejected_qty = 10
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
moved_qty = {d.warehouse: d.actual_qty for d in sl_entries}
moved_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertNotIn(to_warehouse, moved_qty)
self.assertEqual(moved_qty[transit_warehouse], -10)
self.assertEqual(moved_qty[rejected_warehouse], 10)
self.assertEqual(flt(moved_value[transit_warehouse]), -1000)
self.assertEqual(flt(moved_value[rejected_warehouse]), 1000)
def test_return_of_an_internal_transfer_invoice_that_rejected_everything(self):
"""Material rejected in full goes back to the in-transit warehouse at the rate it came in
with, and the entries say the same as the stock."""
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Returned Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Returned Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Returned Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Returned Transfer Rejected", company=company)
item_doc = create_item("Test Returned Fully Rejected Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
si = create_sales_invoice(
company=company,
customer="_Test Internal Customer 2",
item_code=item_doc.name,
qty=10,
rate=100,
warehouse=from_warehouse,
update_stock=1,
cost_center="Main - TCP1",
debit_to="Debtors - TCP1",
income_account="Sales - TCP1",
do_not_save=1,
)
si.items[0].target_warehouse = transit_warehouse
si.insert()
si.submit()
pi = make_inter_company_purchase_invoice(si.name)
pi.update_stock = 1
pi.items[0].warehouse = to_warehouse
pi.items[0].qty = 0
pi.items[0].rejected_qty = 10
pi.items[0].received_qty = 10
pi.items[0].rejected_warehouse = rejected_warehouse
pi.items[0].expense_account = "Cost of Goods Sold - TCP1"
pi.submit()
returned = make_return_doc("Purchase Invoice", pi.name)
returned.update_stock = 1
returned.submit()
moved = {
d.warehouse: flt(d.stock_value_difference)
for d in frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": returned.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
}
self.assertEqual(moved[transit_warehouse], 1000)
self.assertEqual(moved[rejected_warehouse], -1000)
booked = {}
for entry in frappe.get_all(
"GL Entry",
filters={"voucher_no": returned.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
):
booked.setdefault(entry.account, 0)
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
self.assertEqual(flt(sum(booked.values()), 2), 0)
self.assertEqual(booked[get_inventory_account(company, transit_warehouse)], 1000)
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -1000)
def test_stock_updating_invoice_rejects_every_unit_of_a_row(self):
"""A row of a stock updating invoice may be rejected in full."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1}).name
rejected_warehouse = create_warehouse("_Test Fully Rejected Invoice Warehouse", company=company)
pi = make_purchase_invoice(
company=company,
item_code=item,
warehouse="Stores - TCP1",
qty=0,
rejected_qty=10,
received_qty=10,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
do_not_save=True,
)
pi.submit()
moved_qty = {
d.warehouse: d.actual_qty
for d in frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty"],
)
}
self.assertEqual(moved_qty, {rejected_warehouse: 10})
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_stock_updating_invoice_bills_the_rejected_quantity(self):
"""With the rejected quantity billed and valued, the invoice pays for every unit received and
the stock it moves matches the entries it books."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Billed Rejected Warehouse", company=company)
pi = make_purchase_invoice(
item_code=item,
company=company,
warehouse="Stores - TCP1",
rejected_warehouse=rejected_warehouse,
cost_center="Main - TCP1",
supplier_warehouse="Work In Progress - TCP1",
expense_account="_Test Account Cost for Goods Sold - TCP1",
update_stock=1,
received_qty=10,
qty=6,
rejected_qty=4,
rate=100,
)
self.assertEqual(pi.items[0].amount, 1000)
self.assertEqual(pi.items[0].valuation_rate, 100)
stock_value = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
self.assertEqual(by_warehouse[rejected_warehouse], 400)
booked = frappe.get_all(
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
)
self.assertEqual(sum(flt(d.debit) for d in booked), 1000)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_rejected_material_is_reposted_after_the_setting_changes(self):
"""The entries an invoice books follow the stock it moved, so they can be built again once
the settings have moved on."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Repost Rejected", company=company)
pi = make_purchase_invoice(
company=company,
item_code=item,
warehouse="Stores - TCP1",
qty=6,
rejected_qty=4,
received_qty=10,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
)
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
rebuilt = pi.get_gl_entries()
rejected_account = get_inventory_account(company, rejected_warehouse)
self.assertEqual(
flt(sum(flt(entry.get("debit")) - flt(entry.get("credit")) for entry in rebuilt), 2), 0
)
self.assertEqual(
flt(
sum(flt(entry.get("debit")) for entry in rebuilt if entry.get("account") == rejected_account)
),
400,
)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_return_without_a_reference_books_both_warehouses(self):
"""A return that stands on its own gives back the rejected material too, and books it once."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
accepted_warehouse = create_warehouse("_Test Invoice Return Accepted", company=company)
rejected_warehouse = create_warehouse("_Test Invoice Return Rejected", company=company)
def make_invoice(sign):
return make_purchase_invoice(
company=company,
item_code=item,
warehouse=accepted_warehouse,
qty=6 * sign,
rejected_qty=4 * sign,
received_qty=10 * sign,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
is_return=1 if sign < 0 else 0,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
)
make_invoice(1)
returned = make_invoice(-1)
booked = {}
for entry in frappe.get_all(
"GL Entry",
filters={"voucher_no": returned.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
):
booked.setdefault(entry.account, 0)
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
self.assertEqual(flt(sum(booked.values()), 2), 0)
self.assertEqual(booked[get_inventory_account(company, accepted_warehouse)], -600)
self.assertEqual(booked[get_inventory_account(company, rejected_warehouse)], -400)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"bill_for_rejected_quantity_in_purchase_invoice": 1,
"set_valuation_rate_for_rejected_materials": 1,
},
)
def test_discount_on_an_invoice_that_bills_the_rejected_quantity(self):
"""A discount is spread over every unit the invoice pays for, not the accepted ones alone."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Discount Rejected", company=company)
pi = make_purchase_invoice(
company=company,
item_code=item,
warehouse="Stores - TCP1",
qty=6,
rejected_qty=4,
received_qty=10,
rate=100,
rejected_warehouse=rejected_warehouse,
update_stock=1,
expense_account="Cost of Goods Sold - TCP1",
cost_center="Main - TCP1",
do_not_save=True,
)
pi.apply_discount_on = "Net Total"
pi.additional_discount_percentage = 10
pi.submit()
self.assertEqual(pi.items[0].amount, 1000)
self.assertEqual(pi.items[0].net_rate, 90)
self.assertEqual(pi.grand_total, 900)
self.assertEqual(frappe.db.get_value("Item", item, "last_purchase_rate"), 90)
@ERPNextTestSuite.change_settings(
"Buying Settings",
{
"set_valuation_rate_for_rejected_materials": 1,
"bill_for_rejected_quantity_in_purchase_invoice": 0,
},
)
def test_rejected_material_is_not_valued_on_a_stock_updating_invoice(self):
"""An invoice that does not bill the rejected quantity has nothing to pay for that material,
so it carries no cost and the stock the invoice moves matches the entries it books."""
"""An invoice bills the accepted quantity alone, so its rejected material has no cost and the
stock it moves must match the entries it books."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@@ -3172,6 +2634,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Rejected Warehouse", company=company)
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 1)
self.addCleanup(
frappe.db.set_single_value, "Buying Settings", "set_valuation_rate_for_rejected_materials", 0
)
pi = make_purchase_invoice(
item_code=item,
company=company,
@@ -3187,8 +2654,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
rate=100,
)
self.assertEqual(pi.items[0].amount, 600)
stock_value = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},

View File

@@ -47,10 +47,6 @@
"setting_field": "bill_for_rejected_quantity_in_purchase_invoice",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "set_valuation_rate_for_rejected_materials",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "unlink_payment_on_cancellation_of_invoice",
"settings_doctype": "Accounts Settings"

View File

@@ -6,9 +6,8 @@ from datetime import date
import frappe
from frappe import _, msgprint, qb, scrub
from frappe.contacts.doctype.address.address import get_company_address, get_default_address
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
from frappe.model.utils import get_fetch_values
from frappe.permissions import get_allowed_docs_for_doctype
from frappe.query_builder.functions import Abs, Date, Sum
from frappe.utils import (
add_days,
@@ -160,7 +159,7 @@ def _get_party_details(
)
set_contact_details(party_details, party, party_type, doctype)
set_other_values(party_details, party, party_type)
set_price_list(party_details, party, party_type, price_list, pos_profile, doctype)
set_price_list(party_details, party, party_type, price_list, pos_profile)
tax_template = set_taxes(
party.name,
@@ -409,33 +408,13 @@ def get_default_price_list(party):
return price_list
def get_permitted_price_lists(doctype=None):
permissions = sorted(
get_user_permissions().get("Price List", []), key=lambda p: p.get("is_default"), reverse=True
)
# a permission applicable for another doctype doesn't restrict this transaction
return get_allowed_docs_for_doctype(permissions, doctype)
def get_usable_price_list(price_lists, party_doctype):
transaction_side = "selling" if party_doctype == "Customer" else "buying"
for price_list in price_lists:
details = frappe.get_cached_value(
"Price List", price_list, ["enabled", transaction_side], as_dict=True
)
if details.enabled and details[transaction_side]:
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None, doctype=None):
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
# price list
permitted_price_lists = get_permitted_price_lists(doctype)
price_list = get_permitted_documents("Price List")
# if there is only one permitted document based on user permissions, set it
if len(permitted_price_lists) == 1:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
if price_list and len(price_list) == 1:
price_list = price_list[0]
elif pos and party_type == "Customer":
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
@@ -447,10 +426,6 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None,
else:
price_list = get_default_price_list(party) or given_price_list
# don't set a price list the user has no permission for, the transaction can't be saved with it
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
if price_list and not is_price_list_enabled(price_list):
price_list = None

View File

@@ -1,6 +1,5 @@
import frappe
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.party import get_default_price_list, set_price_list
from erpnext.tests.utils import ERPNextTestSuite
@@ -35,159 +34,19 @@ class PartyTestCase(ERPNextTestSuite):
self.assertIsNone(party_details.selling_price_list)
def test_fallback_should_not_pick_an_unpermitted_price_list(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", self.create_price_list(enabled=1), doctype="Sales Order"
)
self.assertEqual(party_details.selling_price_list, permitted_default)
def test_permitted_given_price_list_should_be_kept(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", permitted_other, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, permitted_other)
def test_permission_for_another_doctype_should_not_apply(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_a_single_permitted_price_list_should_fit_the_transaction(self):
buying_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
user = self.create_user_with_price_list_permissions([buying_price_list])
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def test_buying_transaction_should_not_take_a_selling_price_list(self):
permitted = [self.create_price_list(enabled=1) for _ in range(2)]
user = self.create_user_with_price_list_permissions(permitted)
supplier_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
supplier = self.create_supplier(default_price_list=supplier_price_list)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, supplier, "Supplier", None, doctype="Purchase Order")
self.assertIsNone(party_details.buying_price_list)
def test_permission_for_another_doctype_should_not_apply_without_a_doctype(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list)
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_pos_price_list_should_be_kept(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted)
pos_price_list = self.create_price_list(enabled=1)
pos_profile = make_pos_profile(selling_price_list=pos_price_list)
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", None, pos=pos_profile.name, doctype="POS Invoice"
)
self.assertEqual(party_details.selling_price_list, pos_price_list)
def test_disabled_permitted_price_lists_should_clear_the_price_list(self):
permitted = [self.create_price_list(enabled=0), self.create_price_list(enabled=0)]
user = self.create_user_with_price_list_permissions(permitted)
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def create_user_with_price_list_permissions(self, price_lists, applicable_for=None):
user = frappe.get_doc(
{
"doctype": "User",
"email": f"{frappe.generate_hash(length=10)}@example.com",
"first_name": "Price List Test",
"send_welcome_email": 0,
"roles": [{"role": "Sales User"}],
}
).insert(ignore_permissions=True)
for idx, price_list in enumerate(price_lists):
frappe.get_doc(
{
"doctype": "User Permission",
"user": user.name,
"allow": "Price List",
"for_value": price_list,
"is_default": int(idx == 0),
"apply_to_all_doctypes": int(not applicable_for),
"applicable_for": applicable_for,
}
).insert(ignore_permissions=True)
frappe.clear_cache(user=user.name)
self.addCleanup(frappe.clear_cache, user=user.name)
return user.name
def create_price_list(self, enabled, selling=1, buying=0):
def create_price_list(self, enabled):
price_list = frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": selling,
"buying": buying,
"selling": 1,
"enabled": enabled,
}
).insert(ignore_permissions=True)
return price_list.name
def create_supplier(self, **values):
return frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": frappe.generate_hash(length=10),
**values,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
def create_customer(self, **values):
customer = frappe.get_doc(
{

View File

@@ -40,7 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Asset",
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"]]",
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"],[\"Asset\",\"company\",\"=\",\"eval:doc.company\"]]",
"options": "Asset",
"reqd": 1
},

View File

@@ -139,7 +139,7 @@
},
{
"default": "1",
"description": "If checked, the rejected quantity is billed anywhere in the purchase cycle.",
"description": "If checked, Rejected Quantity will be included while making Purchase Invoice from Purchase Receipt.",
"fieldname": "bill_for_rejected_quantity_in_purchase_invoice",
"fieldtype": "Check",
"label": "Bill for rejected quantity in Purchase Invoice"
@@ -247,7 +247,7 @@
{
"default": "0",
"depends_on": "bill_for_rejected_quantity_in_purchase_invoice",
"description": "If enabled, the system will generate an accounting entry for material rejected anywhere in the purchase cycle.",
"description": "If enabled, the system will generate an accounting entry for materials rejected in the Purchase Receipt.",
"fieldname": "set_valuation_rate_for_rejected_materials",
"fieldtype": "Check",
"label": "Set valuation rate for rejected Materials"

View File

@@ -79,43 +79,12 @@ class BuyingSettings(Document):
self.set_landed_cost_based_on_purchase_invoice_rate = 0
def is_rejected_material_valued(voucher_type: str, voucher_detail_no: str | None = None) -> bool:
"""Rejected material carries stock value only when something has paid for it.
Material of an internal transfer always has: its value was credited out of the in-transit
warehouse. A Purchase Receipt books rejected material against Stock Received But Not Billed, so
the supplier still owes an invoice for it, and Buying Settings decides. A stock updating Purchase
Invoice pays for it only when it bills the received qty, which is what the settings ask for.
"""
if is_material_from_in_transit_warehouse(voucher_type, voucher_detail_no):
return True
if not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
def is_rejected_material_valued(voucher_type: str) -> bool:
"""Rejected material carries stock value only when something is going to pay for it. A Purchase
Receipt books it against Stock Received But Not Billed, so the supplier still owes an invoice for
it. A stock updating Purchase Invoice bills the accepted quantity alone, so its rejected material
has no cost to carry."""
if voucher_type == "Purchase Invoice":
return False
return voucher_type != "Purchase Invoice" or bool(
frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")
)
def is_material_from_in_transit_warehouse(voucher_type: str, voucher_detail_no: str | None) -> bool:
if voucher_type not in ("Purchase Receipt", "Purchase Invoice") or not voucher_detail_no:
return False
return bool(frappe.get_cached_value(voucher_type + " Item", voucher_detail_no, "from_warehouse"))
def bills_rejected_quantity(doc) -> bool:
"""An invoice that moves stock itself has no receipt to bill the rejected material for it, so it
bills the received qty when the settings ask for the material to be valued.
An internal transfer bills nothing of the sort: its material is paid for by the warehouse it came
out of.
"""
if doc.doctype != "Purchase Invoice" or not doc.get("update_stock"):
return False
if doc.get("is_internal_supplier") and doc.get("represents_company") == doc.get("company"):
return False
return is_rejected_material_valued(doc.doctype)
return bool(frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"))

View File

@@ -349,26 +349,12 @@ class AccountsController(TransactionBase):
self.validate_company_in_accounting_dimension()
def validate_price_list(self):
if self.get("selling_price_list"):
price_list_field, transaction_side = "selling_price_list", "selling"
else:
price_list_field, transaction_side = "buying_price_list", "buying"
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
price_list = self.get(price_list_field)
if not price_list:
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
return
details = (
frappe.db.get_value("Price List", price_list, ["enabled", transaction_side], as_dict=True)
or frappe._dict()
)
# An internal transfer carries the price list of the outward document into the inward one.
fits_transaction = details.get(transaction_side) or self.is_internal_transfer()
if details.enabled and fits_transaction:
return
# Returns retain a submitted voucher's pricing even if its price list no longer fits.
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
if (
self.get("is_return")
and self.get("return_against")
@@ -379,20 +365,9 @@ class AccountsController(TransactionBase):
):
return
if not details.enabled:
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
if transaction_side == "selling":
message = _("Price List {0} cannot be used on a selling transaction")
else:
message = _("Price List {0} cannot be used on a buying transaction")
frappe.throw(
message.format(get_link_to_form("Price List", price_list)),
title=_("Invalid Price List"),
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
def set_default_letter_head(self):
@@ -749,15 +724,12 @@ class AccountsController(TransactionBase):
args = "for_buying"
if self.meta.get_field(fieldname) and self.get(fieldname):
previous_price_list_currency = self.price_list_currency
self.price_list_currency = frappe.db.get_value("Price List", self.get(fieldname), "currency")
if self.price_list_currency == self.company_currency:
self.plc_conversion_rate = 1.0
elif not self.plc_conversion_rate or (
previous_price_list_currency and previous_price_list_currency != self.price_list_currency
):
elif not self.plc_conversion_rate:
self.plc_conversion_rate = get_exchange_rate(
self.price_list_currency, self.company_currency, transaction_date, args
)
@@ -1002,7 +974,7 @@ class AccountsController(TransactionBase):
def validate_zero_qty_for_return_invoices_with_stock(self):
rows = []
for item in self.items:
if not (flt(item.qty) or flt(item.get("rejected_qty"))):
if not flt(item.qty):
rows.append(item)
if rows:
frappe.throw(
@@ -1011,18 +983,12 @@ class AccountsController(TransactionBase):
).format(frappe.bold(comma_and(["#" + str(x.idx) for x in rows])))
)
def is_stock_receipt(self) -> bool:
"""Whether this document receives material into a warehouse."""
return self.doctype == "Purchase Receipt" or (
self.doctype == "Purchase Invoice" and self.update_stock
)
def validate_qty_is_not_zero(self):
if self.flags.allow_zero_qty:
return
for item in self.items:
if self.is_stock_receipt() and item.get("rejected_qty"):
if self.doctype == "Purchase Receipt" and item.rejected_qty:
continue
if not flt(item.qty):

View File

@@ -15,20 +15,18 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.party import _get_party_details
from erpnext.buying.doctype.buying_settings.buying_settings import (
bills_rejected_quantity,
is_rejected_material_valued,
)
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
get_conversion_factor,
get_item_defaults,
)
from erpnext.stock.utils import _get_incoming_rate, is_serial_no_wise_valuation_disabled
from erpnext.stock.utils import _get_incoming_rate
class QtyMismatchError(ValidationError):
@@ -72,7 +70,6 @@ class BuyingController(SubcontractingController):
if self.doctype in ("Purchase Receipt", "Purchase Invoice"):
self.update_valuation_rate()
self.sync_accepted_packages()
self.set_serial_and_batch_bundle()
def onload(self):
@@ -152,10 +149,13 @@ class BuyingController(SubcontractingController):
for item in self.get("items"):
if item.get(field) and not item.serial_and_batch_bundle and bundle_ids.get(item.get(field)):
item.serial_and_batch_bundle = self.make_accepted_package(
item, bundle_ids.get(item.get(field))
item.serial_and_batch_bundle = self.make_package_for_transfer(
bundle_ids.get(item.get(field)),
item.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=item.qty,
)
elif (
not self.is_new()
and item.serial_and_batch_bundle
@@ -178,138 +178,6 @@ class BuyingController(SubcontractingController):
):
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
if item.get(field) and bundle_ids.get(item.get(field)):
self.set_rejected_package(item, bundle_ids.get(item.get(field)))
def make_accepted_package(self, row, package) -> str:
"""Package of the material the row accepts.
A row that rejects nothing keeps the package of the in-transit warehouse it came out of. A
row that rejects material needs a package of the accepted warehouse instead, since that is
the entry it belongs to; the material leaving the in-transit warehouse gets a package of its
own when the receipt is submitted.
"""
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=flt(row.stock_qty),
)
if not flt(row.stock_qty):
return ""
return self.make_package_for_transfer(
package,
row.warehouse,
type_of_transaction="Inward",
do_not_submit=True,
qty=flt(row.stock_qty),
exclude_serial_nos=self.get_rejected_serial_nos(row),
)
def get_delivered_package(self, row) -> str | None:
"""Package of the material the delivery note put in the in-transit warehouse."""
field = "delivery_note_item" if self.doctype == "Purchase Receipt" else "sales_invoice_item"
doctype = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item"
if not row.get(field):
return None
return frappe.db.get_value(doctype, row.get(field), "serial_and_batch_bundle")
def set_rejected_package(self, row, package) -> None:
"""Package of the material the row rejects.
A receipt of an internal transfer builds no package for it on its own, so rejected material
of a tracked item would have nothing to say where it came from.
"""
if not (self.is_internal_receipt() and flt(row.rejected_qty)) or self.is_return:
return
if row.get("rejected_serial_and_batch_bundle") or not row.rejected_warehouse:
return
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
row.rejected_serial_and_batch_bundle = self.make_package_for_transfer(
package,
row.rejected_warehouse,
type_of_transaction="Inward",
do_not_submit=True,
qty=rejected_qty,
exclude_serial_nos=self.get_accepted_serial_nos(row),
)
frappe.db.set_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "is_rejected", 1)
def get_accepted_serial_nos(self, row) -> list:
if not row.get("serial_and_batch_bundle"):
return []
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle, "serial_no": ("is", "set")},
pluck="serial_no",
)
def sync_accepted_packages(self) -> None:
"""Keep the package of a row in the shape its own entry needs.
A row that rejects material carries the package of its accepted warehouse; a row that
rejects nothing carries the package of the in-transit warehouse it came out of. Editing the
split moves the package from one to the other.
"""
if not self.is_internal_receipt() or self.is_return:
return
for row in self.get("items"):
package = row.get("serial_and_batch_bundle")
if not package:
continue
details = frappe.db.get_value(
"Serial and Batch Bundle",
package,
["warehouse", "type_of_transaction", "docstatus"],
as_dict=True,
)
if not details or details.docstatus != 0:
continue
if flt(row.rejected_qty):
wanted = (row.warehouse, "Inward")
else:
wanted = (row.from_warehouse, "Outward")
if (details.warehouse, details.type_of_transaction) == wanted:
continue
row.serial_and_batch_bundle = self.make_accepted_package(
row, self.get_delivered_package(row) or package
)
frappe.delete_doc("Serial and Batch Bundle", package, force=True, ignore_permissions=True)
def get_internal_transfer_qty(self, row) -> float:
if flt(row.qty) or not self.is_internal_receipt():
return flt(row.qty)
return flt(row.rejected_qty)
def get_rejected_serial_nos(self, row) -> list:
if not flt(row.get("rejected_qty")):
return []
if row.get("rejected_serial_and_batch_bundle"):
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.rejected_serial_and_batch_bundle, "serial_no": ("is", "set")},
pluck="serial_no",
)
return get_serial_nos(row.get("rejected_serial_no"))
def set_rate_for_standalone_debit_note(self):
if self.get("is_return") and self.get("update_stock") and not self.return_against:
for row in self.items:
@@ -601,7 +469,7 @@ class BuyingController(SubcontractingController):
net_rate = item.base_net_amount
if item.sales_incoming_rate: # for internal transfer
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
net_rate = item.qty * item.sales_incoming_rate
if (
not net_rate
@@ -612,7 +480,7 @@ class BuyingController(SubcontractingController):
):
net_rate = item.rejected_qty * item.net_rate
qty_in_stock_uom = flt(self.get_valued_qty(item) * item.conversion_factor)
qty_in_stock_uom = flt(item.qty * item.conversion_factor)
if not qty_in_stock_uom and item.get("rejected_qty"):
qty_in_stock_uom = flt(item.rejected_qty * item.conversion_factor)
@@ -627,14 +495,6 @@ class BuyingController(SubcontractingController):
update_regional_item_valuation_rate(self)
def get_valued_qty(self, row):
"""Quantity the net amount of the row was billed for, which is what its valuation spreads
over."""
if not flt(row.get("rejected_qty")) or not bills_rejected_quantity(self):
return flt(row.qty)
return flt(row.qty) + flt(row.rejected_qty)
def get_tax_details(self):
tax_accounts = []
total_valuation_amount = 0.0
@@ -760,11 +620,7 @@ class BuyingController(SubcontractingController):
return
if cint(self.get("is_return")):
# Material of a transfer goes back at the rate it came in with. Anything else is
# valued from the original item cost by its valuation method.
if self.is_internal_transfer():
self.set_sales_incoming_rate_for_internal_transfer()
# Get outgoing rate based on original item cost based on valuation method
return
if not self.is_internal_transfer():
@@ -805,13 +661,8 @@ class BuyingController(SubcontractingController):
}
ref_doctype = ref_doctype_map.get(self.doctype)
returned_field = frappe.scrub(self.doctype) + "_item"
for d in self.get("items"):
if self.get("is_return") and d.get(returned_field):
d.sales_incoming_rate = flt(
frappe.db.get_value(self.doctype + " Item", d.get(returned_field), "sales_incoming_rate")
)
elif not d.get(frappe.scrub(ref_doctype)):
if not d.get(frappe.scrub(ref_doctype)):
posting_time = self.get("posting_time")
if not posting_time:
posting_time = nowtime()
@@ -906,95 +757,6 @@ class BuyingController(SubcontractingController):
)
)
def is_internal_receipt(self) -> bool:
return self.is_internal_transfer() and self.is_stock_receipt()
def get_source_warehouse_qty(self, row, accepted_qty):
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return accepted_qty
if row.get("serial_and_batch_bundle") and not row.get("rejected_serial_and_batch_bundle"):
return accepted_qty
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
def get_accepted_warehouse_package(self, row, type_of_transaction, via_landed_cost_voucher):
"""Package for the entry into the accepted warehouse, which is the package of the row itself
when the row rejects material."""
if flt(row.rejected_qty) and self.is_internal_receipt() and not self.is_return:
return row.serial_and_batch_bundle
if self.is_internal_transfer() and not self.is_return and self.docstatus != 2:
return self.get_package_for_target_warehouse(
row,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
return row.serial_and_batch_bundle
def get_submitted_package(self, row, warehouse):
return frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "warehouse": warehouse, "is_cancelled": 0},
"serial_and_batch_bundle",
)
def get_source_warehouse_reversal_package(self, row, package):
if not (self.is_internal_transfer() and self.is_return):
return package
if existing_package := self.get_package_of_source_warehouse(row):
return existing_package
if not row.get("rejected_serial_and_batch_bundle"):
return self.get_package_for_target_warehouse(row, row.from_warehouse, "Inward")
return self.get_returned_source_package(row)
def get_source_warehouse_package(self, row, package):
if not (row.get("rejected_serial_and_batch_bundle") and self.is_internal_receipt()):
return package
if existing_package := self.get_package_of_source_warehouse(row):
return existing_package
if not package:
return self.make_package_for_transfer(
row.rejected_serial_and_batch_bundle, row.from_warehouse, type_of_transaction="Outward"
)
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def get_package_of_source_warehouse(self, row) -> str | None:
return frappe.db.get_value(
"Serial and Batch Bundle",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"warehouse": row.from_warehouse,
"docstatus": 1,
"is_cancelled": 0,
},
"name",
)
def get_returned_source_package(self, row):
return self.make_package_for_transfer(
row.serial_and_batch_bundle,
row.from_warehouse,
type_of_transaction="Inward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
self.update_ordered_and_reserved_qty()
@@ -1005,114 +767,114 @@ class BuyingController(SubcontractingController):
if d.item_code not in stock_items:
continue
source_reversal_sle = None
if d.warehouse:
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_qty = self.get_source_warehouse_qty(d, pr_qty)
if pr_qty:
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
):
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
"serial_and_batch_bundle",
)
if source_qty and (d.warehouse or not pr_qty):
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
):
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
"serial_and_batch_bundle",
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
"dependant_sle_voucher_detail_no": d.name,
"serial_and_batch_bundle": serial_and_batch_bundle,
},
)
from_warehouse_sle = self.get_sl_entries(
sl_entries.append(from_warehouse_sle)
type_of_transaction = "Inward"
if self.docstatus == 2:
type_of_transaction = "Outward"
sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
"dependant_sle_voucher_detail_no": d.name,
"serial_and_batch_bundle": self.get_source_warehouse_package(
d, serial_and_batch_bundle
"actual_qty": flt(pr_qty),
"serial_and_batch_bundle": (
d.serial_and_batch_bundle
if not self.is_internal_transfer()
or self.is_return
or (self.is_internal_transfer() and self.docstatus == 2)
else self.get_package_for_target_warehouse(
d,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
),
},
)
sl_entries.append(from_warehouse_sle)
type_of_transaction = "Inward"
if self.docstatus == 2:
type_of_transaction = "Outward"
sle = self.get_sl_entries(
d,
{
"actual_qty": flt(pr_qty),
"serial_and_batch_bundle": self.get_accepted_warehouse_package(
d, type_of_transaction, via_landed_cost_voucher
),
},
)
if self.is_return:
outgoing_rate = 0.0
if not is_serial_no_wise_valuation_disabled(d.item_code):
if self.is_return:
outgoing_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
)
sle.update(
{
"outgoing_rate": outgoing_rate,
"recalculate_rate": 1,
"serial_and_batch_bundle": d.serial_and_batch_bundle,
}
)
if d.from_warehouse:
sle.dependant_sle_voucher_detail_no = d.name
else:
sle.update(
{
"incoming_rate": d.valuation_rate,
"recalculate_rate": 1
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
else 0,
}
)
sl_entries.append(sle)
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 2)
or (cint(self.is_return) and self.docstatus == 1)
):
serial_and_batch_bundle = None
if self.is_internal_transfer() and self.docstatus == 2:
reversed_warehouse = (
d.from_warehouse if d.get("rejected_serial_and_batch_bundle") else d.warehouse
sle.update(
{
"outgoing_rate": outgoing_rate,
"recalculate_rate": 1,
"serial_and_batch_bundle": d.serial_and_batch_bundle,
}
)
serial_and_batch_bundle = self.get_submitted_package(d, reversed_warehouse)
if d.from_warehouse:
sle.dependant_sle_voucher_detail_no = d.name
else:
sle.update(
{
"incoming_rate": d.valuation_rate,
"recalculate_rate": 1
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
else 0,
}
)
sl_entries.append(sle)
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": self.get_source_warehouse_reversal_package(
d, serial_and_batch_bundle
),
},
)
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 2)
or (cint(self.is_return) and self.docstatus == 1)
):
serial_and_batch_bundle = None
if self.is_internal_transfer() and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.warehouse},
"serial_and_batch_bundle",
)
if self.is_internal_transfer() and self.is_return:
from_warehouse_sle.incoming_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": (
self.get_package_for_target_warehouse(d, d.from_warehouse, "Inward")
if self.is_internal_transfer() and self.is_return
else serial_and_batch_bundle
),
},
)
source_reversal_sle = from_warehouse_sle
sl_entries.append(from_warehouse_sle)
if flt(d.rejected_qty) != 0:
valuation_rate_for_rejected_item = 0.0
if is_rejected_material_valued(self.doctype, d.name):
if is_rejected_material_valued(self.doctype):
valuation_rate_for_rejected_item = d.valuation_rate
sl_entries.append(
@@ -1130,9 +892,6 @@ class BuyingController(SubcontractingController):
)
)
if source_reversal_sle:
sl_entries.append(source_reversal_sle)
self.make_sl_entries(
sl_entries,
allow_negative_stock=allow_negative_stock,

View File

@@ -170,7 +170,7 @@ def validate_returned_items(doc):
"Delivery Note",
"POS Invoice",
):
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0 or flt(d.get("rejected_qty")) < 0:
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
items_returned = True
else:
items_returned = True
@@ -736,7 +736,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
if return_against_rejected_qty:
return doc.rejected_qty
return doc.qty or doc.get("rejected_qty")
return doc.qty
doclist = get_mapped_doc(
doctype,
@@ -885,13 +885,8 @@ def get_filters(
if reference_voucher_detail_no:
warehouses = get_warehouses_for_return(voucher_type, reference_voucher_detail_no)
# A row that accepted nothing goes back at the rate the rejected warehouse received it at.
warehouse_field = "warehouse"
if not flt(item_row.get("qty")) and flt(item_row.get("rejected_qty")):
warehouse_field = "rejected_warehouse"
if item_row.get(warehouse_field) and item_row.get(warehouse_field) in warehouses:
filters["warehouse"] = item_row.get(warehouse_field)
if item_row.get("warehouse") and item_row.get("warehouse") in warehouses:
filters["warehouse"] = item_row.get("warehouse")
return filters

View File

@@ -260,25 +260,12 @@ class StockController(AccountsController):
return SerialBatchBundleService(self).set_serial_and_batch_bundle(table_name, ignore_validate)
def make_package_for_transfer(
self,
serial_and_batch_bundle,
warehouse,
type_of_transaction=None,
do_not_submit=None,
qty=0,
include_bundle=None,
exclude_serial_nos=None,
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
):
from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService
return SerialBatchBundleService(self).make_package_for_transfer(
serial_and_batch_bundle,
warehouse,
type_of_transaction,
do_not_submit,
qty,
include_bundle,
exclude_serial_nos,
serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty
)
def get_sl_entries(self, d, args):
@@ -954,20 +941,10 @@ def make_bundle_for_material_transfer(**kwargs):
bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no
bundle_doc.is_cancelled = 0
if kwargs.include_bundle:
for entry in frappe.get_doc("Serial and Batch Bundle", kwargs.include_bundle).entries:
bundle_doc.append("entries", entry.as_dict(no_default_fields=True))
if kwargs.exclude_serial_nos:
keep = [row for row in bundle_doc.entries if row.serial_no not in set(kwargs.exclude_serial_nos)]
bundle_doc.entries = keep
for idx, row in enumerate(keep, start=1):
row.idx = idx
qty = 0
if (
len(bundle_doc.entries) == 1
and abs(flt(kwargs.qty)) < abs(flt(bundle_doc.total_qty))
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
and not bundle_doc.has_serial_no
):
qty = kwargs.qty

View File

@@ -13,7 +13,6 @@ from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules
from erpnext.buying.doctype.buying_settings.buying_settings import bills_rejected_quantity
from erpnext.controllers.accounts_controller import (
validate_conversion_rate,
validate_inclusive_tax,
@@ -242,19 +241,13 @@ class calculate_taxes_and_totals:
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
item.amount = flt(item.rate * self.get_billed_qty(item), item.precision("amount"))
item.amount = flt(item.rate * item.qty, item.precision("amount"))
item.net_amount = item.amount
self._set_in_company_currency(
item, ["price_list_rate", "rate_with_margin", "rate", "net_rate", "amount", "net_amount"]
)
item.item_tax_amount = 0.0
def get_billed_qty(self, item):
if not flt(item.get("rejected_qty")) or not bills_rejected_quantity(self.doc):
return flt(item.qty)
return flt(item.qty) + flt(item.rejected_qty)
def _set_in_company_currency(self, doc, fields):
"""set values in base currency"""
for f in fields:
@@ -346,7 +339,7 @@ class calculate_taxes_and_totals:
item._unrounded_net_amount = amount / (1 + total_tax_slope)
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
item.net_rate = flt(item.net_amount / self.get_billed_qty(item), item.precision("net_rate"))
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.discount_percentage = flt(
item.discount_percentage, item.precision("discount_percentage")
)
@@ -952,9 +945,8 @@ class calculate_taxes_and_totals:
)
net_total += rounding_difference
billed_qty = self.get_billed_qty(item)
item.net_rate = (
flt(item.net_amount / billed_qty, item.precision("net_rate")) if billed_qty else 0
flt(item.net_amount / item.qty, item.precision("net_rate")) if item.qty else 0
)
self._set_in_company_currency(item, ["net_rate", "net_amount"])

View File

@@ -1,59 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# For license information, please see license.txt
from unittest.mock import Mock, patch
import frappe
from frappe.tests import UnitTestCase
from erpnext.controllers.accounts_controller import AccountsController
class TestPriceListCurrency(UnitTestCase):
def test_price_list_currency_transition(self):
cases = (
("USD", "CDF", 1, 0.000444444, True),
("EUR", "CDF", 1.2, 0.000444444, True),
("CDF", "USD", 0.000444444, 1, False),
("CDF", "CDF", 0.0005, 0.0005, False),
("CDF", "CDF", 0, 0.000444444, True),
(None, "CDF", 0.0005, 0.0005, False),
)
for direction in ("Selling", "Buying"):
for previous_currency, currency, previous_rate, expected_rate, fetch_rate in cases:
with self.subTest(
direction=direction,
previous_currency=previous_currency,
currency=currency,
previous_rate=previous_rate,
):
doc = frappe._dict(
meta=Mock(),
posting_date="2026-09-18",
selling_price_list="New Selling Price List",
buying_price_list="New Buying Price List",
price_list_currency=previous_currency,
plc_conversion_rate=previous_rate,
company_currency="USD",
currency="CDF",
conversion_rate=0.000444444,
)
with (
patch("erpnext.controllers.accounts_controller.frappe") as mock_frappe,
patch(
"erpnext.controllers.accounts_controller.get_exchange_rate",
return_value=0.000444444,
) as exchange_rate,
):
mock_frappe.db.get_value.return_value = currency
mock_frappe.db.get_single_value.return_value = False
AccountsController.set_price_list_currency(doc, direction)
self.assertEqual(doc.price_list_currency, currency)
self.assertEqual(doc.plc_conversion_rate, expected_rate)
self.assertEqual(doc.conversion_rate, 0.000444444)
if fetch_rate:
exchange_rate.assert_called_once_with(
currency, "USD", "2026-09-18", f"for_{direction.lower()}"
)
else:
exchange_rate.assert_not_called()

View File

@@ -1,93 +0,0 @@
import frappe
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
class TestPriceListValidation(ERPNextTestSuite):
def create_price_list(self, selling=0, buying=0, enabled=1):
return (
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": selling,
"buying": buying,
"enabled": enabled,
}
)
.insert()
.name
)
def test_selling_transaction_should_reject_a_buying_price_list(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(buying=1)
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
invoice.save()
def test_buying_transaction_should_reject_a_selling_price_list(self):
invoice = make_purchase_invoice(do_not_save=1)
invoice.buying_price_list = self.create_price_list(selling=1)
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
invoice.save()
def test_a_price_list_for_both_sides_should_be_accepted(self):
price_list = self.create_price_list(selling=1, buying=1)
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = price_list
invoice.save()
self.assertEqual(invoice.selling_price_list, price_list)
def test_a_missing_price_list_should_report_rather_than_crash(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = frappe.generate_hash(length=10)
with self.assertRaises(frappe.ValidationError):
invoice.validate_price_list()
def test_internal_transfer_should_keep_the_outward_price_list(self):
"""The inward document of an internal transfer takes the price list of the outward one, which
is flagged for the opposite side."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
selling_only = self.create_price_list(selling=1)
delivery_note = create_delivery_note(
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
warehouse="Stores - TCP1",
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
do_not_submit=1,
)
delivery_note.selling_price_list = selling_only
delivery_note.save()
delivery_note.submit()
receipt = make_inter_company_purchase_receipt(delivery_note.name)
receipt.items[0].warehouse = "Stores - TCP1"
receipt.save()
self.assertEqual(receipt.buying_price_list, selling_only)
def test_disabled_price_list_should_still_report_as_disabled(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
invoice.save()

View File

@@ -285,7 +285,7 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
}
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
this.frm.doc.opportunity_type = "Sales";
this.frm.doc.opportunity_type = __("Sales");
}
this.setup_queries();
}

View File

@@ -166,7 +166,7 @@ class Opportunity(TransactionBase, CRMNote):
def set_opportunity_type(self):
if self.is_new() and not self.opportunity_type:
self.opportunity_type = "Sales"
self.opportunity_type = _("Sales")
def set_exchange_rate(self):
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")

View File

@@ -523,4 +523,3 @@ erpnext.patches.v16_0.set_supplier_quotation_order_status
erpnext.patches.v16_0.recalculate_holiday_list_totals
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
erpnext.patches.v16_0.rename_component_cost_valuation_type
erpnext.patches.v16_0.enable_serial_no_wise_valuation

View File

@@ -1,11 +0,0 @@
import frappe
def execute():
item = frappe.qb.DocType("Item")
(
frappe.qb.update(item)
.set(item.use_serial_no_wise_valuation, 1)
.where((item.has_serial_no == 1) & (item.use_serial_no_wise_valuation == 0))
).run()

View File

@@ -157,24 +157,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
.filter((fieldname) => !do_not_round_fields.includes(fieldname));
}
get_billed_qty(item) {
const settings = frappe.boot.sysdefaults || {};
const is_internal_transfer =
this.frm.doc.is_internal_supplier && this.frm.doc.represents_company === this.frm.doc.company;
const bills_rejected_quantity =
this.frm.doc.doctype === "Purchase Invoice" &&
this.frm.doc.update_stock &&
!is_internal_transfer &&
cint(settings.set_valuation_rate_for_rejected_materials) &&
cint(settings.bill_for_rejected_quantity_in_purchase_invoice);
if (!flt(item.rejected_qty) || !bills_rejected_quantity) {
return flt(item.qty);
}
return flt(item.qty) + flt(item.rejected_qty);
}
calculate_item_values() {
var me = this;
if (!this.discount_amount_applied) {
@@ -185,10 +167,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
if (!(me.frm.doc.is_return || me.frm.doc.is_debit_note)) {
item.net_amount = item.amount = flt(
item.rate * me.get_billed_qty(item),
precision("amount", item)
);
item.net_amount = item.amount = flt(item.rate * item.qty, precision("amount", item));
} else {
// allow for '0' qty on Credit/Debit notes
let qty = flt(item.qty);

View File

@@ -252,6 +252,7 @@ class SalesOrder(SellingController):
self.validate_warehouse()
self.validate_drop_ship()
SalesOrderStockReservation(self).validate_reserved_stock()
self.validate_serial_no_based_delivery()
validate_against_blanket_order(self)
validate_inter_company_party(
self.doctype, self.customer, self.company, self.inter_company_order_reference
@@ -693,6 +694,41 @@ class SalesOrder(SellingController):
),
)
def validate_serial_no_based_delivery(self):
reserved_items = []
normal_items = []
for item in self.items:
if item.ensure_delivery_based_on_produced_serial_no:
if item.item_code in normal_items:
frappe.throw(
_(
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
).format(item.item_code)
)
if item.item_code not in reserved_items:
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
frappe.throw(
_(
"Item {0} has no Serial No. Only serialized items can have delivery based on Serial No"
).format(item.item_code)
)
if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}):
frappe.throw(
_(
"No active BOM found for item {0}. Delivery by Serial No cannot be ensured"
).format(item.item_code)
)
reserved_items.append(item.item_code)
else:
normal_items.append(item.item_code)
if not item.ensure_delivery_based_on_produced_serial_no and item.item_code in reserved_items:
frappe.throw(
_(
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
).format(item.item_code)
)
@frappe.whitelist()
def has_unreserved_stock(self, table_name: str = "items") -> dict:
"""Returns unreserved qty per item if there is any unreserved item in the Sales Order."""

View File

@@ -14,6 +14,7 @@
"is_product_bundle",
"product_bundle",
"customer_item_code",
"ensure_delivery_based_on_produced_serial_no",
"is_stock_item",
"reserve_stock",
"col_break1",
@@ -164,6 +165,12 @@
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "ensure_delivery_based_on_produced_serial_no",
"fieldtype": "Check",
"label": "Ensure Delivery Based on Produced Serial No"
},
{
"fieldname": "col_break1",
"fieldtype": "Column Break"
@@ -1070,7 +1077,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-09-21 10:12:00.000000",
"modified": "2026-08-27 11:55:37.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -42,6 +42,7 @@ class SalesOrderItem(Document):
discount_amount: DF.Currency
discount_percentage: DF.Percent
distributed_discount_amount: DF.Currency
ensure_delivery_based_on_produced_serial_no: DF.Check
fg_item: DF.Link | None
fg_item_qty: DF.Float
grant_commission: DF.Check

View File

@@ -89,30 +89,6 @@ frappe.query_reports["Sales Order Analysis"] = {
label: __("Group by Sales Order"),
fieldtype: "Check",
default: 0,
on_change: (report) => {
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
report.set_filter_value("group_by_item", 0);
return;
}
if (!report._no_refresh) {
report.refresh(true);
}
},
},
{
fieldname: "group_by_item",
label: __("Group by Item"),
fieldtype: "Check",
default: 0,
on_change: (report) => {
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
report.set_filter_value("group_by_so", 0);
return;
}
if (!report._no_refresh) {
report.refresh(true);
}
},
},
],

View File

@@ -8,23 +8,18 @@ import frappe
from frappe import _, qb
from frappe.query_builder import Case, CustomFunction
from frappe.query_builder.functions import Coalesce, DateDiff, Max, Sum
from frappe.utils import date_diff, flt, nowdate
import erpnext
from frappe.utils import date_diff, flt, getdate, nowdate
def execute(filters=None):
if not filters:
return [], [], None, []
filters = frappe._dict(filters)
filters.company = filters.get("company") or erpnext.get_default_company()
validate_filters(filters)
columns = get_columns(filters)
data = get_data(filters)
so_elapsed_time = {} if filters.get("group_by_item") else get_so_elapsed_time(data)
so_elapsed_time = get_so_elapsed_time(data)
if not data:
return [], [], None, []
@@ -35,9 +30,6 @@ def execute(filters=None):
def validate_filters(filters):
if not filters.get("company"):
frappe.throw(_("{0} is mandatory").format(_("Company")))
from_date, to_date = filters.get("from_date"), filters.get("to_date")
if not from_date and to_date:
@@ -45,9 +37,6 @@ def validate_filters(filters):
elif date_diff(to_date, from_date) < 0:
frappe.throw(_("To Date cannot be before From Date."))
if filters.get("group_by_so") and filters.get("group_by_item"):
frappe.throw(_("Group the report by Sales Order or by Item, not both."))
def get_data(filters):
so = qb.DocType("Sales Order")
@@ -76,7 +65,6 @@ def get_data(filters):
so.status,
so.customer,
soi.item_code,
soi.uom,
delay.as_("delay_days"),
Case().when(so.status.isin(["Completed", "To Bill"]), 0).else_(delay).as_("delay"),
soi.qty,
@@ -93,7 +81,6 @@ def get_data(filters):
soi.description.as_("description"),
)
.where((so.status.notin(["Stopped", "On Hold"])) & (so.docstatus == 1))
.where(so.company == filters.get("company"))
.groupby(soi.name, so.name)
.orderby(so.transaction_date)
.orderby(soi.item_code)
@@ -101,6 +88,8 @@ def get_data(filters):
if filters.get("from_date") and filters.get("to_date"):
query = query.where(so.transaction_date[filters.get("from_date") : filters.get("to_date")])
if filters.get("company"):
query = query.where(so.company == filters.get("company"))
if filters.get("sales_order"):
query = query.where(so.name.isin(filters.get("sales_order")))
if filters.get("status"):
@@ -162,86 +151,71 @@ def get_so_elapsed_time(data):
return so_elapsed_time
AGGREGATED_FIELDS = (
"qty",
"delivered_qty",
"pending_qty",
"billed_qty",
"qty_to_bill",
"amount",
"delivered_qty_amount",
"billed_amount",
"pending_amount",
)
def prepare_data(data, so_elapsed_time, filters):
completed, pending = 0, 0
if filters.get("group_by_so"):
sales_order_map = {}
for row in data:
# sum data for chart
completed += row["billed_amount"]
pending += row["pending_amount"]
# prepare data for report view
row["qty_to_bill"] = flt(row["qty"]) - flt(row["billed_qty"])
row["delay"] = 0 if row["delay"] and row["delay"] < 0 else row["delay"]
row["time_taken_to_deliver"] = (
so_elapsed_time.get((row.sales_order, row.item_code))
if row["status"] in ("To Bill", "Completed")
else 0
)
if filters.get("group_by_so"):
so_name = row["sales_order"]
if so_name not in sales_order_map:
# create an entry
row_copy = copy.deepcopy(row)
sales_order_map[so_name] = row_copy
else:
# update existing entry
so_row = sales_order_map[so_name]
so_row["required_date"] = max(getdate(so_row["delivery_date"]), getdate(row["delivery_date"]))
so_row["delay"] = (
min(so_row["delay"], row["delay"])
if row["delay"] and so_row["delay"]
else so_row["delay"]
)
# sum numeric columns
fields = [
"qty",
"delivered_qty",
"pending_qty",
"billed_qty",
"qty_to_bill",
"amount",
"delivered_qty_amount",
"billed_amount",
"pending_amount",
]
for field in fields:
so_row[field] = flt(row[field]) + flt(so_row[field])
chart_data = prepare_chart_data(pending, completed)
if filters.get("group_by_so"):
data = group_by_sales_order(data)
elif filters.get("group_by_item"):
data = group_by_item(data)
data = []
for so in sales_order_map:
data.append(sales_order_map[so])
return data, chart_data
return data, chart_data
def group_by_sales_order(data):
sales_order_map = {}
for row in data:
group = sales_order_map.get(row["sales_order"])
if not group:
sales_order_map[row["sales_order"]] = copy.deepcopy(row)
continue
group["delay"] = (
min(group["delay"], row["delay"]) if row["delay"] and group["delay"] else group["delay"]
)
add_aggregated_fields(group, row)
return list(sales_order_map.values())
def group_by_item(data):
"""Group on company and UOM as well as the item.
Quantities are in the line UOM and amounts are in the company currency, so neither sums
across a second UOM of the same item or a second company.
"""
item_map = {}
for row in data:
key = (row["company"], row["item_code"], row["uom"])
group = item_map.get(key)
if not group:
item_map[key] = copy.deepcopy(row)
continue
add_aggregated_fields(group, row)
return sorted(item_map.values(), key=lambda row: (row["company"], row["item_code"], row["uom"]))
def add_aggregated_fields(group, row):
for field in AGGREGATED_FIELDS:
group[field] = flt(group[field]) + flt(row[field])
def prepare_chart_data(pending, completed):
labels = [_("Amount to Bill"), _("Billed Amount")]
@@ -253,34 +227,7 @@ def prepare_chart_data(pending, completed):
def get_columns(filters):
if filters.get("group_by_item"):
return get_grouped_by_item_columns()
columns = get_sales_order_columns()
if not filters.get("group_by_so"):
columns += get_item_columns()
columns += get_quantity_columns() + get_amount_columns() + get_delivery_columns()
if not filters.get("group_by_so"):
columns.append(get_warehouse_column())
columns.append(get_company_column())
return columns
def get_grouped_by_item_columns():
columns = [get_item_code_column(), get_uom_column()]
columns += get_quantity_columns() + get_amount_columns()
columns.append(get_company_column())
return columns
def get_sales_order_columns():
return [
columns = [
{"label": _("Date"), "fieldname": "date", "fieldtype": "Date", "width": 90},
{
"label": _("Sales Order"),
@@ -299,139 +246,117 @@ def get_sales_order_columns():
},
]
if not filters.get("group_by_so"):
columns.append(
{
"label": _("Item Code"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 100,
}
)
columns.append(
{"label": _("Description"), "fieldname": "description", "fieldtype": "Small Text", "width": 100}
)
def get_item_columns():
return [
get_item_code_column(),
{"label": _("Description"), "fieldname": "description", "fieldtype": "Small Text", "width": 100},
]
def get_item_code_column():
return {
"label": _("Item Code"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 100,
}
def get_uom_column():
return {
"label": _("UOM"),
"fieldname": "uom",
"fieldtype": "Link",
"options": "UOM",
"width": 100,
}
def get_quantity_columns():
return [
columns.extend(
[
{
"label": _("Qty"),
"fieldname": "qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Delivered Qty"),
"fieldname": "delivered_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Qty to Deliver"),
"fieldname": "pending_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Billed Qty"),
"fieldname": "billed_qty",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
{
"label": _("Qty to Bill"),
"fieldname": "qty_to_bill",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
{
"label": _("Amount"),
"fieldname": "amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Billed Amount"),
"fieldname": "billed_amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Pending Amount"),
"fieldname": "pending_amount",
"fieldtype": "Currency",
"width": 130,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Amount Delivered"),
"fieldname": "delivered_qty_amount",
"fieldtype": "Currency",
"width": 100,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{"label": _("Delivery Date"), "fieldname": "delivery_date", "fieldtype": "Date", "width": 120},
{"label": _("Delay (in Days)"), "fieldname": "delay", "fieldtype": "Data", "width": 100},
{
"label": _("Time Taken to Deliver"),
"fieldname": "time_taken_to_deliver",
"fieldtype": "Duration",
"width": 100,
},
]
)
if not filters.get("group_by_so"):
columns.append(
{
"label": _("Warehouse"),
"fieldname": "warehouse",
"fieldtype": "Link",
"options": "Warehouse",
"width": 100,
}
)
columns.append(
{
"label": _("Qty"),
"fieldname": "qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Delivered Qty"),
"fieldname": "delivered_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Qty to Deliver"),
"fieldname": "pending_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Billed Qty"),
"fieldname": "billed_qty",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
{
"label": _("Qty to Bill"),
"fieldname": "qty_to_bill",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
]
def get_amount_columns():
return [
{
"label": _("Amount"),
"fieldname": "amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Billed Amount"),
"fieldname": "billed_amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Pending Amount"),
"fieldname": "pending_amount",
"fieldtype": "Currency",
"width": 130,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Amount Delivered"),
"fieldname": "delivered_qty_amount",
"fieldtype": "Currency",
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"width": 100,
"options": "Company:company:default_currency",
"convertible": "rate",
},
]
}
)
def get_delivery_columns():
return [
{"label": _("Delivery Date"), "fieldname": "delivery_date", "fieldtype": "Date", "width": 120},
{"label": _("Delay (in Days)"), "fieldname": "delay", "fieldtype": "Data", "width": 100},
{
"label": _("Time Taken to Deliver"),
"fieldname": "time_taken_to_deliver",
"fieldtype": "Duration",
"width": 100,
},
]
def get_warehouse_column():
return {
"label": _("Warehouse"),
"fieldname": "warehouse",
"fieldtype": "Link",
"options": "Warehouse",
"width": 100,
}
def get_company_column():
return {
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"width": 100,
}
return columns

View File

@@ -1,27 +1,20 @@
from unittest.mock import patch
import frappe
from frappe.utils import add_days
from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import (
AGGREGATED_FIELDS,
execute,
group_by_item,
)
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import execute
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesOrderAnalysis(ERPNextTestSuite):
def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False, qty=10, uom=None):
def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False):
item = create_item(item_code="_Test Excavator", is_stock_item=0)
so = make_sales_order(
transaction_date=transaction_date,
item=item.item_code,
qty=qty,
uom=uom,
qty=10,
rate=100000,
do_not_save=True,
)
@@ -35,17 +28,6 @@ class TestSalesOrderAnalysis(ERPNextTestSuite):
so.submit()
return item, so
def make_item_row(self, company, qty):
row = frappe._dict(dict.fromkeys(AGGREGATED_FIELDS, 0))
row.update({"company": company, "item_code": "_Test Excavator", "uom": "Nos", "qty": qty})
return row
def add_uom(self, item_code, uom, conversion_factor):
item = frappe.get_doc("Item", item_code)
if not any(row.uom == uom for row in item.uoms):
item.append("uoms", {"uom": uom, "conversion_factor": conversion_factor})
item.save()
def create_sales_invoice(self, so, do_not_save=False, do_not_submit=False):
sinv = make_sales_invoice(so.name)
sinv.posting_date = so.transaction_date
@@ -273,104 +255,3 @@ class TestSalesOrderAnalysis(ERPNextTestSuite):
for key, val in expected_value.items():
with self.subTest(key=key, val=val):
self.assertEqual(data[0][key], val)
def test_08_group_by_item_across_sales_orders(self):
transaction_date = "2021-06-01"
item, so1 = self.create_sales_order(transaction_date)
self.create_sales_order(transaction_date, qty=4)
dn = self.create_delivery_note(so1, do_not_save=True)
dn.items[0].qty = 3
dn.save().submit()
columns, data, message, chart = execute(
{
"company": "_Test Company",
"from_date": "2021-06-01",
"to_date": "2021-06-30",
"group_by_item": 1,
}
)
expected_value = {
"item_code": item.item_code,
"uom": "Nos",
"qty": 14,
"delivered_qty": 3,
"pending_qty": 11,
}
self.assertEqual(len(data), 1)
for key, val in expected_value.items():
with self.subTest(key=key, val=val):
self.assertEqual(data[0][key], val)
fieldnames = [column["fieldname"] for column in columns]
self.assertIn("uom", fieldnames)
self.assertNotIn("sales_order", fieldnames)
def test_09_group_by_item_keeps_each_uom_apart(self):
transaction_date = "2021-06-01"
item, so = self.create_sales_order(transaction_date)
self.add_uom(item.item_code, "Box", 10)
self.create_sales_order(transaction_date, qty=2, uom="Box")
columns, data, message, chart = execute(
{
"company": "_Test Company",
"from_date": "2021-06-01",
"to_date": "2021-06-30",
"group_by_item": 1,
}
)
self.assertEqual(len(data), 2)
self.assertEqual([(row["uom"], row["qty"]) for row in data], [("Box", 2), ("Nos", 10)])
def test_10_group_by_filters_cannot_be_combined(self):
self.assertRaises(
frappe.ValidationError,
execute,
{
"company": "_Test Company",
"from_date": "2021-06-01",
"to_date": "2021-06-30",
"group_by_so": 1,
"group_by_item": 1,
},
)
def test_11_group_by_item_keeps_each_company_apart(self):
rows = [
self.make_item_row("_Test Company", 10),
self.make_item_row("_Test Company 1", 4),
self.make_item_row("_Test Company", 6),
]
grouped = group_by_item(rows)
self.assertEqual(
[(row["company"], row["qty"]) for row in grouped],
[("_Test Company", 16), ("_Test Company 1", 4)],
)
def test_12_company_falls_back_to_the_default(self):
transaction_date = "2021-06-01"
item, so = self.create_sales_order(transaction_date)
filters = {"from_date": "2021-06-01", "to_date": "2021-06-30"}
with patch("erpnext.get_default_company", return_value="_Test Company"):
columns, data, message, chart = execute(filters)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["sales_order"], so.name)
with patch("erpnext.get_default_company", return_value="_Test Company 1"):
columns, data, message, chart = execute(filters)
self.assertNotIn(so.name, [row["sales_order"] for row in data])
def test_13_company_is_mandatory_without_a_default(self):
with patch("erpnext.get_default_company", return_value=None):
self.assertRaises(
frappe.ValidationError,
execute,
{"from_date": "2021-06-01", "to_date": "2021-06-30"},
)

View File

@@ -22,12 +22,6 @@ def boot_session(bootinfo):
frappe.get_single_value("Selling Settings", "use_legacy_js_reactivity")
)
bootinfo.sysdefaults.allow_stale = cint(frappe.get_single_value("Accounts Settings", "allow_stale"))
bootinfo.sysdefaults.bill_for_rejected_quantity_in_purchase_invoice = cint(
frappe.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")
)
bootinfo.sysdefaults.set_valuation_rate_for_rejected_materials = cint(
frappe.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
)
bootinfo.sysdefaults.over_billing_allowance = frappe.get_single_value(
"Accounts Settings", "over_billing_allowance"
)

View File

@@ -3,10 +3,10 @@ import json
from collections import defaultdict
import frappe
from frappe.query_builder.functions import Locate, Sum
from frappe.query_builder.functions import Sum
from frappe.utils import flt
from pypika import Order
from pypika.functions import Coalesce, Concat, Lower
from pypika.functions import Coalesce
from erpnext.deprecation_dumpster import deprecated
@@ -64,51 +64,36 @@ class DeprecatedSerialNoValuation:
incoming_values += self.serial_no_incoming_rate[serial_no]
continue
for sle in self.get_last_inward_sle_for_serial_no(serial_no, posting_datetime):
table = frappe.qb.DocType("Stock Ledger Entry")
stock_ledgers = (
frappe.qb.from_(table)
.select(table.incoming_rate, table.actual_qty, table.stock_value_difference)
.where(
(
(table.serial_no == serial_no)
| (table.serial_no.like(serial_no + "\n%"))
| (table.serial_no.like("%\n" + serial_no))
| (table.serial_no.like("%\n" + serial_no + "\n%"))
)
& (table.item_code == self.sle.item_code)
& (table.company == self.sle.company)
& (table.warehouse == self.sle.warehouse)
& (table.serial_and_batch_bundle.isnull())
& (table.actual_qty > 0)
& (table.is_cancelled == 0)
& table.posting_datetime
<= posting_datetime
)
.orderby(table.posting_datetime, order=Order.desc)
.limit(1)
).run(as_dict=1)
for sle in stock_ledgers:
self.serial_no_incoming_rate[serial_no] += flt(sle.incoming_rate)
incoming_values += self.serial_no_incoming_rate[serial_no]
return incoming_values
def get_last_inward_sle_for_serial_no(self, serial_no, posting_datetime):
table = frappe.qb.DocType("Stock Ledger Entry")
serial_no_column = table.serial_no
needle = serial_no
padded_needle = f"\n{serial_no}\n"
if frappe.db.db_type != "mariadb":
# Locate maps to strpos on PostgreSQL and instr on SQLite, both case sensitive, while
# MariaDB compares serial_no under a case insensitive collation. Lower both operands so
# a mixed case serial no resolves the same legacy Stock Ledger Entry on every database.
serial_no_column = Lower(serial_no_column)
needle = needle.lower()
padded_needle = padded_needle.lower()
query = (
frappe.qb.from_(table)
.select(table.incoming_rate, table.actual_qty, table.stock_value_difference)
.where(
(table.item_code == self.sle.item_code)
& (table.company == self.sle.company)
& (table.warehouse == self.sle.warehouse)
& (table.posting_datetime <= posting_datetime)
& (table.is_cancelled == 0)
& (table.actual_qty > 0)
& (table.serial_and_batch_bundle.isnull())
& (Locate(needle, serial_no_column) > 0)
& (Locate(padded_needle, Concat("\n", serial_no_column, "\n")) > 0)
)
.orderby(table.posting_datetime, order=Order.desc)
.orderby(table.creation, order=Order.desc)
.limit(1)
)
if frappe.db.db_type == "mariadb":
query = query.force_index("item_code_warehouse_posting_datetime_creation_index")
return query.run(as_dict=1)
class DeprecatedBatchNoValuation:
@deprecated(

View File

@@ -97,27 +97,6 @@ class TestBin(ERPNextTestSuite):
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
def test_cancelling_transfer_restores_bin_stock_value(self):
"""Cancelling a transfer must put back the stock value of both bins, not just the quantity."""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = make_item().name
source = "_Test Warehouse - _TC"
target = "_Test Warehouse 1 - _TC"
make_stock_entry(item_code=item_code, target=source, qty=10, rate=100, posting_time="01:00:00")
make_stock_entry(item_code=item_code, target=target, qty=10, rate=200, posting_time="02:00:00")
se = make_stock_entry(
item_code=item_code, source=source, target=target, qty=5, posting_time="03:00:00"
)
se.cancel()
for warehouse, valuation_rate, stock_value in ((source, 100, 1000), (target, 200, 2000)):
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
self.assertEqual(bin.actual_qty, 10)
self.assertEqual(bin.valuation_rate, valuation_rate)
self.assertEqual(bin.stock_value, stock_value)
def test_deleting_last_voucher_resets_bin(self):
"""Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared."""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry

View File

@@ -1804,229 +1804,6 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(dn.items[0].rate, rate)
self.assertEqual(dn.items[0].net_rate, rate)
def test_internal_transfer_carries_the_batch_into_transit(self):
"""Material sent to an in-transit warehouse keeps the batch it left the source warehouse with."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
item = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-TRANSIT-BATCH-.####",
}
).name
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item)
dn = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
packages = {
d.warehouse: d.name
for d in frappe.get_all(
"Serial and Batch Bundle", filters={"voucher_no": dn.name}, fields=["name", "warehouse"]
)
}
sent_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[warehouse]}, "batch_no"
)
received_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[transit_warehouse]}, "batch_no"
)
self.assertEqual(received_batch, sent_batch)
self.assertEqual(frappe.db.count("Batch", {"item": item}), 1)
def test_internal_transfer_carries_the_batch_of_a_bundle_component(self):
"""A batched component of a product bundle keeps its batch on the way to transit."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
component = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-BUNDLE-BATCH-.####",
}
).name
bundle_item = make_item(properties={"is_stock_item": 0}).name
make_product_bundle(bundle_item, [component], qty=1)
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=component)
dn = create_delivery_note(
item_code=bundle_item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
packages = {
d.warehouse: d.name
for d in frappe.get_all(
"Serial and Batch Bundle", filters={"voucher_no": dn.name}, fields=["name", "warehouse"]
)
}
sent_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[warehouse]}, "batch_no"
)
received_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[transit_warehouse]}, "batch_no"
)
self.assertEqual(received_batch, sent_batch)
self.assertEqual(frappe.db.count("Batch", {"item": component}), 1)
def test_internal_transfer_of_a_bundle_with_a_repeated_component(self):
"""A component listed twice on a bundle keeps its batch on both packed rows."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
component = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-REPEATED-BATCH-.####",
}
).name
bundle_item = make_item(properties={"is_stock_item": 0}).name
product_bundle = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": bundle_item})
product_bundle.append("items", {"item_code": component, "qty": 1})
product_bundle.append("items", {"item_code": component, "qty": 2})
product_bundle.insert()
product_bundle.submit()
make_stock_entry(target=warehouse, qty=20, basic_rate=100, item_code=component)
customer = create_internal_customer(represents_company=company)
dn = create_delivery_note(
item_code=bundle_item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
received = frappe.get_all(
"Serial and Batch Bundle",
filters={"voucher_no": dn.name, "warehouse": transit_warehouse},
pluck="total_qty",
)
self.assertEqual(sorted(received), [5, 10])
self.assertEqual(frappe.db.count("Batch", {"item": component}), 1)
def test_internal_transfer_return_carries_the_batch_back(self):
"""Material coming back from an in-transit warehouse returns under the batch it left with."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
item = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-RETURNED-BATCH-.####",
}
).name
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item)
dn = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
returned = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=-5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
is_return=1,
return_against=dn.name,
)
received_package = frappe.db.get_value(
"Serial and Batch Bundle", {"voucher_no": returned.name, "warehouse": warehouse}
)
self.assertEqual(
frappe.db.get_value("Serial and Batch Entry", {"parent": received_package}, "batch_no"),
frappe.db.get_value(
"Serial and Batch Entry",
{
"parent": frappe.db.get_value(
"Serial and Batch Bundle", {"voucher_no": dn.name, "warehouse": warehouse}
)
},
"batch_no",
),
)
self.assertEqual(frappe.db.count("Batch", {"item": item}), 1)
def test_internal_transfer_of_an_item_that_cannot_create_batches(self):
"""An item whose batches are made by hand travels through an in-transit warehouse."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
item = make_item(properties={"has_batch_no": 1, "create_new_batch": 0}).name
batch = frappe.get_doc({"doctype": "Batch", "batch_id": f"_T-MANUAL-{item}", "item": item}).insert()
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item, batch_no=batch.name)
with self.change_settings("Stock Settings", auto_create_serial_and_batch_bundle_for_outward=1):
dn = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
received_package = frappe.db.get_value(
"Serial and Batch Bundle", {"voucher_no": dn.name, "warehouse": transit_warehouse}
)
self.assertEqual(
frappe.db.get_value("Serial and Batch Entry", {"parent": received_package}, "batch_no"),
batch.name,
)
def test_internal_transfer_precision_gle(self):
from erpnext.selling.doctype.customer.test_customer import create_internal_customer

View File

@@ -99,7 +99,6 @@
"column_break_37",
"has_serial_no",
"serial_no_series",
"use_serial_no_wise_valuation",
"variants_section",
"variant_of",
"variant_based_on",
@@ -388,7 +387,6 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Valuation Method",
"description": "Serialized items are valued at Moving Average once stock transactions exist and Serial No Wise Valuation is disabled.",
"options": "\nFIFO\nMoving Average\nLIFO\nStandard Cost"
},
{
@@ -531,15 +529,6 @@
"label": "Serial Number Series",
"show_description_on_click": 1
},
{
"default": "1",
"depends_on": "eval:doc.is_stock_item && doc.has_serial_no",
"description": "Value every outward movement at each Serial No's own incoming rate. If unchecked, the item is valued at Moving Average once stock transactions exist. Cannot be enabled once Serial Nos exist for this item.",
"fieldname": "use_serial_no_wise_valuation",
"fieldtype": "Check",
"label": "Use Serial No Wise Valuation",
"show_description_on_click": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "attributes",
@@ -1129,7 +1118,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-09-16 12:00:00.000000",
"modified": "2026-09-07 19:53:27.830229",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -146,7 +146,6 @@ class Item(Document):
taxes: DF.Table[ItemTax]
total_projected_qty: DF.Float
uoms: DF.Table[UOMConversionDetail]
use_serial_no_wise_valuation: DF.Check
valuation_method: DF.Literal["", "FIFO", "Moving Average", "LIFO", "Standard Cost"]
valuation_rate: DF.Currency
variant_based_on: DF.Literal["Item Attribute", "Manufacturer"]
@@ -245,8 +244,6 @@ class Item(Document):
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
self.validate_serialized_change_with_bundle()
self.validate_serial_no_wise_valuation()
self.set_valuation_method_for_serial_no_wise_valuation()
self.validate_standard_cost_change()
self.validate_item_tax_net_rate_range()
@@ -254,11 +251,8 @@ class Item(Document):
self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group")
def on_update(self):
from erpnext.stock.utils import clear_valuation_method_cache
self.update_variants()
self.update_item_price()
clear_valuation_method_cache()
def validate_description(self):
"""Clean HTML description if set"""
@@ -1167,49 +1161,6 @@ class Item(Document):
frappe.throw(msg, title=_("Linked with submitted documents"))
def validate_serial_no_wise_valuation(self):
if self.is_new() or not self._doc_before_save:
return
if not self.use_serial_no_wise_valuation or self._doc_before_save.use_serial_no_wise_valuation:
return
if frappe.db.exists("Serial No", {"item_code": self.name}):
frappe.throw(
_(
"Serial No Wise Valuation cannot be enabled for Item {0} because Serial Nos already exist for it. Valuation for those Serial Nos was not tracked, so enabling it now would value outward entries incorrectly."
).format(frappe.bold(self.name)),
title=_("Serial Nos Exist"),
)
def set_valuation_method_for_serial_no_wise_valuation(self):
if not self.has_serial_no or self.use_serial_no_wise_valuation:
return
# Only the switch turning off forces Moving Average, because the per serial costs already in the
# ledger cannot be replayed as a FIFO queue. An item that has always had the switch off keeps its
# own method, so an unrelated save cannot silently revalue a ledger nothing reposts.
if self._doc_before_save and not self._doc_before_save.use_serial_no_wise_valuation:
return
if not frappe.db.exists("Stock Ledger Entry", {"item_code": self.name, "is_cancelled": 0}):
return
if (
not self.is_new()
and self._doc_before_save
and self.has_value_changed("valuation_method")
and self.valuation_method in ("FIFO", "LIFO", "Standard Cost")
):
frappe.throw(
_(
"Valuation Method for Item {0} must be Moving Average because Serial No Wise Valuation is disabled. Enable Serial No Wise Valuation to use FIFO, LIFO or Standard Cost."
).format(frappe.bold(self.name)),
title=_("Invalid Valuation Method"),
)
self.valuation_method = "Moving Average"
def validate_serialized_change_with_bundle(self):
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""

View File

@@ -73,42 +73,24 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
}
set_applicable_charges_for_item() {
var me = this;
if (this.frm.doc.taxes.length) {
var total_item_cost = 0.0;
var based_on = this.frm.doc.distribute_charges_based_on.toLowerCase();
if (based_on != "distribute manually") {
var items = this.frm.doc.items || [];
items.forEach((item) => {
total_item_cost += flt(item[based_on]);
$.each(this.frm.doc.items || [], function (i, d) {
total_item_cost += flt(d[based_on]);
});
if (items.length) {
total_item_cost = flt(total_item_cost, precision(based_on, items[0]));
}
if (!total_item_cost) {
items.forEach((item) => {
item.applicable_charges = 0;
});
refresh_field("items");
if (items.length) {
frappe.show_alert({
message: __(
"Total {0} of all items is zero, charges cannot be distributed on it.",
[this.frm.doc.distribute_charges_based_on]
),
indicator: "red",
});
}
return;
}
var total_charges = 0.0;
items.forEach((item) => {
$.each(this.frm.doc.items || [], function (i, item) {
item.applicable_charges =
(flt(item[based_on]) * flt(me.frm.doc.total_taxes_and_charges)) /
flt(total_item_cost);
item.applicable_charges = flt(
(flt(item[based_on]) * flt(this.frm.doc.total_taxes_and_charges)) /
flt(total_item_cost),
item.applicable_charges,
precision("applicable_charges", item)
);
total_charges += item.applicable_charges;
@@ -116,7 +98,7 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
if (total_charges != this.frm.doc.total_taxes_and_charges) {
var diff = this.frm.doc.total_taxes_and_charges - flt(total_charges);
items.slice(-1)[0].applicable_charges += diff;
this.frm.doc.items.slice(-1)[0].applicable_charges += diff;
}
refresh_field("items");
}

View File

@@ -67,7 +67,7 @@ class LandedCostVoucher(Document):
item.item_code = d.item_code
item.description = d.description
item.qty = d.qty
item.rate = d.base_rate
item.rate = d.get("base_rate") or d.get("rate")
item.cost_center = d.cost_center or erpnext.get_default_cost_center(self.company)
item.amount = d.base_amount
item.receipt_document_type = pr.receipt_document_type
@@ -306,24 +306,22 @@ class LandedCostVoucher(Document):
def set_applicable_charges_on_item(self):
if self.get("taxes") and self.distribute_charges_based_on != "Distribute Manually":
items = self.get("items")
total_item_cost = 0.0
total_charges = 0.0
item_count = 0
based_on_field = frappe.scrub(self.distribute_charges_based_on)
total_item_cost = sum(flt(item.get(based_on_field)) for item in items)
if items:
total_item_cost = flt(total_item_cost, items[0].precision(based_on_field))
if not total_item_cost:
frappe.throw(
_("Total {0} of all items is zero. Set 'Distribute Charges Based On' to {1}.").format(
self.distribute_charges_based_on,
_("Qty") if based_on_field == "amount" else _("Amount"),
)
)
for item in self.get("items"):
total_item_cost += item.get(based_on_field)
for item in self.get("items"):
if not total_item_cost and not item.get(based_on_field):
frappe.throw(
_(
"It's not possible to distribute charges equally when total amount is zero, please set 'Distribute Charges Based On' as 'Quantity'"
)
)
item.applicable_charges = flt(
flt(item.get(based_on_field))
* (flt(self.total_taxes_and_charges) / flt(total_item_cost)),
@@ -568,8 +566,8 @@ def get_pr_items(purchase_receipt):
query = query.where(pr_item.is_finished_item == 1)
else:
query = query.select(
pr_item.base_net_rate.as_("base_rate"),
pr_item.base_net_amount.as_("base_amount"),
pr_item.base_rate,
pr_item.base_amount,
pr_item.is_fixed_asset,
)

View File

@@ -28,103 +28,6 @@ class TestLandedCostVoucher(ERPNextTestSuite):
def setUp(self):
self.load_test_records("Currency Exchange")
def test_landed_cost_uses_discounted_purchase_values(self):
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
for apply_discount_on in ("Net Total", "Grand Total"):
with self.subTest(purchase=make_purchase.__name__, apply_discount_on=apply_discount_on):
lcv = frappe.new_doc("Landed Cost Voucher")
lcv.company = "_Test Company"
lcv.distribute_charges_based_on = "Amount"
for discount in (40, 0, 100):
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
purchase.apply_discount_on = apply_discount_on
purchase.additional_discount_percentage = discount
purchase.items[0].allow_zero_valuation_rate = 1
purchase.insert()
purchase.submit()
lcv.append(
"purchase_receipts",
{
"receipt_document_type": purchase.doctype,
"receipt_document": purchase.name,
},
)
lcv.get_items_from_purchase_receipts()
self.assertEqual([item.amount for item in lcv.items], [120, 200, 0])
self.assertEqual([item.rate for item in lcv.items], [60, 100, 0])
lcv.append("taxes", {"amount": 80})
lcv.total_taxes_and_charges = 80
lcv.set_applicable_charges_on_item()
self.assertEqual([item.applicable_charges for item in lcv.items], [30, 50, 0])
def test_landed_cost_rejects_offsetting_purchase_and_return_amounts(self):
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
with self.subTest(purchase=make_purchase.__name__):
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
purchase.apply_discount_on = "Grand Total"
purchase.additional_discount_percentage = 50
purchase.insert()
purchase.submit()
original = make_purchase(qty=1, rate=100, update_stock=1)
purchase_return = make_purchase(
qty=-1, rate=100, update_stock=1, is_return=1, return_against=original.name
)
lcv = make_landed_cost_voucher(
receipt_document_type=purchase.doctype,
receipt_document=purchase.name,
charges=80,
do_not_save=True,
)
lcv.append(
"purchase_receipts",
{
"receipt_document_type": purchase_return.doctype,
"receipt_document": purchase_return.name,
},
)
lcv.get_items_from_purchase_receipts()
self.assertEqual([item.amount for item in lcv.items], [100, -100])
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
lcv.insert()
lcv.distribute_charges_based_on = "Qty"
lcv.insert()
self.assertEqual([item.applicable_charges for item in lcv.items], [160, -80])
def test_landed_cost_rejects_fully_discounted_purchase(self):
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
with self.subTest(purchase=make_purchase.__name__):
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
purchase.apply_discount_on = "Net Total"
purchase.additional_discount_percentage = 100
purchase.items[0].allow_zero_valuation_rate = 1
purchase.insert()
purchase.submit()
lcv = make_landed_cost_voucher(
receipt_document_type=purchase.doctype,
receipt_document=purchase.name,
charges=80,
do_not_save=True,
)
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
lcv.insert()
lcv.distribute_charges_based_on = "Qty"
lcv.insert()
self.assertEqual([item.applicable_charges for item in lcv.items], [80])
def test_landed_cost_rejects_amounts_that_cancel_to_float_residue(self):
lcv = frappe.new_doc("Landed Cost Voucher")
lcv.company = "_Test Company"
lcv.distribute_charges_based_on = "Amount"
for amount in (100.10, 200.20, -300.30):
lcv.append("items", {"item_code": "_Test Item", "qty": 1, "amount": amount})
lcv.append("taxes", {"amount": 80})
lcv.total_taxes_and_charges = 80
self.assertNotEqual(sum(item.amount for item in lcv.items), 0)
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
lcv.set_applicable_charges_on_item()
def test_get_vendor_invoices_runs(self):
# get_vendor_invoice_query filters unclaimed vendor invoices; the threshold moved from a HAVING
# (which referenced a SELECT alias with no GROUP BY -- invalid on Postgres) to a WHERE.

View File

@@ -80,7 +80,6 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
if (
doc.get("is_return")
and item.return_qty_from_rejected_warehouse
and not doc.is_internal_transfer()
and not frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
@@ -102,15 +101,11 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
outgoing_amount = item.base_net_amount
if doc.is_internal_transfer() and item.valuation_rate:
outgoing_amount = -1 * flt(
get_stock_value_difference(doc.name, item.name, item.from_warehouse)
)
outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
credit_amount = outgoing_amount
if (
item.get("rejected_qty")
and not doc.is_internal_transfer()
and frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
if item.get("rejected_qty") and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
credit_amount = outgoing_amount
@@ -262,7 +257,9 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
)
if item.get("rejected_qty") and self.is_rejected_material_valued():
if item.get("rejected_qty") and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
divisional_loss -= rejected_item_cost
@@ -350,7 +347,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
make_sub_contracting_gl_entries(d)
make_divisional_loss_gl_entry(d, outgoing_amount)
elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
not self.is_rejected_material_valued()
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
and d.rejected_warehouse
and d.rejected_warehouse not in warehouse_with_no_account
):
@@ -359,7 +356,9 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
if d.is_fixed_asset and d.landed_cost_voucher_amount:
doc.update_assets(d, d.valuation_rate)
if d.rejected_qty and self.is_rejected_material_valued():
if d.rejected_qty and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
stock_asset_rbnb = (
doc.get_company_default("asset_received_but_not_billed")
if d.is_fixed_asset
@@ -381,16 +380,6 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
+ "\n".join(warehouse_with_no_account)
)
def is_rejected_material_valued(self) -> bool:
"""Rejected material carries stock value when Buying Settings asks for it, and always on an
internal transfer, where that value is credited out of the in-transit warehouse."""
if self.doc.is_internal_transfer():
return True
return bool(
frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
)
def get_divisional_loss_account(self, item, stock_asset_rbnb):
"""Account that absorbs the difference between the document value and the value actually
booked into stock. For a Standard Cost item this difference is a purchase price variance

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ from frappe.utils import (
from frappe.utils.csvutils import build_csv_response
from erpnext.buying.doctype.buying_settings.buying_settings import (
is_material_from_in_transit_warehouse,
is_rejected_material_valued,
)
from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem
@@ -414,9 +413,8 @@ class SerialandBatchBundle(Document):
def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None):
if valuation_details := self.get_valuation_rate_for_return_entry(return_against):
from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled
from erpnext.stock.utils import get_valuation_method
skip_rate_update = is_serial_no_wise_valuation_disabled(self.item_code)
valuation_method = get_valuation_method(self.item_code, self.company)
# An outward return must go out at the batch's current average rate for a
@@ -445,9 +443,6 @@ class SerialandBatchBundle(Document):
if valuation_details:
self.validate_returned_serial_batch_no(return_against, row, valuation_details)
if skip_rate_update:
continue
if row.serial_no:
valuation_rate = valuation_details["serial_nos"].get(row.serial_no)
else:
@@ -704,10 +699,7 @@ class SerialandBatchBundle(Document):
)
def set_incoming_rate_for_outward_transaction(self, row=None, save=False, allow_negative_stock=False):
from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled
if is_serial_no_wise_valuation_disabled(self.item_code):
return
from erpnext.stock.utils import get_valuation_method
sle = self.get_sle_for_outward_transaction()
@@ -910,14 +902,7 @@ class SerialandBatchBundle(Document):
if batches and valuation_method == "FIFO":
stock_queue = parse_json(prev_sle.stock_queue)
values_rejected_material = is_rejected_material_valued(self.voucher_type, self.voucher_detail_no)
if self.is_rejected and is_material_from_in_transit_warehouse(
self.voucher_type, self.voucher_detail_no
):
# Rejected material of a transfer keeps the value it had in transit. A charge spread
# over the accepted quantity does not belong to it.
rate = flt(self.get_transit_rate(row)) or rate
set_valuation_rate_for_rejected_materials = is_rejected_material_valued(self.voucher_type)
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
for d in self.entries:
@@ -925,7 +910,7 @@ class SerialandBatchBundle(Document):
if valuation_method == "FIFO" and d.batch_no in batches:
fifo_batch_wise_val = False
if self.is_rejected and not values_rejected_material:
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
rate = 0.0
elif (
(flt(d.incoming_rate, precision) == flt(rate, precision))
@@ -1175,7 +1160,7 @@ class SerialandBatchBundle(Document):
def reset_qty(self, row, qty_field=None):
qty_field = self.get_qty_field(row, qty_field=qty_field)
qty = abs(flt(self.get_row_qty(row, qty_field), self.precision("total_qty")))
qty = abs(flt(row.get(qty_field), self.precision("total_qty")))
idx = None
while qty > 0:
@@ -1202,38 +1187,20 @@ class SerialandBatchBundle(Document):
self.flags.ignore_links = True
self.save()
def get_row_qty(self, row, qty_field) -> float:
"""What the row holds in the units a package counts in."""
if qty_field == "qty" and row.get("stock_qty"):
return flt(row.get("stock_qty"))
if qty_field == "rejected_qty":
return flt(row.get(qty_field)) * flt(row.get("conversion_factor") or 1)
return flt(row.get(qty_field))
def validate_quantity(self, row, qty_field=None):
qty_field = self.get_qty_field(row, qty_field=qty_field)
qty = self.get_row_qty(row, qty_field)
qty = row.get(qty_field)
if qty_field == "qty" and row.get("stock_qty"):
qty = row.get("stock_qty")
precision = row.precision(qty_field)
if abs(abs(flt(self.total_qty, precision)) - abs(flt(qty, precision))) > 0.01:
total_qty = frappe.format_value(abs(flt(self.total_qty)), "Float", row)
set_qty = frappe.format_value(abs(flt(qty)), "Float", row)
set_qty = frappe.format_value(abs(flt(row.get(qty_field))), "Float", row)
self.throw_error_message(
f"Total quantity {total_qty} in the Serial and Batch Bundle {bold(self.name)} does not match with the quantity {set_qty} for the Item {bold(self.item_code)} in the {self.voucher_type} # {self.voucher_no}"
)
def get_transit_rate(self, row) -> float:
"""What the material was worth on its way into the in-transit warehouse."""
if row and row.get("sales_incoming_rate"):
return flt(row.get("sales_incoming_rate"))
if not (self.voucher_detail_no and self.voucher_no):
return 0.0
return flt(frappe.db.get_value(self.child_table, self.voucher_detail_no, "sales_incoming_rate"))
def get_qty_field(self, row, qty_field=None) -> str:
if not qty_field:
qty_field = "qty"
@@ -1242,7 +1209,7 @@ class SerialandBatchBundle(Document):
qty_field = "consumed_qty"
elif row.get("doctype") == "Stock Entry Detail":
qty_field = "transfer_qty"
elif row.get("doctype") in ["Sales Invoice Item", "Purchase Invoice Item"] and qty_field == "qty":
elif row.get("doctype") in ["Sales Invoice Item", "Purchase Invoice Item"]:
qty_field = "stock_qty"
return qty_field

View File

@@ -1682,426 +1682,6 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
)
self.assertRaises(NegativeStockError, backdated.submit)
def make_serial_item_for_valuation(self, item_code, use_serial_no_wise_valuation):
return make_item(
item_code,
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": item_code + "-.####",
"valuation_method": "FIFO" if use_serial_no_wise_valuation else "Moving Average",
"use_serial_no_wise_valuation": use_serial_no_wise_valuation,
},
)
def receive_serial_stock(self, item_code, qty, rate, warehouse, posting_date=None):
entry = make_stock_entry(
item_code=item_code,
target=warehouse,
qty=qty,
basic_rate=rate,
posting_date=posting_date,
use_serial_batch_fields=1,
)
return get_serial_nos_from_bundle(entry.items[0].serial_and_batch_bundle)
def issue_serial_no(self, item_code, serial_no, warehouse, posting_date=None):
return make_stock_entry(
item_code=item_code,
source=warehouse,
qty=1,
serial_no=serial_no,
posting_date=posting_date,
use_serial_batch_fields=1,
)
def get_stock_value_difference(self, voucher_no):
return frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": voucher_no, "is_cancelled": 0}, "stock_value_difference"
)
def test_serial_no_wise_valuation_uses_serial_rate_when_enabled(self):
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation On", 1)
self.receive_serial_stock(item.name, 2, 100, warehouse)
newer_serial_nos = self.receive_serial_stock(item.name, 2, 200, warehouse)
issue = self.issue_serial_no(item.name, newer_serial_nos[-1], warehouse)
self.assertEqual(flt(self.get_stock_value_difference(issue.name)), -200.0)
def test_serial_no_wise_valuation_uses_item_valuation_method_when_disabled(self):
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Off", 0)
self.receive_serial_stock(item.name, 2, 100, warehouse)
newer_serial_nos = self.receive_serial_stock(item.name, 2, 200, warehouse)
issue = self.issue_serial_no(item.name, newer_serial_nos[-1], warehouse)
self.assertEqual(flt(self.get_stock_value_difference(issue.name)), -150.0)
def test_outward_bundle_rate_not_set_when_serial_no_wise_valuation_disabled(self):
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No Rate", 0)
serial_nos = self.receive_serial_stock(item.name, 2, 100, warehouse)
issue = self.issue_serial_no(item.name, serial_nos[-1], warehouse)
rates = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": issue.items[0].serial_and_batch_bundle},
pluck="incoming_rate",
)
self.assertTrue(rates)
for rate in rates:
self.assertEqual(flt(rate), 0.0)
def test_cannot_enable_serial_no_wise_valuation_when_serial_nos_exist(self):
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Toggle", 1)
self.receive_serial_stock(item.name, 1, 100, warehouse)
item.reload()
item.use_serial_no_wise_valuation = 0
item.save()
item.reload()
item.use_serial_no_wise_valuation = 1
self.assertRaises(frappe.ValidationError, item.save)
def make_purchase_return_for_serial_no(self, item_code, serial_no, receipt):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
entry = make_return_doc("Purchase Receipt", receipt.name)
entry.items[0].qty = -1
entry.items[0].received_qty = -1
for row in entry.items:
row.serial_and_batch_bundle = None
row.use_serial_batch_fields = 1
row.serial_no = serial_no
entry.save()
entry.submit()
return entry
def test_purchase_return_uses_item_valuation_method_when_disabled(self):
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Pur Return Off", 0)
make_purchase_receipt(item_code=item.name, qty=2, rate=100, warehouse=warehouse)
costlier_receipt = make_purchase_receipt(item_code=item.name, qty=2, rate=200, warehouse=warehouse)
serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle)
entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt)
self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -150.0)
def test_purchase_return_uses_serial_rate_when_enabled(self):
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Pur Return On", 1)
make_purchase_receipt(item_code=item.name, qty=2, rate=100, warehouse=warehouse)
costlier_receipt = make_purchase_receipt(item_code=item.name, qty=2, rate=200, warehouse=warehouse)
serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle)
entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt)
self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -200.0)
def deliver_serial_no(self, item_code, serial_no, warehouse, posting_date=None):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
return create_delivery_note(
item_code=item_code,
warehouse=warehouse,
qty=1,
serial_no=serial_no,
posting_date=posting_date,
use_serial_batch_fields=1,
)
def repost_item_and_warehouse(self, item_code, warehouse, posting_date):
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import repost
riv = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Item and Warehouse",
"item_code": item_code,
"warehouse": warehouse,
"posting_date": posting_date,
"posting_time": "00:00:01",
"company": frappe.get_cached_value("Warehouse", warehouse, "company"),
}
)
riv.flags.dont_run_in_test = True
riv.submit()
riv.reload()
repost(riv)
riv.reload()
self.assertEqual(riv.status, "Completed")
def assert_outward_sle_at_moving_average(self, voucher_no, warehouse, rate, qty_after_transaction):
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": voucher_no, "warehouse": warehouse, "is_cancelled": 0},
["outgoing_rate", "valuation_rate", "stock_value", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(flt(sle.outgoing_rate), rate, voucher_no)
self.assertEqual(flt(sle.valuation_rate), rate, voucher_no)
self.assertEqual(flt(sle.stock_value), rate * qty_after_transaction, voucher_no)
self.assertEqual(flt(sle.stock_value_difference), -rate, voucher_no)
def test_repost_values_outward_entries_at_moving_average_when_disabled(self):
"""A repost must value plain outward entries at the moving average once the switch is off. The
serial rates are seeded because such entries come from ledgers written before the switch."""
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Repost Outward", 1)
posting_date = add_days(today(), -10)
cheaper = self.receive_serial_stock(item.name, 2, 100, warehouse, posting_date)
costlier = self.receive_serial_stock(item.name, 2, 200, warehouse, add_days(posting_date, 1))
issue = self.issue_serial_no(item.name, costlier[-1], warehouse, add_days(posting_date, 2))
delivery = self.deliver_serial_no(item.name, cheaper[-1], warehouse, add_days(posting_date, 3))
for voucher_no, serial_rate in ((issue.name, 200.0), (delivery.name, 100.0)):
sle_name = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": voucher_no, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry", sle_name, "outgoing_rate", serial_rate, update_modified=False
)
item.reload()
item.use_serial_no_wise_valuation = 0
item.save()
self.repost_item_and_warehouse(item.name, warehouse, posting_date)
# 2 @ 100 plus 2 @ 200 makes the moving average 150, and neither outward entry may move it
self.assert_outward_sle_at_moving_average(issue.name, warehouse, 150.0, 3.0)
self.assert_outward_sle_at_moving_average(delivery.name, warehouse, 150.0, 2.0)
def test_repost_values_purchase_return_at_moving_average_when_disabled(self):
"""Same as above for a purchase return: it must leave the remaining stock at the moving average,
not at the returned serial's own rate."""
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
warehouse = "_Test Warehouse - _TC"
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Repost Return", 1)
posting_date = add_days(today(), -10)
make_purchase_receipt(
item_code=item.name, qty=2, rate=100, warehouse=warehouse, posting_date=posting_date
)
costlier_receipt = make_purchase_receipt(
item_code=item.name,
qty=2,
rate=200,
warehouse=warehouse,
posting_date=add_days(posting_date, 1),
)
serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle)
entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt)
self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -200.0)
item.reload()
item.use_serial_no_wise_valuation = 0
item.save()
self.repost_item_and_warehouse(item.name, warehouse, posting_date)
self.assert_outward_sle_at_moving_average(entry.name, warehouse, 150.0, 3.0)
def test_valuation_method_forced_to_moving_average_when_disabled(self):
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Forced MA", 1)
self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC")
item.reload()
item.valuation_method = "FIFO"
item.use_serial_no_wise_valuation = 0
item.save()
item.reload()
self.assertEqual(item.valuation_method, "Moving Average")
def test_valuation_method_kept_when_disabled_without_stock_transactions(self):
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No MA Yet", 1)
item.reload()
item.valuation_method = "FIFO"
item.use_serial_no_wise_valuation = 0
item.save()
item.reload()
self.assertEqual(item.valuation_method, "FIFO")
def test_valuation_method_kept_when_disabled_and_saved_after_stock_transactions(self):
"""An item that has always had the switch off keeps its own valuation method. Only turning the
switch off forces Moving Average, so an unrelated save cannot silently revalue the ledger."""
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Keeps FIFO On Save", 0)
item.reload()
item.valuation_method = "FIFO"
item.save()
self.receive_serial_stock(item.name, 2, 100, "_Test Warehouse - _TC")
self.receive_serial_stock(item.name, 2, 200, "_Test Warehouse - _TC")
item.reload()
item.description = "saved for an unrelated reason"
item.save()
item.reload()
self.assertEqual(item.valuation_method, "FIFO")
def test_fifo_allowed_when_disabled_without_stock_transactions(self):
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation FIFO Ok", 0)
item.reload()
item.valuation_method = "FIFO"
item.save()
item.reload()
self.assertEqual(item.valuation_method, "FIFO")
def test_first_transaction_uses_moving_average_when_disabled(self):
from collections import defaultdict
from erpnext.stock.utils import get_valuation_method
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation First Txn", 0)
warehouse = "_Test Warehouse - _TC"
item.reload()
item.valuation_method = "FIFO"
item.save()
previous_cache = getattr(frappe.local, "request_cache", None)
self.addCleanup(setattr, frappe.local, "request_cache", previous_cache)
frappe.local.request_cache = defaultdict(dict)
# Any method may be stored and used while the item has no ledger. Reading it here also
# primes the request cache with FIFO, the way validate() does before entries exist.
self.assertEqual(item.valuation_method, "FIFO")
self.assertEqual(get_valuation_method(item.name), "FIFO")
serial_nos = self.receive_serial_stock(item.name, 1, 100, warehouse)
self.receive_serial_stock(item.name, 1, 200, warehouse)
# The issue must be valued at the moving average of 150, not the FIFO rate of 100, even
# though the cache primed above still holds FIFO.
entry = self.issue_serial_no(item.name, serial_nos[0], warehouse)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": entry.name, "is_cancelled": 0},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -100.0)
# Posting stock does not save the item, so the stored method stays FIFO while the
# effective method is Moving Average now that a ledger exists.
item.reload()
self.assertEqual(item.valuation_method, "FIFO")
frappe.local.request_cache = defaultdict(dict)
self.assertEqual(get_valuation_method(item.name), "FIFO")
def test_legacy_serial_no_lookup_is_case_insensitive(self):
# MariaDB matches serial_no under a case insensitive collation, PostgreSQL does not.
# This asserts the lookup behaves the same on both; it can only fail on PostgreSQL.
from erpnext.stock.deprecated_serial_batch import DeprecatedSerialNoValuation
item = self.make_serial_item_for_valuation("_Test Legacy Serial Case", 1)
warehouse = "_Test Warehouse - _TC"
serial_no = self.receive_serial_stock(item.name, 1, 100, warehouse)[0]
# Rewrite the receipt into the pre-bundle representation.
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"item_code": item.name, "is_cancelled": 0},
fields=["name", "posting_datetime"],
)
for sle in sles:
frappe.db.set_value(
"Stock Ledger Entry", sle.name, {"serial_and_batch_bundle": None, "serial_no": serial_no}
)
class LegacyLookup(DeprecatedSerialNoValuation):
def __init__(self, sle):
self.sle = sle
lookup = LegacyLookup(
frappe._dict(
item_code=item.name,
company=frappe.get_cached_value("Warehouse", warehouse, "company"),
warehouse=warehouse,
)
)
posting_datetime = sles[0].posting_datetime
self.assertTrue(lookup.get_last_inward_sle_for_serial_no(serial_no, posting_datetime))
self.assertTrue(lookup.get_last_inward_sle_for_serial_no(serial_no.swapcase(), posting_datetime))
def test_cannot_set_fifo_when_serial_no_wise_valuation_disabled(self):
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No FIFO", 0)
self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC")
item.reload()
self.assertEqual(item.valuation_method, "Moving Average")
item.valuation_method = "FIFO"
self.assertRaises(frappe.ValidationError, item.save)
def test_valuation_helpers_not_stale_after_disabling_in_same_request(self):
from collections import defaultdict
from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Cache", 1)
self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC")
previous_cache = getattr(frappe.local, "request_cache", None)
self.addCleanup(setattr, frappe.local, "request_cache", previous_cache)
frappe.local.request_cache = defaultdict(dict)
self.assertEqual(get_valuation_method(item.name), "FIFO")
self.assertFalse(is_serial_no_wise_valuation_disabled(item.name))
item.reload()
item.use_serial_no_wise_valuation = 0
item.save()
self.assertEqual(get_valuation_method(item.name), "Moving Average")
self.assertTrue(is_serial_no_wise_valuation_disabled(item.name))
def test_valuation_method_untouched_when_serial_no_wise_valuation_enabled(self):
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Keeps FIFO", 1)
item.reload()
self.assertEqual(item.valuation_method, "FIFO")
def test_enable_serial_no_wise_valuation_allowed_without_serial_nos(self):
item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No Serials", 0)
item.reload()
item.use_serial_no_wise_valuation = 1
item.save()
item.reload()
self.assertEqual(item.use_serial_no_wise_valuation, 1)
def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos

View File

@@ -22,46 +22,6 @@ class BaseMaterialTransferStockEntry(BaseStockEntry):
if not row.s_warehouse:
frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code))
self.validate_transit_warehouses()
def validate_transit_warehouses(self):
if not self.doc.add_to_transit:
return
target_warehouses = {row.t_warehouse for row in self.doc.items if row.t_warehouse}
if self.doc.to_warehouse:
target_warehouses.add(self.doc.to_warehouse)
if not target_warehouses:
return
transit_warehouses = set(
frappe.get_all(
"Warehouse",
filters={
"name": ("in", list(target_warehouses)),
"warehouse_type": "Transit",
"company": self.doc.company,
},
pluck="name",
)
)
if self.doc.to_warehouse and self.doc.to_warehouse not in transit_warehouses:
frappe.throw(
_(
"Default Target Warehouse {0} must be a Transit warehouse when Add to Transit is enabled."
).format(frappe.bold(self.doc.to_warehouse))
)
for row in self.doc.items:
if row.t_warehouse and row.t_warehouse not in transit_warehouses:
frappe.throw(
_(
"Row #{0}: Target Warehouse {1} must be a Transit warehouse when Add to Transit is enabled."
).format(row.idx, frappe.bold(row.t_warehouse))
)
def validate_same_source_target_warehouse(self):
"""
Raises: frappe.ValidationError: If warehouses are same and no inventory dimensions differ

View File

@@ -903,15 +903,22 @@ frappe.ui.form.on("Stock Entry", {
add_to_transit: function (frm) {
if (frm.doc.purpose == "Material Transfer") {
var filters = {
is_group: 0,
company: frm.doc.company,
};
if (frm.doc.add_to_transit) {
filters["warehouse_type"] = "Transit";
frm.set_value("to_warehouse", "");
(frm.doc.items || []).forEach((item) => {
if (item.t_warehouse) {
frappe.model.set_value(item.doctype, item.name, "t_warehouse", "");
}
});
frm.trigger("set_transit_warehouse");
}
frm.fields_dict.to_warehouse.get_query = function () {
return {
filters: filters,
};
};
}
},
@@ -1223,28 +1230,6 @@ frappe.ui.form.on("Landed Cost Taxes and Charges", {
});
erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockController {
setup_warehouse_query() {
super.setup_warehouse_query();
const transit_warehouse_query = () => {
const filters = {
is_group: 0,
company: this.frm.doc.company,
};
if (this.frm.doc.purpose === "Material Transfer" && this.frm.doc.add_to_transit) {
filters["warehouse_type"] = "Transit";
}
return {
filters: filters,
};
};
this.frm.set_query("to_warehouse", transit_warehouse_query);
this.frm.set_query("t_warehouse", "items", transit_warehouse_query);
}
setup() {
var me = this;
@@ -1434,10 +1419,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
this.frm.trigger("toggle_display_account_head");
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype);
if (!this.frm.doc.__onload?.load_after_mapping) {
this.set_default_account("cost_center", "cost_center");
}
this.set_default_account("cost_center", "cost_center");
this.frm.refresh_fields("items");
}

View File

@@ -1750,12 +1750,6 @@ class StockEntry(StockController, SubcontractingInwardController):
@frappe.whitelist()
def make_stock_in_entry(source_name: str, target_doc: str | dict | Document | None = None):
qty_precision = frappe.get_precision("Stock Entry Detail", "transfer_qty")
def get_remaining_transfer_qty(source_doc):
remaining_qty = flt(source_doc.transfer_qty) - flt(source_doc.transferred_qty)
return flt(remaining_qty, qty_precision)
def set_missing_values(source, target):
target.stock_entry_type = "Material Transfer"
target.set_missing_values()
@@ -1775,7 +1769,7 @@ def make_stock_in_entry(source_name: str, target_doc: str | dict | Document | No
target_doc.t_warehouse = warehouse
target_doc.s_warehouse = source_doc.t_warehouse
target_doc.qty = get_remaining_transfer_qty(source_doc) / flt(source_doc.conversion_factor)
target_doc.qty = source_doc.qty - source_doc.transferred_qty
doclist = get_mapped_doc(
"Stock Entry",
@@ -1795,7 +1789,7 @@ def make_stock_in_entry(source_name: str, target_doc: str | dict | Document | No
"batch_no": "batch_no",
},
"postprocess": update_item,
"condition": lambda doc: get_remaining_transfer_qty(doc) > 0,
"condition": lambda doc: flt(doc.qty) - flt(doc.transferred_qty) > 0.00001,
},
},
target_doc,

View File

@@ -272,8 +272,7 @@ class TestStockEntry(ERPNextTestSuite):
company = "_Test Company"
create_warehouse("Test From Warehouse")
create_warehouse("Test Transit Warehouse", properties={"warehouse_type": "Transit"})
frappe.db.set_value("Warehouse", "Test Transit Warehouse - _TC", "warehouse_type", "Transit")
create_warehouse("Test Transit Warehouse")
create_warehouse("Test To Warehouse")
create_item(
@@ -324,131 +323,6 @@ class TestStockEntry(ERPNextTestSuite):
transit_entry.reload()
self.assertEqual(transit_entry.per_transferred, 100)
def test_end_transit_qty_with_uom_conversion(self):
"""transferred_qty is tracked in the stock UOM, so the end transit qty must be converted back."""
company = "_Test Company"
source_warehouse = "_Test Warehouse - _TC"
target_warehouse = "_Test Warehouse 1 - _TC"
transit_warehouse = get_in_transit_warehouse(company)
item_code = make_item(
"_Test Transit UOM Conversion Item",
{"is_stock_item": 1, "stock_uom": "Nos", "uoms": [{"uom": "Kg", "conversion_factor": 0.5}]},
).name
make_stock_entry(item_code=item_code, target=source_warehouse, qty=100, basic_rate=100)
transit_entry = make_stock_entry(
item_code=item_code,
source=source_warehouse,
target=transit_warehouse,
purpose="Material Transfer",
add_to_transit=1,
qty=10,
basic_rate=100,
do_not_save=True,
)
transit_entry.items[0].uom = "Kg"
transit_entry.items[0].conversion_factor = 0.5
transit_entry.save().submit()
self.assertEqual(transit_entry.items[0].transfer_qty, 5)
partial_entry = make_stock_in_entry(transit_entry.name)
partial_entry.to_warehouse = target_warehouse
partial_entry.items[0].qty = 4
partial_entry.items[0].t_warehouse = target_warehouse
partial_entry.save().submit()
remaining_entry = make_stock_in_entry(transit_entry.name)
self.assertEqual(remaining_entry.items[0].uom, "Kg")
self.assertEqual(remaining_entry.items[0].qty, 6)
remaining_entry.to_warehouse = target_warehouse
remaining_entry.items[0].t_warehouse = target_warehouse
remaining_entry.save().submit()
self.assertFalse(make_stock_in_entry(transit_entry.name).get("items"))
def test_end_transit_maps_smallest_remaining_qty(self):
"""The smallest storable remainder survives binary subtraction, 2.001 - 2 is 0.0009999999999998899."""
company = "_Test Company"
source_warehouse = "_Test Warehouse - _TC"
target_warehouse = "_Test Warehouse 1 - _TC"
transit_warehouse = get_in_transit_warehouse(company)
item_code = make_item(
"_Test Transit Fractional Item", {"is_stock_item": 1, "stock_uom": "Litre"}
).name
smallest_qty = 1 / (10 ** frappe.get_precision("Stock Entry Detail", "transfer_qty"))
make_stock_entry(item_code=item_code, target=source_warehouse, qty=100, basic_rate=100)
transit_entry = make_stock_entry(
item_code=item_code,
source=source_warehouse,
target=transit_warehouse,
purpose="Material Transfer",
add_to_transit=1,
qty=2 + smallest_qty,
basic_rate=100,
)
partial_entry = make_stock_in_entry(transit_entry.name)
partial_entry.to_warehouse = target_warehouse
partial_entry.items[0].qty = 2
partial_entry.items[0].t_warehouse = target_warehouse
partial_entry.save().submit()
remaining_entry = make_stock_in_entry(transit_entry.name)
self.assertEqual(remaining_entry.items[0].qty, smallest_qty)
def test_add_to_transit_non_transit_target_warehouse_validation(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
item_code = "_Test Transit Item 2"
company = "_Test Company"
create_warehouse("Test Source Warehouse")
create_warehouse("Test Regular Target Warehouse")
create_item(
item_code=item_code,
is_stock_item=1,
is_purchase_item=1,
company=company,
)
make_stock_entry(
item_code=item_code,
target="Test Source Warehouse - _TC",
qty=10,
basic_rate=100,
expense_account="Stock Adjustment - _TC",
cost_center="Main - _TC",
)
# Submitting or saving with add_to_transit=1 and a non-transit target warehouse must be rejected
se = frappe.new_doc("Stock Entry")
se.purpose = "Material Transfer"
se.stock_entry_type = "Material Transfer"
se.company = company
se.from_warehouse = "Test Source Warehouse - _TC"
se.to_warehouse = "Test Regular Target Warehouse - _TC"
se.add_to_transit = 1
se.append(
"items",
{
"item_code": item_code,
"s_warehouse": "Test Source Warehouse - _TC",
"t_warehouse": "Test Regular Target Warehouse - _TC",
"qty": 5,
"basic_rate": 100,
"expense_account": "Stock Adjustment - _TC",
"cost_center": "Main - _TC",
},
)
self.assertRaises(frappe.ValidationError, se.save)
def test_material_receipt_gl_entry(self):
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")

View File

@@ -18,7 +18,6 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_in
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
combine_datetime,
get_available_serial_nos,
get_serial_nos_based_on_posting_date,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_reconciliation_item.stock_reconciliation_item import StockReconciliationItem
@@ -488,65 +487,37 @@ class StockReconciliation(StockController):
reco_obj = cls_obj.duplicate_package()
total_current_qty = 0.0
entries_in_stock = []
serial_nos_in_stock = self.get_serial_nos_in_stock(row, reco_obj.entries)
for entry in reco_obj.entries:
if not entry.batch_no or entry.serial_no:
if entry.serial_no not in serial_nos_in_stock:
continue
total_current_qty += entry.qty
entry.qty *= -1
continue
current_qty = entry.qty
else:
current_qty = get_batch_qty(
entry.batch_no,
row.warehouse,
row.item_code,
ignore_voucher_nos=[self.name],
posting_date=self.posting_date,
posting_time=self.posting_time,
for_stock_levels=True,
consider_negative_batches=True,
do_not_check_future_batches=True,
)
current_qty = get_batch_qty(
entry.batch_no,
row.warehouse,
row.item_code,
ignore_voucher_nos=[self.name],
posting_date=self.posting_date,
posting_time=self.posting_time,
for_stock_levels=True,
consider_negative_batches=True,
do_not_check_future_batches=True,
)
if not current_qty:
continue
if not current_qty:
continue
total_current_qty += current_qty
entry.qty = current_qty * -1
entries_in_stock.append(entry)
if total_current_qty:
reco_obj.set("entries", entries_in_stock)
reco_obj.save()
row.current_qty = total_current_qty
return reco_obj
def get_serial_nos_in_stock(self, row, entries) -> set:
"""Serial nos of the row that hold stock in the warehouse as of the posting datetime."""
serial_nos = [entry.serial_no for entry in entries if entry.serial_no]
if not serial_nos:
return set()
in_stock = get_serial_nos_based_on_posting_date(
frappe._dict(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
"serial_nos": serial_nos,
"check_serial_nos": True,
"voucher_no": self.name,
}
),
[],
)
return set(in_stock)
def has_change_in_serial_batch(self, row) -> bool:
bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []}
@@ -977,19 +948,7 @@ class StockReconciliation(StockController):
)
)
def get_balance_before_reconciliation(self, row) -> dict:
from erpnext.stock.stock_ledger import get_previous_sle
return get_previous_sle(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
def get_stranded_stock_value(self, row, previous_sle=None) -> float:
def get_stranded_stock_value(self, row) -> float:
"""Stock value the ledger still carries for an item-warehouse that has no quantity on hand.
This is what an adjustment entry writes off. The write-off is measured at item-warehouse
@@ -998,10 +957,16 @@ class StockReconciliation(StockController):
at an already empty batch while other batches of the same item still hold stock would
otherwise write off the valuation of the stock that remains.
"""
from erpnext.stock.stock_ledger import get_stock_value_difference
from erpnext.stock.stock_ledger import get_previous_sle, get_stock_value_difference
if previous_sle is None:
previous_sle = self.get_balance_before_reconciliation(row)
previous_sle = get_previous_sle(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
if flt(previous_sle.get("qty_after_transaction")):
return 0.0
@@ -1011,23 +976,13 @@ class StockReconciliation(StockController):
)
def make_adjustment_entry(self, row, sl_entries):
previous_sle = self.get_balance_before_reconciliation(row)
difference_amount = self.get_stranded_stock_value(row, previous_sle=previous_sle)
difference_amount = self.get_stranded_stock_value(row)
# rounded, so float dust does not post an entry whose GL counterpart rounds away to zero
if not flt(difference_amount, self.precision("difference_amount")):
if not difference_amount:
return
args = self.get_sle_for_items(row)
args.update(
{
"stock_value_difference": -1 * difference_amount,
# the row carries no rate, so carry the running one forward rather than stamp a zero
# that later rate lookups would read back as the last known valuation
"valuation_rate": flt(previous_sle.get("valuation_rate")),
"is_adjustment_entry": 1,
}
)
args.update({"stock_value_difference": -1 * difference_amount, "is_adjustment_entry": 1})
sl_entries.append(args)
@@ -1090,16 +1045,7 @@ class StockReconciliation(StockController):
has_dimensions = True
if self.docstatus == 2:
if self.is_adjustment_row(row):
# Reversing a value-only entry must not shift any quantity, so mirror the balance the
# ledger carried across it and let get_stock_reco_qty_shift resolve to zero.
data.actual_qty = 0.0
data.qty_after_transaction = flt(row.current_qty)
data.previous_qty_after_transaction = flt(row.current_qty)
data.valuation_rate = flt(row.current_valuation_rate)
data.stock_value = flt(row.current_amount)
data.stock_value_difference = -1 * flt(row.amount_difference)
elif row.current_qty and current_bundle:
if row.current_qty and current_bundle:
data.actual_qty = -1 * row.current_qty
data.qty_after_transaction = flt(row.current_qty)
data.previous_qty_after_transaction = flt(row.qty)
@@ -1232,14 +1178,9 @@ class StockReconciliation(StockController):
for row in self.items:
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
if self.is_adjustment_row(row):
self.set_adjustment_row_values(row, amount_difference)
difference_amount += amount_difference
continue
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
current_qty = self.get_current_qty_from_ledger(row)
@@ -1269,50 +1210,6 @@ class StockReconciliation(StockController):
update_modified=False,
)
def is_adjustment_row(self, row: StockReconciliationItem) -> bool:
# Read once for the whole voucher: both callers run per row, and a reconciliation
# submits and cancels synchronously for up to 100 of them.
if self.flags.adjustment_rows is None:
self.flags.adjustment_rows = set(
frappe.get_all(
"Stock Ledger Entry",
filters={
"voucher_type": self.doctype,
"voucher_no": self.name,
"is_adjustment_entry": 1,
"is_cancelled": 0,
},
pluck="voucher_detail_no",
)
)
return row.name in self.flags.adjustment_rows
def set_adjustment_row_values(self, row: StockReconciliationItem, amount_difference: float):
"""Refresh a value-only row: it moves no stock, so both sides carry the ledger's own figures
and ``amount_difference`` is the write-off booked to the GL, not a change in what is on hand.
"""
previous_sle = self.get_previous_ledger_entry(row) or frappe._dict()
current_qty = flt(previous_sle.get("qty_after_transaction"), row.precision("current_qty"))
current_valuation_rate = flt(
previous_sle.get("valuation_rate"), row.precision("current_valuation_rate")
)
# from the ledger's stock value, since rounding the rate first loses money on large qtys
current_amount = flt(previous_sle.get("stock_value"), row.precision("current_amount"))
row.db_set(
{
"amount": current_amount,
"current_qty": current_qty,
"current_valuation_rate": current_valuation_rate,
"current_amount": current_amount,
"quantity_difference": 0.0,
"amount_difference": amount_difference,
},
update_modified=False,
)
def get_current_qty_from_ledger(self, row: StockReconciliationItem):
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
@@ -1327,14 +1224,6 @@ class StockReconciliation(StockController):
)
return abs(flt(total_qty, row.precision("current_qty")))
previous_sle = self.get_previous_ledger_entry(row)
if previous_sle is None:
return flt(row.current_qty, row.precision("current_qty"))
return flt(previous_sle.get("qty_after_transaction"), row.precision("current_qty"))
def get_previous_ledger_entry(self, row: StockReconciliationItem):
"""Balance, rate and value carried just before this row's own entries, or None if it has none."""
reco_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
@@ -1347,12 +1236,12 @@ class StockReconciliation(StockController):
as_dict=True,
)
if not reco_sle:
return None
return flt(row.current_qty, row.precision("current_qty"))
sle = frappe.qb.DocType("Stock Ledger Entry")
previous_sle = (
frappe.qb.from_(sle)
.select(sle.qty_after_transaction, sle.valuation_rate, sle.stock_value)
.select(sle.qty_after_transaction)
.where(
(sle.item_code == row.item_code)
& (sle.warehouse == row.warehouse)
@@ -1368,9 +1257,9 @@ class StockReconciliation(StockController):
.orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run(as_dict=True)
).run()
return previous_sle[0] if previous_sle else frappe._dict()
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
def submit(self):
if len(self.items) > 100:

View File

@@ -1324,39 +1324,6 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
self.assertAlmostEqual(row.incoming_rate, 1000.00)
self.assertEqual(row.serial_no, serial_nos[row.idx - 1])
def test_opening_stock_reco_for_serial_nos_without_stock(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import make_serial_nos
item = self.make_item(
"Test Serial No Item Opening Stock Not Reconcile All",
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "SNN-TEST-OPENING-NRALL-S-.###",
},
)
warehouse = "_Test Warehouse - _TC"
serial_nos = [f"SNN-TEST-OPENING-NRALL-{idx}" for idx in range(1, 6)]
make_serial_nos(item.name, [{"serial_no": serial_no} for serial_no in serial_nos])
with self.change_settings("Stock Settings", {"allow_negative_stock": 0}):
sr = create_stock_reconciliation(
item_code=item.name,
warehouse=warehouse,
qty=5,
rate=100,
purpose="Opening Stock",
expense_account="Temporary Opening - _TC",
reconcile_all_serial_batch=0,
serial_no=serial_nos,
)
self.assertEqual(sr.docstatus, 1)
self.assertEqual(sr.items[0].current_qty, 0)
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
self.assertEqual(get_stock_balance(item.name, warehouse), 5)
def test_stock_reco_with_legacy_batch(self):
from erpnext.stock.doctype.batch.batch import get_batch_qty
@@ -2376,211 +2343,6 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(flt(sles[0].stock_value_difference), -100.0)
def test_adjustment_entry_clears_value_stranded_at_zero_qty(self):
"""bal_qty 0 with bal_val 500: the write-off has to bring the reported value to zero."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.report.stock_balance.stock_balance import execute
item_code = self.make_item("Test Stock Reco Stranded Value Non Batch").name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
target=warehouse,
qty=10,
basic_rate=100,
posting_date=add_days(nowdate(), -3),
)
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -2))
# strand 500 of value: qty nets out, stock_value_difference does not
receipt_sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry",
receipt_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 500,
update_modified=False,
)
report_filters = frappe._dict(
{"item_code": [item_code], "warehouse": [warehouse], "company": "_Test Company"}
)
# this is what the user sees before the reconciliation
_columns, data = execute(filters=report_filters)
self.assertEqual(flt(data[0].get("bal_qty")), 0.0)
self.assertEqual(flt(data[0].get("bal_val")), 500.0)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0},
fields=["actual_qty", "qty_after_transaction", "stock_value_difference", "is_adjustment_entry"],
)
self.assertEqual(len(sles), 1)
self.assertEqual(sles[0].is_adjustment_entry, 1)
self.assertEqual(flt(sles[0].actual_qty), 0.0)
self.assertEqual(flt(sles[0].qty_after_transaction), 0.0)
self.assertEqual(flt(sles[0].stock_value_difference), -500.0)
# the report, and the GL basis behind it, both land on zero
# (the row drops out entirely once every figure on it is zero)
_columns, data = execute(filters=report_filters)
self.assertEqual(flt(data[0].get("bal_val")) if data else 0.0, 0.0)
self.assertEqual(
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
0.0,
)
def _make_backdated_adjustment_scenario(self, item_name, valuation_method, backdated_qty=4):
"""Strand 100 of value at zero qty, write it off, then backdate a receipt before the write-off."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item(item_name, {"valuation_method": valuation_method}).name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
target=warehouse,
qty=10,
basic_rate=100,
posting_date=add_days(nowdate(), -10),
)
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -9))
# strand 100 of value on the ledger: qty nets out, stock_value_difference does not
receipt_sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry",
receipt_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 100,
update_modified=False,
)
sr = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=0,
rate=0,
posting_date=add_days(nowdate(), -5),
do_not_save=1,
)
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
self.assertTrue(
frappe.db.exists("Stock Ledger Entry", {"voucher_no": sr.name, "is_adjustment_entry": 1})
)
# a backdated receipt lands before the write-off
if backdated_qty:
make_stock_entry(
item_code=item_code,
target=warehouse,
qty=backdated_qty,
basic_rate=50,
posting_date=add_days(nowdate(), -7),
)
return item_code, warehouse, sr
def _assert_backdated_stock_survives(self, item_code, warehouse, sr):
adjustment_sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": sr.name, "is_cancelled": 0},
["qty_after_transaction", "stock_value", "stock_value_difference", "valuation_rate"],
as_dict=True,
)
# the backdated stock is carried through the adjustment entry, not wiped out by it
self.assertEqual(flt(adjustment_sle.qty_after_transaction), 4.0)
self.assertEqual(flt(adjustment_sle.stock_value), 200.0)
self.assertEqual(flt(adjustment_sle.valuation_rate), 50.0)
# and the write-off still lands the running ledger value on the stock value it holds
self.assertEqual(
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
200.0,
)
self.assertEqual(get_stock_balance(item_code, warehouse), 4.0)
def test_adjustment_entry_does_not_zero_out_backdated_stock(self):
"""An adjustment entry restates value, so a backdated receipt posted before it must survive."""
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Backdated Adjustment", "FIFO"
)
self._assert_backdated_stock_survives(item_code, warehouse, sr)
def test_adjustment_entry_does_not_zero_out_backdated_stock_moving_average(self):
"""Same, through the moving average path rather than the queue."""
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Backdated Adjustment MA", "Moving Average"
)
self._assert_backdated_stock_survives(item_code, warehouse, sr)
def test_adjustment_row_amount_is_not_distorted_by_rate_rounding(self):
"""The refreshed amount comes from the ledger's stock value, not from a rounded rate."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Adjustment Rounding", "FIFO", backdated_qty=0
)
# a backdated receipt whose value does not divide evenly into a 2 decimal rate
make_stock_entry(
item_code=item_code,
target=warehouse,
qty=10000,
basic_rate=1.2345,
posting_date=add_days(nowdate(), -7),
)
sr.reload()
row = sr.items[0]
self.assertEqual(flt(row.current_qty), 10000.0)
self.assertEqual(flt(row.current_amount), 12345.0)
def test_cancelling_adjustment_entry_shifts_no_qty(self):
"""Reversing a value-only entry must not push the preserved quantity into later entries."""
from erpnext.stock.stock_ledger import get_stock_reco_qty_shift
_item_code, _warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Adjustment Cancel", "FIFO"
)
sr.reload()
row = sr.items[0]
# the refreshed document reports the balance the ledger carries and no quantity movement
self.assertEqual(flt(row.current_qty), 4.0)
self.assertEqual(flt(row.quantity_difference), 0.0)
self.assertEqual(flt(row.current_valuation_rate), 50.0)
self.assertEqual(flt(row.current_amount), 200.0)
self.assertEqual(flt(row.amount_difference), -100.0)
# the reversal built on cancellation moves nothing, so later entries are not shifted
sr.docstatus = 2
args = sr.get_sle_for_items(row)
args.actual_qty = -flt(args.actual_qty) # as make_sl_entries flips it for a cancellation
self.assertEqual(flt(args.actual_qty), 0.0)
self.assertEqual(flt(get_stock_reco_qty_shift(args)), 0.0)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -109,63 +109,30 @@ class SerialBatchBundle:
):
return True
def get_transit_package(self) -> str | None:
if self.sle.is_cancelled or self.sle.voucher_type not in ["Delivery Note", "Sales Invoice"]:
return None
return frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_no": self.sle.voucher_no,
"voucher_detail_no": self.sle.voucher_detail_no,
"item_code": self.sle.item_code,
"actual_qty": ("<", 0),
"is_cancelled": 0,
"serial_and_batch_bundle": ("is", "set"),
},
"serial_and_batch_bundle",
order_by="creation desc",
)
def make_serial_batch_no_bundle_for_material_transfer(self, bundle):
def make_serial_batch_no_bundle_for_material_transfer(self):
from erpnext.controllers.stock_controller import make_bundle_for_material_transfer
if not bundle:
return
new_bundle_id = make_bundle_for_material_transfer(
is_new=False,
docstatus=1,
voucher_type=self.sle.voucher_type,
voucher_no=self.sle.voucher_no,
serial_and_batch_bundle=bundle,
warehouse=self.sle.warehouse,
type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward",
do_not_submit=0,
bundle = frappe.db.get_value(
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
)
self.sle.db_set({"serial_and_batch_bundle": new_bundle_id})
if bundle:
new_bundle_id = make_bundle_for_material_transfer(
is_new=False,
docstatus=1,
voucher_type=self.sle.voucher_type,
voucher_no=self.sle.voucher_no,
serial_and_batch_bundle=bundle,
warehouse=self.sle.warehouse,
type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward",
do_not_submit=0,
)
self.sle.db_set({"serial_and_batch_bundle": new_bundle_id})
def make_serial_batch_no_bundle(self):
if self.sle.actual_qty > 0 and (transit_package := self.get_transit_package()):
self.make_serial_batch_no_bundle_for_material_transfer(transit_package)
if not self.is_packed_entry():
frappe.db.set_value(
self.child_doctype,
self.sle.voucher_detail_no,
"serial_and_batch_bundle",
self.sle.serial_and_batch_bundle,
)
return
self.validate_item()
if self.sle.actual_qty > 0 and self.is_material_transfer():
self.make_serial_batch_no_bundle_for_material_transfer(
frappe.db.get_value(
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
)
)
self.make_serial_batch_no_bundle_for_material_transfer()
return
sn_doc = SerialBatchCreation(
@@ -346,7 +313,6 @@ class SerialBatchBundle:
)
and self.sle.actual_qty < 0
)
or (self.sle.actual_qty > 0 and self.get_transit_package())
)
):
self.make_serial_batch_no_bundle()

View File

@@ -580,14 +580,7 @@ class SerialBatchBundleService:
)
def make_package_for_transfer(
self,
serial_and_batch_bundle,
warehouse,
type_of_transaction=None,
do_not_submit=None,
qty=0,
include_bundle=None,
exclude_serial_nos=None,
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
):
from erpnext.controllers.stock_controller import make_bundle_for_material_transfer
@@ -601,8 +594,6 @@ class SerialBatchBundleService:
type_of_transaction=type_of_transaction,
do_not_submit=do_not_submit,
qty=qty,
include_bundle=include_bundle,
exclude_serial_nos=exclude_serial_nos,
)
def validate_reserved_batches(self):

View File

@@ -28,6 +28,13 @@ from frappe.utils import (
import erpnext
from erpnext.stock.doctype.bin.bin import update_qty_from_sle
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_batch_nos_details,
get_sre_reserved_serial_nos_details,
)
from erpnext.stock.utils import (
get_combine_datetime,
get_incoming_outgoing_rate_for_cancel,
@@ -35,7 +42,6 @@ from erpnext.stock.utils import (
get_serial_nos_data,
get_stock_balance,
get_valuation_method,
is_serial_no_wise_valuation_disabled,
)
from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
@@ -640,7 +646,6 @@ class update_entries_after:
self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company")
self.set_precision()
self.valuation_method = get_valuation_method(self.item_code, self.company)
self.skip_serial_batch_valuation = is_serial_no_wise_valuation_disabled(self.item_code)
self.repost_affected_transaction = args.get("repost_affected_transaction") or set()
self.new_items_found = False
@@ -954,10 +959,8 @@ class update_entries_after:
def process_sle_against_current_timestamp(self):
sl_entries = get_sle_against_current_voucher(self.args)
if self.args.get("cancelled"):
# Cancellation flags every entry of the voucher first, so this query usually returns
# nothing and the args are the only anchor left to seed the previous values from.
self.seed_previous_sle_for_cancellation(sl_entries[0] if sl_entries else self.args)
if self.args.get("cancelled") and sl_entries:
self.seed_previous_sle_for_cancellation(sl_entries[0])
for sle in sl_entries:
sle["timestamp"] = sle.posting_datetime
self.process_sle(sle)
@@ -968,7 +971,7 @@ class update_entries_after:
return
args = frappe._dict(anchor_sle)
args["sle_id"] = args.get("name")
args["sle_id"] = args.name
prev_sle = get_previous_sle_of_current_voucher(args)
if prev_sle:
self.prev_sle_dict[key] = prev_sle
@@ -1086,9 +1089,9 @@ class update_entries_after:
# Inventory is always carried at the standard rate effective on the posting date;
# FIFO/Moving Average/serial-batch valuation is bypassed entirely.
self.process_standard_cost(sle)
elif sle.serial_and_batch_bundle and not self.skip_serial_batch_valuation:
elif sle.serial_and_batch_bundle:
self.calculate_valuation_for_serial_batch_bundle(sle)
elif sle.serial_no and not self.skip_serial_batch_valuation and not self.args.get("sle_id"):
elif sle.serial_no and not self.args.get("sle_id"):
# Only run in reposting
self.get_serialized_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
@@ -1100,7 +1103,6 @@ class update_entries_after:
)
elif (
sle.batch_no
and not self.skip_serial_batch_valuation
and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True)
and not self.args.get("sle_id")
):
@@ -1109,8 +1111,6 @@ class update_entries_after:
else:
if (
sle.voucher_type == "Stock Reconciliation"
# an adjustment entry counted nothing, so it must not assert a balance
and not sle.is_adjustment_entry
and not sle.batch_no
and not sle.has_batch_no
and not has_dimensions
@@ -1172,20 +1172,25 @@ class update_entries_after:
sle.stock_value_difference = stock_value_difference
# Re-derive the write-off on every repost: whatever brings the running sum of
# stock_value_difference back in line with the stock value held at this point. A non-zero
# difference above means the entry moved something, so it is not a write-off and is left alone.
if sle.is_adjustment_entry and flt(sle.stock_value_difference, self.currency_precision) == 0:
value_till_now = get_stock_value_difference(
sle.item_code,
sle.warehouse,
sle.posting_date,
sle.posting_time,
voucher_detail_no=sle.voucher_detail_no,
creation=sle.creation,
if (
sle.is_adjustment_entry
and flt(sle.qty_after_transaction, self.flt_precision) == 0
and (
flt(sle.stock_value, self.currency_precision) != 0
or flt(sle.stock_value_difference, self.currency_precision) == 0
)
):
sle.stock_value_difference = (
get_stock_value_difference(
sle.item_code,
sle.warehouse,
sle.posting_date,
sle.posting_time,
voucher_detail_no=sle.voucher_detail_no,
creation=sle.creation,
)
* -1
)
sle.stock_value_difference = flt(flt(sle.stock_value) - value_till_now, self.currency_precision)
sle.doctype = "Stock Ledger Entry"
sle.modified = now()
@@ -1414,19 +1419,6 @@ class update_entries_after:
else:
sle.outgoing_rate = rate
elif self.has_stale_serial_no_wise_outgoing_rate(sle):
# Serial No Wise Valuation is off, but the entry still carries its serial nos' rate and has
# no recalculate_rate flag to re-derive it. Value it at the rate running just before it.
sle.outgoing_rate = flt(self.wh_data.valuation_rate)
def has_stale_serial_no_wise_outgoing_rate(self, sle):
return bool(
self.skip_serial_batch_valuation
and self.valuation_method == "Moving Average"
and flt(sle.actual_qty) < 0
and flt(sle.outgoing_rate)
)
def has_landed_cost_based_on_pi(self, sle):
if sle.voucher_type == "Purchase Receipt" and frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
@@ -1454,9 +1446,11 @@ class update_entries_after:
get_rate_for_return, # don't move this import to top
)
if self.valuation_method == "Moving Average" and (
self.skip_serial_batch_valuation
or not (sle.get("serial_no") or sle.get("batch_no") or sle.get("serial_and_batch_bundle"))
if (
self.valuation_method == "Moving Average"
and not sle.get("serial_no")
and not sle.get("batch_no")
and not sle.get("serial_and_batch_bundle")
):
rate = self.get_moving_average_rate_for_return(sle)
@@ -1469,9 +1463,6 @@ class update_entries_after:
sle=sle,
)
elif self.skip_serial_batch_valuation and flt(sle.actual_qty) < 0:
rate = 0.0
else:
rate = get_rate_for_return(
sle.voucher_type,
@@ -1813,9 +1804,6 @@ class update_entries_after:
self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
def is_return_purchase_entry(self, sle):
if self.skip_serial_batch_valuation:
return False
if sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"]:
return frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_return")
@@ -2599,6 +2587,51 @@ def validate_reserved_stock(kwargs):
frappe.throw(msg, title=_("Reserved Stock"))
def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(item_code, warehouse, serial_nos):
if common_serial_nos := list(set(serial_nos).intersection(set(reserved_serial_nos_details.keys()))):
msg = _(
"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding."
)
msg += "<br />"
msg += _("Example: Serial No {0} reserved in {1}.").format(
frappe.bold(common_serial_nos[0]),
frappe.get_desk_link(
"Stock Reservation Entry", reserved_serial_nos_details[common_serial_nos[0]]
),
)
frappe.throw(msg, title=_("Reserved Serial No."))
def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos):
available_batches = get_auto_batch_nos(
frappe._dict(
{
"item_code": item_code,
"warehouse": warehouse,
"posting_datetime": get_combine_datetime(nowdate(), nowtime()),
}
)
)
available_batches_map = {row.batch_no: row.qty for row in available_batches}
precision = cint(frappe.db.get_default("float_precision")) or 2
for batch_no in batch_nos:
diff = flt(
available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision
)
if diff < 0 and abs(diff) > 0.0001:
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
abs(diff),
frappe.get_desk_link("Batch", batch_no),
frappe.get_desk_link("Warehouse", warehouse),
nowdate(),
nowtime(),
)
frappe.throw(msg, title=_("Reserved Stock for Batch"))
def is_negative_stock_allowed(*, item_code: str | None = None) -> bool:
if frappe.get_cached_doc("Stock Settings").allow_negative_stock:
return True

View File

@@ -317,26 +317,15 @@ def _get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fa
in_rate = None
item_details = frappe.get_cached_value(
"Item",
args.get("item_code"),
["has_serial_no", "has_batch_no", "use_serial_no_wise_valuation"],
as_dict=1,
"Item", args.get("item_code"), ["has_serial_no", "has_batch_no"], as_dict=1
)
use_moving_avg_for_batch = frappe.get_single_value("Stock Settings", "do_not_use_batchwise_valuation")
skip_serial_batch_valuation = bool(
item_details and item_details.has_serial_no and not item_details.use_serial_no_wise_valuation
)
if isinstance(args, dict):
args = frappe._dict(args)
if (
item_details
and item_details.has_serial_no
and args.get("serial_and_batch_bundle")
and not skip_serial_batch_valuation
):
if item_details and item_details.has_serial_no and args.get("serial_and_batch_bundle"):
args.actual_qty = args.qty
sn_obj = SerialNoValuation(
sle=args,
@@ -351,7 +340,6 @@ def _get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fa
and item_details.has_batch_no
and args.get("serial_and_batch_bundle")
and not use_moving_avg_for_batch
and not skip_serial_batch_valuation
):
args.actual_qty = args.qty
batch_obj = BatchNoValuation(
@@ -362,23 +350,14 @@ def _get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fa
return batch_obj.get_incoming_rate()
elif (
(args.get("serial_no") or "").strip()
and not args.get("serial_and_batch_bundle")
and not skip_serial_batch_valuation
):
elif (args.get("serial_no") or "").strip() and not args.get("serial_and_batch_bundle"):
args.actual_qty = args.qty
args.serial_nos = get_serial_nos_data(args.get("serial_no"))
sn_obj = SerialNoValuation(sle=args, warehouse=args.get("warehouse"), item_code=args.get("item_code"))
return sn_obj.get_incoming_rate()
elif (
args.get("batch_no")
and not args.get("serial_and_batch_bundle")
and not use_moving_avg_for_batch
and not skip_serial_batch_valuation
):
elif args.get("batch_no") and not args.get("serial_and_batch_bundle") and not use_moving_avg_for_batch:
args.actual_qty = args.qty
args.batch_nos = frappe._dict({args.batch_no: args})
@@ -431,16 +410,9 @@ def get_avg_purchase_rate(serial_nos):
)
def is_serial_no_wise_valuation_disabled(item_code) -> bool:
item_details = frappe.get_cached_value(
"Item", item_code, ["has_serial_no", "use_serial_no_wise_valuation"], as_dict=1
)
return bool(item_details and item_details.has_serial_no and not item_details.use_serial_no_wise_valuation)
@frappe.request_cache
def get_valuation_method(item_code, company=None):
"""get valuation method from item or default"""
val_method = frappe.get_cached_value("Item", item_code, "valuation_method")
if not val_method:
val_method = (
@@ -451,14 +423,6 @@ def get_valuation_method(item_code, company=None):
return val_method
def clear_valuation_method_cache():
cache = getattr(frappe.local, "request_cache", None)
if not cache:
return
cache.pop(getattr(get_valuation_method, "__wrapped__", get_valuation_method), None)
def get_fifo_rate(previous_stock_queue, qty):
"""get FIFO (average) Rate from Queue"""
return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO")