From 9d654b78901c26b98a256bdff9ed211d2755ddfc Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:44:39 +0530 Subject: [PATCH] fix(stock): correct stock ageing value for moving average and lifo items (backport #56693) (#57016) fix(stock): correct stock ageing value for moving average and lifo items (#56693) * fix(stock): recompute moving average item slots * test(stock): add test to validate the stock value of moving average items * fix(stock): support lifo valuation in stock ageing report lifo items were aged as fifo (oldest consumed first), so the report kept the newest lots on hand and reported the wrong stock value and average age. prefetch each item's valuation method (it can't be resolved mid-stream without breaking the unbuffered cursor) and consume from the tail for lifo items. also reuse that shared lookup in the moving average revaluation pass. scoped to plain items; batch, serial and same-voucher repack legs stay on fifo. * test(stock): add test for lifo consumption in stock ageing report (cherry picked from commit 9cb6610b9e380a296df7802c857462bc59c4ed07) # Conflicts: # erpnext/stock/report/stock_ageing/stock_ageing.py Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> --- .../stock/report/stock_ageing/stock_ageing.py | 84 ++++++++++-- .../report/stock_ageing/test_stock_ageing.py | 127 ++++++++++++++++++ 2 files changed, 203 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index b9ec3e7204f..e0106f7ddb0 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -287,6 +287,7 @@ class FIFOSlots: self.serial_no_details = {} self.batch_no_details = {} self.batchwise_valuation_by_batch = {} + self.valuation_method_by_item = {} self.filters = filters self.sle = sle @@ -307,9 +308,10 @@ class FIFOSlots: self.prepare_stock_reco_voucher_wise_count() if stock_ledger_entries is None: - # nested queries invalidate the streaming cursor below, - # so batchwise valuation flags must be resolved beforehand + # streaming path: nested queries invalidate the streaming cursor below, + # so batchwise valuation flags and item valuation methods must be resolved beforehand self._prefetch_batchwise_valuations() + self._prefetch_valuation_methods() with frappe.db.unbuffered_cursor(): if stock_ledger_entries is None: @@ -321,12 +323,28 @@ class FIFOSlots: # Note that stock_ledger_entries is an iterator, you can not reuse it like a list del stock_ledger_entries + self._recompute_moving_average_slots() + if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) self.item_details = self._aggregate_details_by_item(self.item_details) return self.item_details + def _recompute_moving_average_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"): + continue + + details = item_dict["details"] + if self._get_item_valuation_method(details.name) != "Moving Average": + continue + + rate = flt(details.valuation_rate) + for slot in item_dict["fifo_queue"]: + if is_qty_slot(slot): + slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: if stock_ledger_entries is not None: return frappe._dict({}), frappe._dict({}) @@ -347,7 +365,10 @@ class FIFOSlots: if row.actual_qty > 0: self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) else: - self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + from_end = self._get_item_valuation_method(row.name) == "LIFO" + self._compute_outgoing_stock( + row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end + ) self._update_balances(row, key) self._trim_serial_fifo_queue(row, key, fifo_queue) @@ -460,6 +481,43 @@ class FIFOSlots: for batch_no, use_batchwise_valuation in query.run(): self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation + def _get_item_valuation_method(self, item_code: str) -> str: + from erpnext.stock.utils import get_valuation_method + + if item_code not in self.valuation_method_by_item: + # only reachable when stock ledger entries are passed in directly; + # the streaming path prefetches all methods before iteration + self.valuation_method_by_item[item_code] = get_valuation_method(item_code) + + return self.valuation_method_by_item[item_code] + + def _prefetch_valuation_methods(self) -> None: + from erpnext.stock.utils import get_valuation_method + + company = self.filters.get("company") + sle = frappe.qb.DocType("Stock Ledger Entry") + item = frappe.qb.DocType("Item") + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + + query = ( + frappe.qb.from_(sle) + .inner_join(item) + .on(sle.item_code == item.name) + .select(item.name, item.valuation_method) + .distinct() + .where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1)) + ) + query = self._apply_filter(query, sle, "item_code") + + # items with no item-level method share the company/settings default; resolve it once + default_method = None + for item_code, valuation_method in query.run(): + if not valuation_method: + if default_method is None: + default_method = get_valuation_method(item_code) + valuation_method = default_method + self.valuation_method_by_item[item_code] = valuation_method + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." @@ -576,7 +634,13 @@ class FIFOSlots: fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference) def _compute_outgoing_stock( - self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + self, + row: dict, + fifo_queue: list, + transfer_key: tuple, + serial_nos: list, + batch_nos: list, + from_end: bool = False, ): "Update FIFO Queue on outward stock." if serial_nos: @@ -584,7 +648,7 @@ class FIFOSlots: elif batch_nos: self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos) else: - self._consume_fifo_slots(row, fifo_queue, transfer_key) + self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end) def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None: fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos] @@ -661,19 +725,23 @@ class FIFOSlots: ) self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference]) - def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None: + def _consume_fifo_slots( + self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False + ) -> None: + # LIFO consumes the most recent inward first, so pop from the tail instead of the head. + index = -1 if from_end else 0 qty_to_pop = abs(row.actual_qty) stock_value = abs(row.stock_value_difference) while qty_to_pop: - slot = fifo_queue[0] if fifo_queue else [0, None, 0] + slot = fifo_queue[index] if fifo_queue else [0, None, 0] slot_qty = flt(slot[FIFO_QTY_INDEX]) slot_value = flt(slot[FIFO_VALUE_INDEX]) if 0 < slot_qty <= qty_to_pop: qty_to_pop -= slot_qty stock_value -= slot_value - self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(index)) elif not fifo_queue: fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) self.transferred_item_details[transfer_key].append( diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 54f9ef9241e..2f74e1e3327 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe from frappe.tests.utils import FrappeTestCase @@ -67,6 +69,131 @@ class TestStockAgeing(FrappeTestCase): data = format_report_data(self.filters, slots, self.filters["to_date"]) self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30 + def test_moving_average_value_ties_to_stock_balance(self): + """For Moving Average items the queue value is re-derived as qty * rate so the + report's stock value ties to Stock Balance, instead of stranding a residual + from FIFO-by-qty consumption vs blended outgoing value.""" + sle = [ + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=10, + stock_value_difference=1000, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=20, + stock_value_difference=2000, + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-10), + qty_after_transaction=10, + stock_value_difference=(-1500), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-5), + qty_after_transaction=5, + stock_value_difference=(-750), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-04", + voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["MA Item"]["fifo_queue"] + total_value = sum(slot[2] for slot in queue) + + # Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150 + self.assertEqual(total_value, 750.0) + + def test_lifo_consumes_newest_first(self): + """LIFO items consume the most recent inward first, so the oldest lot stays on + hand. The remaining queue, stock value and average age must reflect the older + stock, unlike the default FIFO which retains the newest lots.""" + sle = [ + frappe._dict( + name="LIFO Item", + actual_qty=30, + qty_after_transaction=30, + stock_value_difference=30, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=20, + qty_after_transaction=50, + stock_value_difference=20, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=(-10), + qty_after_transaction=40, + stock_value_difference=(-10), + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["LIFO Item"]["fifo_queue"] + + # newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10 + self.assertEqual(queue[0][0], 30.0) + self.assertEqual(queue[-1][0], 10.0) + self.assertEqual(sum(slot[0] for slot in queue), 40.0) + self.assertEqual(sum(slot[2] for slot in queue), 40.0) + + # average age skews older than the FIFO result (8.5) because the old lot is retained + self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75) + def test_insufficient_balance(self): "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" sle = [