diff --git a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py index e29a53c8981..40a062bf329 100644 --- a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py @@ -8,7 +8,7 @@ from frappe import _ from frappe.core.doctype.prepared_report.prepared_report import create_json_gz_file from frappe.desk.form.load import get_attachments from frappe.model.document import Document -from frappe.utils import add_days, get_date_str, get_link_to_form, nowtime, parse_json +from frappe.utils import add_days, flt, get_date_str, get_link_to_form, nowtime, parse_json from frappe.utils.background_jobs import enqueue from frappe.utils.caching import request_cache @@ -233,18 +233,18 @@ class StockClosing: sl_entries = self.get_sle_entries() closing_stock = frappe._dict() + counted_sles = set() for row in sl_entries: dimensions_keys = self.get_keys(row) for dimension_key in dimensions_keys: for dimension_fields, dimension_values in dimension_key.items(): key = dimension_values + value_difference = self.get_value_difference(row, dimension_fields, key, counted_sles) if key in closing_stock: actual_qty = row.sabb_qty or row.actual_qty closing_stock[key].actual_qty += actual_qty - closing_stock[key].stock_value_difference += ( - row.sabb_stock_value_difference or row.stock_value_difference - ) + closing_stock[key].stock_value_difference += value_difference if not row.actual_qty and row.qty_after_transaction: closing_stock[key].actual_qty = row.qty_after_transaction @@ -254,11 +254,33 @@ class StockClosing: self.update_fifo_queue(fifo_queue, actual_qty, row.posting_date) closing_stock[key].fifo_queue = fifo_queue else: - entries = self.get_initialized_entry(row, dimension_fields) + entries = self.get_initialized_entry(row, dimension_fields, value_difference) closing_stock[key] = entries return closing_stock + def get_value_difference(self, row, dimension_fields, key, counted_sles): + """Value `row` contributes to `key`. + + The Serial and Batch Entry join fans a batched Stock Ledger Entry out into one row per batch, + so batch and inventory dimension keys are built from those per-batch values. The item + + warehouse total instead stays on the Stock Ledger Entry's own `stock_value_difference`, which + is the basis an `is_adjustment_entry` write-off is computed against (see + `get_stock_value_difference`). Summing per-batch values there would subtract that write-off + from a batch total that already nets out and strand a phantom balance value in the closing. + """ + if dimension_fields != ("item_code", "warehouse"): + return flt(row.sabb_stock_value_difference or row.stock_value_difference) + + # Only the first of an entry's fanned out rows carries the entry level value. + if row.name: + if (key, row.name) in counted_sles: + return 0.0 + + counted_sles.add((key, row.name)) + + return flt(row.stock_value_difference) + def update_fifo_queue(self, fifo_queue, actual_qty, posting_date): if actual_qty > 0: fifo_queue.append([actual_qty, get_date_str(posting_date)]) @@ -274,7 +296,7 @@ class StockClosing: remaining_qty += queue[0] fifo_queue.pop(0) - def get_initialized_entry(self, row, dimension_fields): + def get_initialized_entry(self, row, dimension_fields, value_difference): item_details = frappe.get_cached_value( "Item", row.item_code, ["item_group", "item_name", "stock_uom", "has_serial_no"], as_dict=1 ) @@ -283,14 +305,17 @@ class StockClosing: if dimension_fields not in [("item_code", "warehouse"), ("item_code", "warehouse", "batch_no")]: inventory_dimension_key = json.dumps(dimension_fields) - actual_qty = row.sabb_qty or row.actual_qty or row.qty_after_transaction + # A carried forward Stock Closing Balance row has no qty_after_transaction, so an item that + # closed at zero qty (what an is_adjustment_entry write-off leaves behind) would seed the + # entry with None and break the next closing's `actual_qty +=`. + actual_qty = flt(row.sabb_qty or row.actual_qty or row.qty_after_transaction) entry = frappe._dict( { "item_code": row.item_code, "warehouse": row.warehouse, "actual_qty": actual_qty, - "stock_value_difference": row.sabb_stock_value_difference or row.stock_value_difference, + "stock_value_difference": value_difference, "item_group": item_details.item_group, "item_name": item_details.item_name, "stock_uom": item_details.stock_uom, @@ -318,6 +343,7 @@ class StockClosing: sl_entries += self.get_entries( "Stock Closing Balance", fields=[ + "name", "item_code", "warehouse", "posting_date", @@ -341,6 +367,7 @@ class StockClosing: sl_entries += self.get_entries( "Stock Ledger Entry", fields=[ + "name", "item_code", "warehouse", "posting_date", diff --git a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py index 84832d5f006..0b2da814952 100644 --- a/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py @@ -5,7 +5,7 @@ from unittest.mock import patch import frappe from frappe.core.doctype.user_permission.test_user_permission import create_user -from frappe.utils import add_days, today +from frappe.utils import add_days, flt, today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing @@ -47,6 +47,84 @@ class TestStockClosingEntry(ERPNextTestSuite): self.assertEqual(closing.last_closing_balance.name, self.last_closing_entry) self.assertIn(item, {row.item_code for row in entries}) + def test_adjustment_entry_write_off_uses_ledger_basis_for_batched_item(self): + """An is_adjustment_entry writes off stock value stranded on the Stock Ledger Entry, so the + item + warehouse closing total has to be built from sle.stock_value_difference. Building it + from the per-batch values instead subtracts the write-off from a batch total that already + nets out, and the phantom balance is then carried forward as the Stock Balance opening.""" + item = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "_T-CBAL-ADJ-.####", + } + ).name + receipt_date = add_days(today(), -10) + issue_date = add_days(today(), -9) + + receipt = make_stock_entry( + item_code=item, + to_warehouse=WAREHOUSE, + qty=10, + rate=100, + posting_date=receipt_date, + company=COMPANY, + ) + batch_no = frappe.db.get_value( + "Serial and Batch Entry", + { + "parent": frappe.db.get_value( + "Stock Ledger Entry", {"voucher_no": receipt.name}, "serial_and_batch_bundle" + ) + }, + "batch_no", + ) + issue = make_stock_entry( + item_code=item, + from_warehouse=WAREHOUSE, + qty=10, + batch_no=batch_no, + posting_date=issue_date, + company=COMPANY, + ) + + # Strand 100 of value: the batch ledger nets out but the Stock Ledger Entries no longer do. + outgoing_sle = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue.name}, "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, + ) + + # The write-off a Stock Reconciliation emits for it: no quantity, no bundle, value only. + adjustment_entry = frappe.get_doc( + { + "doctype": "Stock Ledger Entry", + "item_code": item, + "warehouse": WAREHOUSE, + "company": COMPANY, + "posting_date": add_days(today(), -8), + "posting_time": "10:00:00", + "voucher_type": "Stock Reconciliation", + "voucher_no": "_T-CBAL-ADJ-RECO", + "actual_qty": 0, + "qty_after_transaction": 0, + "stock_value": 0, + "stock_value_difference": -100, + "is_adjustment_entry": 1, + } + ) + adjustment_entry.flags.ignore_links = True + adjustment_entry.submit() + + entries = StockClosing(COMPANY, receipt_date, today()).get_stock_closing_entries() + + self.assertEqual(flt(entries[(item, WAREHOUSE)].stock_value_difference), 0.0) + self.assertEqual(flt(entries[(item, WAREHOUSE, batch_no)].stock_value_difference), 0.0) + def make_stock_closing_entry(self, from_date, to_date): entry = frappe.get_doc( doctype="Stock Closing Entry", diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index c89c9e0138b..964444665e5 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -485,8 +485,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 @@ -523,11 +521,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") @@ -823,13 +817,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 diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index ec4fdc0cd35..d87240d01eb 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -2089,6 +2089,136 @@ class TestStockReconciliation(ERPNextTestSuite, 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) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 7c77c81e58f..fed14a16e0d 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -172,6 +172,7 @@ class StockBalanceReport: sle.serial_and_batch_bundle, sle.has_serial_no, sle.voucher_detail_no, + sle.is_adjustment_entry, item_table.item_group, item_table.stock_uom, item_table.item_name, @@ -346,8 +347,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)