From 5738cfce794a5a5de624d3e72763a297b6cd1122 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 10:48:24 +0530 Subject: [PATCH 1/3] fix: repost read stale sibling SLE rate for moving average returns During repost, a return line with recalculate_rate resolved its moving average rate through get_incoming_rate -> get_previous_sle, which matches posting_datetime <= and orders by creation desc. For a multi-line return of the same item, every line shares one posting_datetime, so the query landed on a sibling line of the same voucher whose stored valuation_rate was still the previous repost run's output, not the rate before the voucher. Each repost run therefore re-seeded the voucher from its own prior output. The error gain per run is (qty returned at the stale rate) / (qty remaining after the return), so whenever a return removes most of the stock the loop diverges instead of converging, alternating sign and growing until stock_value overflows decimal(21,9) and the repost dies with 'Out of range value for column stock_value'. Use the in-memory running valuation rate that update_entries_after already tracks for the warehouse at this point in the repost. It is the authoritative pre-entry state, is immune to sibling rows, and makes the repost idempotent. The database lookup is kept only as a fallback for a zero in-memory rate, preserving the existing zero-rate fallback chain. --- erpnext/stock/stock_ledger.py | 36 ++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index a2cce420e3a..f7626931243 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1260,23 +1260,25 @@ class update_entries_after: and not sle.get("batch_no") and not sle.get("serial_and_batch_bundle") ): - rate = get_incoming_rate( - { - "item_code": sle.item_code, - "warehouse": sle.warehouse, - "posting_date": sle.posting_date, - "posting_time": sle.posting_time, - "qty": sle.actual_qty, - "serial_no": sle.get("serial_no"), - "batch_no": sle.get("batch_no"), - "serial_and_batch_bundle": sle.get("serial_and_batch_bundle"), - "company": sle.company, - "voucher_type": sle.voucher_type, - "voucher_no": sle.voucher_no, - "allow_zero_valuation": self.allow_zero_rate, - "sle": sle.name, - } - ) + rate = flt(self.wh_data.valuation_rate) + if not rate: + rate = get_incoming_rate( + { + "item_code": sle.item_code, + "warehouse": sle.warehouse, + "posting_date": sle.posting_date, + "posting_time": sle.posting_time, + "qty": sle.actual_qty, + "serial_no": sle.get("serial_no"), + "batch_no": sle.get("batch_no"), + "serial_and_batch_bundle": sle.get("serial_and_batch_bundle"), + "company": sle.company, + "voucher_type": sle.voucher_type, + "voucher_no": sle.voucher_no, + "allow_zero_valuation": self.allow_zero_rate, + "sle": sle.name, + } + ) if not rate and sle.voucher_type in ["Delivery Note", "Sales Invoice"]: rate = get_rate_for_return( From 9c8d5ac8a54f784401bcbd694d9b096dbb3d356c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 10:53:04 +0530 Subject: [PATCH 2/3] test: repost of multi-line moving average return is idempotent Reposting a return that removes most of the stock across several lines of the same item must keep every line at the running average and produce identical results on a second repost. Before the fix the first repost already drifted, seeding each line from a sibling row of the same voucher. --- .../test_repost_item_valuation.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 5291b2e4381..85c7372be59 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -475,6 +475,55 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): # incoming rate after reposting should be 150 self.assertSLEs(se, [{"incoming_rate": 150}]) + def test_repost_multi_line_moving_average_return(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + item = self.make_item(properties={"valuation_method": "Moving Average"}).name + warehouse = "_Test Warehouse - _TC" + + make_purchase_receipt(item_code=item, qty=100, rate=100, warehouse=warehouse) + + pr = make_purchase_receipt(item_code=item, qty=400, rate=200, warehouse=warehouse, do_not_submit=1) + for qty in (100, 300, 100): + pr.append( + "items", + { + "item_code": item, + "warehouse": warehouse, + "qty": qty, + "received_qty": qty, + "rate": 200, + "uom": pr.items[0].uom, + "conversion_factor": 1.0, + }, + ) + pr.save() + pr.submit() + + return_pr = make_return_doc(pr.doctype, pr.name) + return_pr.save() + return_pr.submit() + + expected_sles = [ + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 600.0}, + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 500.0}, + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 200.0}, + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 100.0}, + ] + + for _ in range(2): + riv = frappe.get_doc( + doctype="Repost Item Valuation", + based_on="Transaction", + voucher_type=pr.doctype, + voucher_no=pr.name, + posting_date=pr.posting_date, + posting_time=pr.posting_time, + ) + riv.submit() + + self.assertSLEs(return_pr, expected_sles) + def test_remove_attached_file(self): item_code = make_item("_Test Remove Attached File Item", properties={"is_stock_item": 1}) From 8131af940563ddb1b6cd99d7d0def0277208563b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 11:00:47 +0530 Subject: [PATCH 3/3] fix: zero-rate repost fallback could still read sibling SLE When the in-memory running rate is zero, the fallback went through get_incoming_rate, whose previous-SLE lookup matches the same posting_datetime and can land on a sibling line of the voucher being replayed. Replace it with get_previous_sle_of_current_voucher excluding the current voucher, keeping the get_valuation_rate chain when no previous entry exists. get_incoming_rate is no longer used in this module. --- erpnext/stock/stock_ledger.py | 53 ++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index f7626931243..52ca6418fc1 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -38,7 +38,6 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor from erpnext.stock.utils import ( get_combine_datetime, get_incoming_outgoing_rate_for_cancel, - get_incoming_rate, get_or_make_bin, get_serial_nos_data, get_stock_balance, @@ -1260,25 +1259,7 @@ class update_entries_after: and not sle.get("batch_no") and not sle.get("serial_and_batch_bundle") ): - rate = flt(self.wh_data.valuation_rate) - if not rate: - rate = get_incoming_rate( - { - "item_code": sle.item_code, - "warehouse": sle.warehouse, - "posting_date": sle.posting_date, - "posting_time": sle.posting_time, - "qty": sle.actual_qty, - "serial_no": sle.get("serial_no"), - "batch_no": sle.get("batch_no"), - "serial_and_batch_bundle": sle.get("serial_and_batch_bundle"), - "company": sle.company, - "voucher_type": sle.voucher_type, - "voucher_no": sle.voucher_no, - "allow_zero_valuation": self.allow_zero_rate, - "sle": sle.name, - } - ) + rate = self.get_moving_average_rate_for_return(sle) if not rate and sle.voucher_type in ["Delivery Note", "Sales Invoice"]: rate = get_rate_for_return( @@ -1346,6 +1327,38 @@ class update_entries_after: return rate + def get_moving_average_rate_for_return(self, sle): + """Rate just before this entry, taken from the in-memory running state so a + multi-line return never reads a sibling row of its own voucher.""" + rate = flt(self.wh_data.valuation_rate) + if rate: + return rate + + previous_sle = get_previous_sle_of_current_voucher( + frappe._dict( + item_code=sle.item_code, + warehouse=sle.warehouse, + posting_date=sle.posting_date, + posting_time=sle.posting_time, + voucher_no=sle.voucher_no, + ), + exclude_current_voucher=True, + ) + + rate = previous_sle.get("valuation_rate") + if rate is None: + rate = get_valuation_rate( + sle.item_code, + sle.warehouse, + sle.voucher_type, + sle.voucher_no, + self.allow_zero_rate, + currency=erpnext.get_company_currency(sle.company), + company=sle.company, + ) + + return flt(rate) + def update_outgoing_rate_on_transaction(self, sle): """ Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return