From 39d6f2a236873ba0488af07ed807452c2bb50931 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:45:32 +0530 Subject: [PATCH 1/2] fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998) * fix(stock): use stored posting_datetime for repost boundary get_stock_ledger_entries re-derived posting_datetime from posting_date and posting_time on every call, discarding the stored value its callers pass in. when a row's stored posting_datetime differs from that pair, the replay window is built from the wrong instant: the row falls outside the range filter and is never recomputed, while get_previous_sle still selects it as the opening balance and reuses its stale qty_after_transaction. every later entry inherits the error, leaving bin qty adrift from the sum of its ledger. derive the boundary only when the caller has not supplied one. * fix(stock): match current voucher sle on stored posting_datetime get_sle_against_current_voucher selected rows with an equality check against a posting_datetime re-derived from posting_date and posting_time. a row whose stored posting_datetime differs from that pair matches nothing, so reposting the voucher silently processes zero entries and the row can never be corrected through its own voucher. read the timestamp from the stored row when the sle is known, and derive it only as a fallback. * test(stock): cover repost with diverged posting_datetime add a repack scenario whose incoming entry stores a posting_datetime one microsecond before its own posting_time. asserts the voucher lookup still finds that entry, and that reposting replays it instead of reusing its stale qty_after_transaction, which otherwise left bin qty at 115 against 615 of recorded movements. (cherry picked from commit 6cee9c330c92471f9eedc21bd3623a508d8f33c9) # Conflicts: # erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py --- .../test_stock_ledger_entry.py | 147 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 29 +++- 2 files changed, 168 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 801c70920cf..ce08ac3c299 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1594,6 +1594,153 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): item_code=item_code, source=warehouse, qty=470.84, rate=100, posting_date=add_days(today(), -1) ) +<<<<<<< HEAD +======= + def test_zero_qty_row_is_skipped(self): + """A zero-qty non-reconciliation row must be skipped entirely: no SLE, + no crash, no reprocessing of the previous row's entry.""" + from erpnext.stock.stock_ledger import make_sl_entries + + item = make_item(properties={"is_stock_item": 1}) + voucher_no = f"zero-qty-{uuid4()}" + + make_sl_entries( + [ + frappe._dict( + item_code=item.name, + warehouse="_Test Warehouse - _TC", + company="_Test Company", + posting_date=today(), + posting_time="12:00:00", + voucher_type="Stock Entry", + voucher_no=voucher_no, + actual_qty=0, + stock_uom=item.stock_uom, + ) + ] + ) + + self.assertFalse(frappe.db.exists("Stock Ledger Entry", {"voucher_no": voucher_no})) + + def test_repost_with_diverged_posting_datetime(self): + """Reposting must use the stored posting_datetime, not one rebuilt from posting_date and posting_time.""" + from erpnext.stock.stock_ledger import get_sle_against_current_voucher + + raw = make_item().name + fg = make_item().name + warehouse = "_Test Warehouse - _TC" + clash_time = "09:04:25.784069" + + make_stock_entry( + item_code=fg, + to_warehouse=warehouse, + qty=123, + rate=2, + posting_date="2026-08-20", + posting_time="10:00:00", + ) + make_stock_entry( + item_code=fg, + to_warehouse=warehouse, + qty=500, + rate=2, + posting_date="2026-08-22", + posting_time="10:00:00", + ) + make_stock_entry( + item_code=raw, + to_warehouse=warehouse, + qty=500, + rate=2, + posting_date="2026-08-21", + posting_time="10:00:00", + ) + + # issue posted first, then a repack producing the same item at the same posting time + make_stock_entry( + item_code=fg, + from_warehouse=warehouse, + qty=500, + posting_date="2026-08-24", + posting_time=clash_time, + ) + + repack = make_stock_entry( + item_code=raw, source=warehouse, qty=500, rate=2, purpose="Repack", do_not_save=True + ) + repack.set_posting_time = 1 + repack.posting_date = "2026-08-24" + repack.posting_time = clash_time + repack.append( + "items", {"item_code": fg, "t_warehouse": warehouse, "qty": 500, "conversion_factor": 1} + ) + repack.save() + repack.submit() + + make_stock_entry( + item_code=fg, from_warehouse=warehouse, qty=8, posting_date="2026-08-25", posting_time="10:00:00" + ) + + # mimic a repair that backdates the stored value and leaves posting_time untouched + incoming = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": repack.name, "item_code": fg, "actual_qty": (">", 0)}, + ["name", "posting_date", "posting_time", "creation"], + as_dict=True, + ) + frappe.db.set_value( + "Stock Ledger Entry", + incoming.name, + "posting_datetime", + "2026-08-24 09:04:25.784068", + update_modified=False, + ) + + # the row must stay reachable through its own voucher + found = get_sle_against_current_voucher( + frappe._dict( + { + "item_code": fg, + "warehouse": warehouse, + "posting_date": incoming.posting_date, + "posting_time": incoming.posting_time, + "creation": incoming.creation, + "name": incoming.name, + } + ) + ) + self.assertEqual([d.name for d in found], [incoming.name]) + + riv = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Item and Warehouse", + "item_code": raw, + "warehouse": warehouse, + "posting_date": "2026-08-21", + "posting_time": "00:00:00", + "company": "_Test Company", + "allow_negative_stock": 1, + } + ).insert() + riv.submit() + + # the backdated row is replayed instead of contributing a stale balance + sle = frappe.qb.DocType("Stock Ledger Entry") + balances = ( + frappe.qb.from_(sle) + .select(sle.qty_after_transaction) + .where((sle.item_code == fg) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0)) + .orderby(sle.posting_datetime) + .orderby(sle.creation) + ).run(pluck=True) + + self.assertEqual([flt(b) for b in balances], [123.0, 623.0, 1123.0, 623.0, 615.0]) + self.assertEqual( + flt(frappe.db.get_value("Bin", {"item_code": fg, "warehouse": warehouse}, "actual_qty")), 615.0 + ) + +>>>>>>> 6cee9c3 (fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998)) def create_repack_entry(**args): args = frappe._dict(args) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 8ba82f31e7b..fab62fcb703 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1825,7 +1825,15 @@ class update_entries_after: def get_sle_against_current_voucher(kwargs): - kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) + # Match on the row's stored posting_datetime. Re-deriving it from posting_date and posting_time + # makes rows whose stored value differs unmatchable, so the voucher gets reposted against nothing. + if kwargs.get("name") and not kwargs.get("posting_datetime"): + kwargs["posting_datetime"] = frappe.db.get_value( + "Stock Ledger Entry", kwargs.get("name"), "posting_datetime" + ) + + if not kwargs.get("posting_datetime"): + kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) doctype = frappe.qb.DocType("Stock Ledger Entry") query = ( @@ -1958,14 +1966,19 @@ def get_stock_ledger_entries( frappe.db.escape(f"%\n{serial_no}\n%"), ) - if not previous_sle.get("posting_date"): - previous_sle["posting_datetime"] = "1900-01-01 00:00:00" - else: - posting_time = previous_sle.get("posting_time") - if not posting_time: - posting_time = "00:00:00" + if not previous_sle.get("posting_datetime"): + # Derive only when the caller has not supplied the stored posting_datetime. Re-deriving it + # would shift the boundary for rows whose stored value differs from posting_date + posting_time. + if not previous_sle.get("posting_date"): + previous_sle["posting_datetime"] = "1900-01-01 00:00:00" + else: + posting_time = previous_sle.get("posting_time") + if not posting_time: + posting_time = "00:00:00" - previous_sle["posting_datetime"] = get_combine_datetime(previous_sle["posting_date"], posting_time) + previous_sle["posting_datetime"] = get_combine_datetime( + previous_sle["posting_date"], posting_time + ) if operator in (">", "<=") and previous_sle.get("name"): conditions += " and name!=%(name)s" From 6fab4e02038302a9c9593a92928e32d7ae887099 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 17 Sep 2026 18:12:54 +0530 Subject: [PATCH 2/2] fix: resolve conflicts --- .../test_stock_ledger_entry.py | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index ce08ac3c299..aa1f9f37683 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1594,34 +1594,6 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): item_code=item_code, source=warehouse, qty=470.84, rate=100, posting_date=add_days(today(), -1) ) -<<<<<<< HEAD -======= - def test_zero_qty_row_is_skipped(self): - """A zero-qty non-reconciliation row must be skipped entirely: no SLE, - no crash, no reprocessing of the previous row's entry.""" - from erpnext.stock.stock_ledger import make_sl_entries - - item = make_item(properties={"is_stock_item": 1}) - voucher_no = f"zero-qty-{uuid4()}" - - make_sl_entries( - [ - frappe._dict( - item_code=item.name, - warehouse="_Test Warehouse - _TC", - company="_Test Company", - posting_date=today(), - posting_time="12:00:00", - voucher_type="Stock Entry", - voucher_no=voucher_no, - actual_qty=0, - stock_uom=item.stock_uom, - ) - ] - ) - - self.assertFalse(frappe.db.exists("Stock Ledger Entry", {"voucher_no": voucher_no})) - def test_repost_with_diverged_posting_datetime(self): """Reposting must use the stored posting_datetime, not one rebuilt from posting_date and posting_time.""" from erpnext.stock.stock_ledger import get_sle_against_current_voucher @@ -1740,7 +1712,6 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): flt(frappe.db.get_value("Bin", {"item_code": fg, "warehouse": warehouse}, "actual_qty")), 615.0 ) ->>>>>>> 6cee9c3 (fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998)) def create_repack_entry(**args): args = frappe._dict(args)