From b00062df8658951d23fdaee9c692e9a93d953a70 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:23:10 +0530 Subject: [PATCH] fix(stock): reset bin when a repost finds no stock ledger entries (#58434) * fix(stock): reset bin when a repost finds no stock ledger entries `build()` wipes `prev_sle_dict` in `initialize_reposting()` before `update_bin()` runs, so a repost over an item and warehouse whose ledger is empty writes no bin at all. `actual_qty`, `stock_value` and `valuation_rate` keep their last values, bin totals drift from the stock balance, and reposting again cannot heal it. Reset those bins as a terminal step in `update_bin()`, guarded by a re-check that no live SLE exists so ordinary valuation is untouched. Cancel is already correct on this branch, since `initialize_previous_data()` seeds zeros when there is no previous SLE and the sle_id path never calls `initialize_reposting()`. That seeding stays; this is a repost-path fix only. develop drops it in #58362 because the `cancelled` guard makes it dead code there. * test(stock): cover bin reset when the stock ledger is empty --- erpnext/stock/doctype/bin/test_bin.py | 62 +++++++++++++++++++++++++++ erpnext/stock/stock_ledger.py | 25 +++++++++++ 2 files changed, 87 insertions(+) diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py index e4f5565cd75..36f06572cf5 100644 --- a/erpnext/stock/doctype/bin/test_bin.py +++ b/erpnext/stock/doctype/bin/test_bin.py @@ -28,6 +28,68 @@ class TestBin(FrappeTestCase): frappe.db.rollback() + def test_repost_resets_bin_without_sle(self): + """A repost must zero the bin when the ledger is empty, e.g. after entries were deleted.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + from erpnext.stock.stock_ledger import update_entries_after + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + + # deleting a transaction with `delete_linked_ledger_entries` on drops its entries outright + frappe.db.delete("Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse}) + + update_entries_after( + { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": "1900-01-01", + "posting_time": "00:01", + } + ) + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + + def test_cancelling_last_entry_resets_bin(self): + """Cancelling the only voucher must clear stock value, not just quantity.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + + se.cancel() + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + + def test_deleting_last_voucher_resets_bin(self): + """Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + delete_entries = frappe.get_single_value("Accounts Settings", "delete_linked_ledger_entries") + frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1) + + try: + se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + se.cancel() + frappe.delete_doc("Stock Entry", se.name, force=1) + finally: + frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", delete_entries) + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + def test_index_exists(self): indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1) if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7a9bc900307..e57da3cd71b 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1750,6 +1750,31 @@ class update_entries_after: frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True) + self.reset_bin_without_stock_ledger_entries() + + def reset_bin_without_stock_ledger_entries(self): + """Reset the bin when its ledger has no entries left, a repost never covers that case.""" + item_code, warehouse = self.args.get("item_code"), self.args.get("warehouse") + if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict: + return + + if frappe.db.count( + "Stock Ledger Entry", + {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, + ): + return + + bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) + if not bin_name: + return + + frappe.db.set_value( + "Bin", + bin_name, + {"actual_qty": 0.0, "stock_value": 0.0, "valuation_rate": 0.0}, + update_modified=True, + ) + def get_sle_against_current_voucher(kwargs): kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time)