From f3dcc6180f60fefc4319d9fdb3a2824642254ff2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 15 Sep 2026 12:11:21 +0530 Subject: [PATCH] fix(stock): value stock entry rows as of the posting date (#59042) --- .../stock/doctype/stock_entry/stock_entry.py | 14 +- .../doctype/stock_entry/test_stock_entry.py | 120 +++++++++++++++--- erpnext/stock/stock_ledger.py | 27 +++- 3 files changed, 137 insertions(+), 24 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ad24efa914d..3fad9e50e54 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -34,7 +34,7 @@ from erpnext.stock.get_item_details import ( get_default_cost_center, ) from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate -from erpnext.stock.utils import get_incoming_rate +from erpnext.stock.utils import get_combine_datetime, get_incoming_rate from .services.disassemble import DisassembleStockEntry from .services.manufacturing import ( @@ -736,6 +736,18 @@ class StockEntry(StockController, SubcontractingInwardController): raise_error_if_no_rate=raise_error_if_no_rate, batch_no=d.batch_no, serial_and_batch_bundle=d.serial_and_batch_bundle, + posting_datetime=get_combine_datetime(self.posting_date, self.posting_time), + creation=self.first_sle_creation, + ) + + @property + def first_sle_creation(self): + """Creation of this entry's earliest ledger entry, if it has posted any yet.""" + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0}, + "creation", + order_by="creation asc", ) def _notify_zero_valuation_rate(self, items): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 673ef381249..adaffa3940a 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -56,6 +56,19 @@ def get_sle(**args): ) +def stock_entry_row(item_code, qty, **kwargs): + stock_uom = frappe.db.get_value("Item", item_code, "stock_uom") + return { + "item_code": item_code, + "qty": qty, + "transfer_qty": qty, + "uom": stock_uom, + "stock_uom": stock_uom, + "conversion_factor": 1, + **kwargs, + } + + class TestStockEntry(ERPNextTestSuite): def setUp(self): self.load_test_records("Stock Entry") @@ -1609,32 +1622,25 @@ class TestStockEntry(ERPNextTestSuite): make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100) - def row(item_code, qty, **kwargs): - stock_uom = frappe.db.get_value("Item", item_code, "stock_uom") - return { - "item_code": item_code, - "qty": qty, - "transfer_qty": qty, - "uom": stock_uom, - "stock_uom": stock_uom, - "conversion_factor": 1, - **kwargs, - } - entry = frappe.new_doc("Stock Entry") entry.company = "_Test Company" entry.purpose = "Manufacture" entry.set_stock_entry_type() entry.fg_completed_qty = 1 - entry.append("items", row(rm_item, 10, s_warehouse="_Test Warehouse - _TC")) - entry.append("items", row(fg_item, 1, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1)) + entry.append("items", stock_entry_row(rm_item, 10, s_warehouse="_Test Warehouse - _TC")) entry.append( "items", - row(scrap_item, 2, t_warehouse="_Test Warehouse 1 - _TC", secondary_item_type="Scrap"), + stock_entry_row(fg_item, 1, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1), ) entry.append( "items", - row( + stock_entry_row( + scrap_item, 2, t_warehouse="_Test Warehouse 1 - _TC", secondary_item_type="Scrap" + ), + ) + entry.append( + "items", + stock_entry_row( manual_item, 1, t_warehouse="_Test Warehouse 1 - _TC", @@ -1663,6 +1669,88 @@ class TestStockEntry(ERPNextTestSuite): manual_row.valuation_type = "% of Component Cost" self.assertRaises(frappe.ValidationError, entry.save) + @ERPNextTestSuite.change_settings("Stock Reposting Settings", {"item_based_reposting": 0}) + def test_repost_values_costed_out_row_as_of_posting_date(self): + from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import repost_sl_entries + + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1, "valuation_method": "Moving Average"}).name + scrap_item = make_item(properties={"is_stock_item": 1}).name + + make_stock_entry( + item_code=rm_item, + target="_Test Warehouse - _TC", + qty=10, + basic_rate=100, + posting_date=add_days(today(), -10), + ) + make_stock_entry( + item_code=scrap_item, + target="_Test Warehouse 1 - _TC", + qty=5, + basic_rate=50, + posting_date=add_days(today(), -10), + ) + + entry = frappe.new_doc("Stock Entry") + entry.company = "_Test Company" + entry.purpose = "Manufacture" + entry.set_stock_entry_type() + entry.set_posting_time = 1 + entry.posting_date = add_days(today(), -5) + entry.posting_time = "10:00:00" + entry.fg_completed_qty = 1 + entry.append("items", stock_entry_row(rm_item, 10, s_warehouse="_Test Warehouse - _TC")) + entry.append( + "items", + stock_entry_row(fg_item, 1, t_warehouse="_Test Warehouse 1 - _TC", is_finished_item=1), + ) + entry.append( + "items", + stock_entry_row( + scrap_item, 2, t_warehouse="_Test Warehouse 1 - _TC", secondary_item_type="Scrap" + ), + ) + entry.insert() + entry.submit() + + self.assertEqual(entry.items[2].basic_rate, 50) + self.assertEqual(entry.items[1].basic_rate, 900) + + make_stock_entry( + item_code=scrap_item, + target="_Test Warehouse 1 - _TC", + qty=10, + basic_rate=5000, + posting_date=add_days(today(), -1), + ) + + make_stock_entry( + item_code=scrap_item, + target="_Test Warehouse 1 - _TC", + qty=10, + basic_rate=9000, + posting_date=add_days(today(), -5), + posting_time="10:00:00", + ) + + backdated_receipt = make_stock_entry( + item_code=rm_item, + target="_Test Warehouse - _TC", + qty=10, + basic_rate=200, + posting_date=add_days(today(), -8), + ) + repost = frappe.db.get_value( + "Repost Item Valuation", {"voucher_no": backdated_receipt.name, "docstatus": 1}, "name" + ) + repost_sl_entries(frappe.get_doc("Repost Item Valuation", repost)) + + entry.load_from_db() + + self.assertEqual(entry.items[2].basic_rate, 50) + self.assertEqual(entry.items[1].basic_rate, 1400) + def test_valuation_rate_lookup_without_voucher_no(self): from erpnext.stock.stock_ledger import get_valuation_rate diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index e6b5dc11940..3e35962ee0e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2155,6 +2155,15 @@ def get_sle_by_voucher_detail_no(voucher_detail_no): ) +def get_prior_ledger_condition(table, posting_datetime, creation): + """Restrict a ledger lookup to the entries that precede a voucher in ledger order.""" + if creation: + return (table.posting_datetime < posting_datetime) | ( + (table.posting_datetime == posting_datetime) & (table.creation < creation) + ) + return table.posting_datetime <= posting_datetime + + def get_valuation_rate( item_code, warehouse, @@ -2167,6 +2176,8 @@ def get_valuation_rate( raise_error_if_no_rate=True, batch_no=None, serial_and_batch_bundle=None, + posting_datetime=None, + creation=None, ): from erpnext.stock.serial_batch_bundle import BatchNoValuation @@ -2189,6 +2200,9 @@ def get_valuation_rate( # Comparing against a None voucher_no yields NULL, which filters out every row query = query.where((table.voucher_no != voucher_no) | (table.voucher_type != voucher_type)) + if posting_datetime: + query = query.where(get_prior_ledger_condition(table, posting_datetime, creation)) + last_valuation_rate = query.run() if last_valuation_rate and last_valuation_rate[0][0] is not None: return flt(last_valuation_rate[0][0]) @@ -2236,6 +2250,11 @@ def get_valuation_rate( ~((sle_entry.voucher_no == voucher_no) & (sle_entry.voucher_type == voucher_type)) ) + if posting_datetime: + last_sle_query = last_sle_query.where( + get_prior_ledger_condition(sle_entry, posting_datetime, creation) + ) + if last_valuation_rate := last_sle_query.run(): return flt(last_valuation_rate[0][0]) @@ -2671,13 +2690,7 @@ def get_stock_value_difference( elif voucher_no: query = query.where(table.voucher_no != voucher_no) - if creation: - query = query.where( - (table.posting_datetime < posting_datetime) - | ((table.posting_datetime == posting_datetime) & (table.creation < creation)) - ) - else: - query = query.where(table.posting_datetime <= posting_datetime) + query = query.where(get_prior_ledger_condition(table, posting_datetime, creation)) difference_amount = query.run() return flt(difference_amount[0][0]) if difference_amount else 0