From e834098c28bcda3715fb8afb88b11c82681ed601 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:08:54 +0530 Subject: [PATCH] fix: sync Stock Reconciliation difference amount with GL after reposting (backport #56574) (#56585) * fix: sync Stock Reconciliation difference amount with GL after reposting (#56574) * fix: sync Stock Reconciliation difference amount with GL after reposting * fix: placement of recalculate differece amount function (cherry picked from commit c7ef42ef98b9e1da549f67e73291fb25431aa46e) # Conflicts: # erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py # erpnext/stock/stock_ledger.py * chore: fix conflicts Removed unused import and fixed import order. * chore: fix conflicts Refactor update_rate_on_stock_reconciliation to use recalculation method for difference amount. --------- Co-authored-by: rohitwaghchaure --- .../stock_reconciliation.py | 113 ++++++++++++ .../test_stock_reconciliation.py | 166 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 45 ++--- 3 files changed, 291 insertions(+), 33 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 3598719f8ae..aa94b808988 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -1008,6 +1008,102 @@ class StockReconciliation(StockController): d.quantity_difference = flt(d.qty) - flt(d.current_qty) d.amount_difference = flt(d.amount) - flt(d.current_amount) + def recalculate_difference_amount_from_ledger(self): + """Sync the displayed current qty/rate and difference amount with the (reposted) ledger. + + Submitted reconciliations freeze ``difference_amount`` and the per-row current values at + submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger + Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures + that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed + ``stock_value_difference`` keeps the document and the GL consistent by construction. + """ + difference_amount = 0.0 + + for row in self.items: + stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name)) + + 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) + current_valuation_rate = ( + flt(current_amount / current_qty, row.precision("current_valuation_rate")) + if current_qty + else 0.0 + ) + + row.db_set( + { + "amount": amount, + "current_qty": current_qty, + "current_valuation_rate": current_valuation_rate, + "current_amount": current_amount, + "quantity_difference": flt(row.qty) - current_qty, + "amount_difference": amount_difference, + }, + update_modified=False, + ) + + difference_amount += amount_difference + + self.db_set( + "difference_amount", + flt(difference_amount, self.precision("difference_amount")), + 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. + + Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation + (blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight + from the current bundle. Non-serial rows can float, so read the ledger balance just before the + reconciliation, excluding the reconciliation's own entries. + """ + if row.current_serial_and_batch_bundle: + total_qty = frappe.db.get_value( + "Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty" + ) + return abs(flt(total_qty, row.precision("current_qty"))) + + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "is_cancelled": 0, + }, + ["posting_datetime", "creation"], + as_dict=True, + ) + if not reco_sle: + 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) + .where( + (sle.item_code == row.item_code) + & (sle.warehouse == row.warehouse) + & (sle.is_cancelled == 0) + & ( + (sle.posting_datetime < reco_sle.posting_datetime) + | ( + (sle.posting_datetime == reco_sle.posting_datetime) + & (sle.creation < reco_sle.creation) + ) + ) + ) + .orderby(sle.posting_datetime, order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) + .limit(1) + ).run() + + return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0 + def submit(self): if len(self.items) > 100: msgprint( @@ -1194,6 +1290,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None): return itemwise_batch_data +def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str): + """Net stock value change posted to the GL by a reconciliation row (sum of its SLEs).""" + sle = frappe.qb.DocType("Stock Ledger Entry") + result = ( + frappe.qb.from_(sle) + .select(Sum(sle.stock_value_difference)) + .where( + (sle.voucher_type == voucher_type) + & (sle.voucher_no == voucher_no) + & (sle.voucher_detail_no == voucher_detail_no) + & (sle.is_cancelled == 0) + ) + ).run() + + return flt(result[0][0]) if result and result[0][0] else 0.0 + + @frappe.whitelist() def get_stock_balance_for( item_code: str, diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index a0fc5a0161e..0d54ea89eba 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -787,6 +787,172 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): sr1.load_from_db() self.assertEqual(sr1.difference_amount, 10000) + def assert_reco_difference_matches_gl(self, reco_name): + """The displayed Difference Amount (doc and per-row) must equal the reposted GL impact, + i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``.""" + from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( + get_row_stock_value_difference, + ) + + reco = frappe.get_doc("Stock Reconciliation", reco_name) + total_difference = 0.0 + + for row in reco.items: + row_difference = flt( + get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name), + row.precision("amount_difference"), + ) + + self.assertEqual(flt(row.amount_difference), row_difference) + total_difference += row_difference + + self.assertEqual( + flt(reco.difference_amount, reco.precision("difference_amount")), + flt(total_difference, reco.precision("difference_amount")), + ) + + def test_difference_amount_synced_with_gl_after_repost_non_serialized(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item().name + warehouse = "_Test Warehouse - _TC" + + # Opening stock => 100 * 100 = 10000 + make_stock_entry( + item_code=item_code, + target=warehouse, + qty=100, + basic_rate=100, + posting_date=add_days(nowdate(), -5), + posting_time="10:00:00", + ) + + # Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=100, + rate=200, + posting_date=add_days(nowdate(), -2), + ) + self.assertEqual(reco.difference_amount, 10000) + self.assert_reco_difference_matches_gl(reco.name) + + # Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500 + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=50, + rate=50, + posting_date=add_days(nowdate(), -3), + ) + + reco.load_from_db() + # Current is now 2500 => difference 20000 - 2500 = 17500 + self.assertEqual(reco.difference_amount, 17500) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_batched(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Batch Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-DIFFSYNC-.###", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 10 @ 100 (batch value 1000) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=500, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_serialized(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Serial Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSIRDS.####", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 5 serial nos @ 100 (value 500) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=500, + serial_no="\n".join(serial_nos), + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + def test_make_stock_zero_for_serial_batch_item(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 8c4eb5a8d1d..b8e7874002b 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1337,6 +1337,11 @@ class update_entries_after: Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount """ + if sle.voucher_type == "Stock Reconciliation": + if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"): + self.update_rate_on_stock_reconciliation(sle) + return + if sle.actual_qty and sle.voucher_detail_no: outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty) @@ -1348,8 +1353,6 @@ class update_entries_after: self.update_rate_on_purchase_receipt(sle, outgoing_rate) elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt": self.update_rate_on_subcontracting_receipt(sle, outgoing_rate) - elif sle.voucher_type == "Stock Reconciliation": - self.update_rate_on_stock_reconciliation(sle) def update_rate_on_stock_entry(self, sle, outgoing_rate): frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) @@ -1443,37 +1446,13 @@ class update_entries_after: d.db_update() def update_rate_on_stock_reconciliation(self, sle): - if not sle.serial_no and not sle.batch_no: - sr = frappe.get_lazy_doc("Stock Reconciliation", sle.voucher_no, for_update=True) - - for item in sr.items: - # Skip for Serial and Batch Items - if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no: - continue - - previous_sle = get_previous_sle( - { - "item_code": item.item_code, - "warehouse": item.warehouse, - "posting_date": sr.posting_date, - "posting_time": sr.posting_time, - "sle": sle.name, - } - ) - - item.current_qty = previous_sle.get("qty_after_transaction") or 0.0 - item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0 - item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate) - - item.amount = flt(item.qty) * flt(item.valuation_rate) - item.quantity_difference = item.qty - item.current_qty - item.amount_difference = item.amount - item.current_amount - else: - sr.difference_amount = sum([item.amount_difference for item in sr.items]) - sr.db_update() - - for item in sr.items: - item.db_update() + # Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted + # ledger so the document keeps matching the GL entries. Handles serialized, batched and + # non-serialized items uniformly (the document method reads the current bundle for serial/batch + # rows and the pre-reconciliation ledger balance for non-serial rows). + frappe.get_lazy_doc( + "Stock Reconciliation", sle.voucher_no, for_update=True + ).recalculate_difference_amount_from_ledger() def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company