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
This commit is contained in:
Sudharsanan Ashok
2026-08-27 16:23:10 +05:30
committed by GitHub
parent 2ce0bb1009
commit b00062df86
2 changed files with 87 additions and 0 deletions

View File

@@ -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):

View File

@@ -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)