mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-24 13:57:05 +00:00
fix: write off stranded stock value only when the warehouse is empty (#59219)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user