From 01374db8dab4979309b4d1fa446c4af0a008ddfc Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 30 Jun 2026 14:27:46 +0530 Subject: [PATCH] fix: update qty in future SLEs when cancelling documents (#56638) --- .../test_stock_ledger_entry.py | 41 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 5 ++- 2 files changed, 45 insertions(+), 1 deletion(-) 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 80d4a3d9efd..0ff773cd6e7 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 @@ -1303,6 +1303,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35) self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10) + def test_cancel_first_of_two_same_timestamp_entries(self): + # Two receipts of the same item+warehouse at the exact same posting timestamp: balances 10 -> 20. + # Cancelling the first must leave the second standing alone on a zero base (qty 10), not + # double-decremented. The same-timestamp sibling is corrected by the cancellation reprocessing, + # so update_qty_in_future_sle must not shift it again. + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", # identical timestamp, later creation + ) + + def qty_after(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + "qty_after_transaction", + ) + + self.assertEqual(qty_after(receipt1), 10) + self.assertEqual(qty_after(receipt2), 20) + + receipt1.cancel() + + # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error). + self.assertEqual(qty_after(receipt2), 10) + def test_get_next_stock_reco_respects_creation_order(self): # A stock reco sharing the exact posting timestamp of the current entry must only count as the # "next" reco when it was created after that entry. A reco created before it actually precedes diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index deb2ee29415..1da3940204c 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2048,8 +2048,11 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): # current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a # later creation. Comparing posting_datetime alone would skip same-timestamp entries created after # this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second). + # On cancellation `args` is a freshly inserted reversal entry, so its `creation` is the cancel time + # (not the original entry's position) and same-timestamp siblings are already recomputed by the + # cancelled path in update_entries_after; applying the tiebreaker here would double-shift them. future_condition = sle.posting_datetime > posting_datetime - if args.get("creation"): + if args.get("creation") and not args.get("is_cancelled"): future_condition = future_condition | ( (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation")) )