fix: write off stranded stock value only when the warehouse is empty (#59219)

This commit is contained in:
rohitwaghchaure
2026-09-21 13:30:14 +05:30
committed by GitHub
parent e4ea6e0a04
commit 7a276344a3
3 changed files with 166 additions and 12 deletions

View File

@@ -478,8 +478,6 @@ class StockReconciliation(StockController):
frappe.db.set_value("Serial and Batch Entry", batch.name, update_values)
def remove_items_with_no_change(self):
from erpnext.stock.stock_ledger import get_stock_value_difference
"""Remove items if qty or rate is not changed"""
self.difference_amount = 0.0
@@ -516,11 +514,7 @@ class StockReconciliation(StockController):
)
if not item_dict.get("qty") and not item.qty and not item.valuation_rate and not item.current_qty:
difference_amount = get_stock_value_difference(
item.item_code, item.warehouse, self.posting_date, self.posting_time, self.name
)
if abs(difference_amount) > 0:
if abs(self.get_stranded_stock_value(item)) > 0:
return True
rate_precision = item.precision("valuation_rate")
@@ -806,13 +800,36 @@ class StockReconciliation(StockController):
)
)
def make_adjustment_entry(self, row, sl_entries):
from erpnext.stock.stock_ledger import get_stock_value_difference
def get_stranded_stock_value(self, row) -> float:
"""Stock value the ledger still carries for an item-warehouse that has no quantity on hand.
difference_amount = get_stock_value_difference(
This is what an adjustment entry writes off. The write-off is measured at item-warehouse
level, so it is only stranded value when nothing is left in that warehouse. ``current_qty``
alone does not say so: on a batch row it is the qty of the selected batch, so a row pointing
at an already empty batch while other batches of the same item still hold stock would
otherwise write off the valuation of the stock that remains.
"""
from erpnext.stock.stock_ledger import get_previous_sle, get_stock_value_difference
previous_sle = get_previous_sle(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
if flt(previous_sle.get("qty_after_transaction")):
return 0.0
return get_stock_value_difference(
row.item_code, row.warehouse, self.posting_date, self.posting_time, self.name
)
def make_adjustment_entry(self, row, sl_entries):
difference_amount = self.get_stranded_stock_value(row)
if not difference_amount:
return

View File

@@ -2085,6 +2085,136 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertEqual(frappe.get_value("Serial No", serial_no, "status"), "Delivered")
def _make_batch_item(self, item_code, series):
return self.make_item(
item_code,
frappe._dict(
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": series,
}
),
).name
def test_zeroing_a_batch_does_not_make_an_adjustment_entry(self):
"""Emptying a batch that holds stock is an ordinary outward entry, not a value write-off."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Zero Batch Qty", "TSRZBQ-.#####")
warehouse = "_Test Warehouse - _TC"
se = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = batch_no
sr.items[0].use_serial_batch_fields = 1
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0},
fields=["actual_qty", "qty_after_transaction", "stock_value", "is_adjustment_entry"],
)
self.assertEqual(len(sles), 1)
self.assertEqual(sles[0].is_adjustment_entry, 0)
self.assertEqual(sles[0].actual_qty, -5)
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(sles[0].stock_value, 0)
def test_no_adjustment_entry_while_other_batches_hold_stock(self):
"""An adjustment entry writes the whole item + warehouse value off, so it must not be
emitted for a row that only points at an empty batch: the value it would strand belongs
to the batches that still hold stock."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Empty Batch Row", "TSREBR-.#####")
warehouse = "_Test Warehouse - _TC"
emptied = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
emptied_batch = get_batch_from_bundle(emptied.items[0].serial_and_batch_bundle)
make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
make_stock_entry(
item_code=item_code,
source=warehouse,
qty=5,
batch_no=emptied_batch,
use_serial_batch_fields=1,
)
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (5.0, 50.0))
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = emptied_batch
sr.items[0].use_serial_batch_fields = 1
sr.items[0].allow_zero_valuation_rate = 1
sr.items[0].current_qty = 0
sr.items[0].current_valuation_rate = 0
sr.save()
# nothing is stranded while stock is on hand, so there is nothing for the row to post
self.assertRaises(frappe.ValidationError, sr.submit)
self.assertFalse(
frappe.db.exists("Stock Ledger Entry", {"voucher_no": sr.name, "is_adjustment_entry": 1})
)
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (5.0, 50.0))
def test_adjustment_entry_writes_off_stranded_stock_value(self):
"""The write-off itself still happens once the item + warehouse has no quantity left."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Stranded Value", "TSRSV-.#####")
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
issue = make_stock_entry(
item_code=item_code, source=warehouse, qty=10, batch_no=batch_no, use_serial_batch_fields=1
)
# strand 100 of value on the ledger: qty nets out, stock_value_difference does not
outgoing_sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": issue.name, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry",
outgoing_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", outgoing_sle, "stock_value_difference")) + 100,
update_modified=False,
)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = batch_no
sr.items[0].use_serial_batch_fields = 1
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0},
fields=["actual_qty", "qty_after_transaction", "stock_value_difference", "is_adjustment_entry"],
)
self.assertEqual(len(sles), 1)
self.assertEqual(sles[0].is_adjustment_entry, 1)
self.assertEqual(sles[0].actual_qty, 0)
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(flt(sles[0].stock_value_difference), -100.0)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -259,8 +259,14 @@ class StockBalanceReport:
for field in self.inventory_dimensions:
qty_dict[field] = entry.get(field)
if entry.voucher_type == "Stock Reconciliation" and (
not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle
# An adjustment entry only writes off stock value that is stranded on an item with no
# quantity left; it moves nothing. Its qty_after_transaction and stock_value are therefore
# not a statement of the balance the way a real reconciliation's are, and the write-off it
# carries lives solely in stock_value_difference. Treat it as the plain delta it is.
if (
entry.voucher_type == "Stock Reconciliation"
and not entry.is_adjustment_entry
and (not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle)
):
if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count:
qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0)
@@ -385,6 +391,7 @@ class StockBalanceReport:
sle.serial_no,
sle.serial_and_batch_bundle,
sle.voucher_detail_no,
sle.is_adjustment_entry,
item_table.has_serial_no,
item_table.has_batch_no,
item_table.item_group,