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 9cb6610b9e)

# Conflicts:
#	erpnext/stock/report/stock_ageing/stock_ageing.py

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
This commit is contained in:
mergify[bot]
2026-07-10 15:44:39 +05:30
committed by GitHub
parent 755e151764
commit 9d654b7890
2 changed files with 203 additions and 8 deletions

View File

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

View File

@@ -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 = [