fix(stock): reset bin when no stock ledger entries remain (#58362)

* fix(stock): reset bin when no stock ledger entries remain

update_bin() only writes bins reachable through prev_sle_dict, and that
dict is empty once the last live sle for an item and warehouse is
cancelled or deleted. actual_qty is still recomputed, but stock_value
and valuation_rate stay stale and a repost cannot heal them, so bin
totals drift permanently from the stock balance.

zero those bins after the normal update, guarded by a re-check that no
live sle exists. also drop the prev_sle_dict seeding added earlier in
initialize_previous_data, which never took effect because
initialize_reposting() discards the dict before update_bin() reads it.

* test(stock): cover bin reset when ledger is empty

three cases that all leave an item and warehouse with no live sle:
cancelling the only voucher, deleting it with delete_linked_ledger_entries
on, and reposting over an already emptied ledger. each asserts actual_qty,
valuation_rate and stock_value are all zero.
This commit is contained in:
Sudharsanan Ashok
2026-08-25 11:39:11 +05:30
committed by GitHub
parent 5fa68dd068
commit 6fbcfade6c
2 changed files with 86 additions and 10 deletions

View File

@@ -57,6 +57,68 @@ class TestBin(ERPNextTestSuite):
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
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):
# has_index is db-agnostic; raw "SHOW INDEX" is MySQL-only and errors on Postgres
if not frappe.db.has_index("tabBin", "unique_item_warehouse"):

View File

@@ -714,16 +714,6 @@ class update_entries_after:
previous_sle = get_previous_sle_of_current_voucher(args)
if previous_sle:
self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = previous_sle
else:
self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = frappe._dict(
{
"qty_after_transaction": 0.0,
"valuation_rate": 0.0,
"stock_value": 0.0,
"prev_stock_value": 0.0,
"stock_queue": [],
}
)
warehouse_dict.previous_sle = previous_sle
@@ -1951,6 +1941,30 @@ 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, prev_sle_dict 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.exists(
"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)