From 3d4198494b30ca2fdfb5b9b36ddc76ad21d62968 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 12:32:51 +0530 Subject: [PATCH] fix: seed cancelled voucher replay from before its posting datetime On cancel, update_entries_after replays every live SLE at the voucher's posting datetime, but get_previous_sle_of_current_voucher seeded the replay with the reversal SLE's creation, which resolves to the bucket's own closing row. The bucket's net qty got double-counted into every same-datetime row, so later submissions passed negative stock validation against inflated balances, and the queued repost then rewrote correct values with allow_negative_stock forced on, silently creating negative stock. Backports the missing guard from eca71dce54f. (cherry picked from commit 1ff297e9da87ec633ea5b621eac215be5b008b03) --- .../doctype/stock_entry/test_stock_entry.py | 44 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 79a353ace30..b344b772cbb 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2751,6 +2751,50 @@ class TestStockEntry(FrappeTestCase): self.assertEqual(se.process_loss_qty, 50) + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_cancel_seeds_replay_from_before_posting_datetime_bucket(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + item = make_item().name + warehouse = create_warehouse("Cancel Replay Warehouse", company="_Test Company") + + def stock_entry(qty, posting_date, **kwargs): + return make_stock_entry( + item_code=item, + qty=qty, + company="_Test Company", + posting_date=posting_date, + posting_time="10:00:00", + **kwargs, + ) + + stock_entry(25, add_days(today(), -3), to_warehouse=warehouse, rate=10) + + bucket_date = add_days(today(), -2) + stock_entry(20, bucket_date, from_warehouse=warehouse) + receipt = stock_entry(75, bucket_date, to_warehouse=warehouse, rate=10) + duplicate_issue = stock_entry(20, bucket_date, from_warehouse=warehouse) + + frappe.flags.dont_execute_stock_reposts = True + self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts") + + duplicate_issue.reload() + duplicate_issue.cancel() + + self.assertEqual( + flt( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": receipt.name, "is_cancelled": 0}, + "qty_after_transaction", + ) + ), + 80, + ) + + overdraw = stock_entry(100, add_days(today(), -1), from_warehouse=warehouse, do_not_submit=True) + self.assertRaises(NegativeStockError, overdraw.submit) + def make_serialized_item(**args): args = frappe._dict(args) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ba578f69814..ddaaf702c86 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1762,7 +1762,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc voucher_no = args.get("voucher_no") voucher_condition = f"and voucher_no != '{voucher_no}'" - elif args.get("creation") and args.get("sle_id"): + elif args.get("creation") and args.get("sle_id") and not args.get("cancelled"): creation = args.get("creation") operator = "<=" voucher_condition = f"and creation < '{creation}'"