mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-11 05:31:48 +00:00
Merge pull request #57017 from frappe/mergify/bp/version-16-hotfix/pr-56693
fix(stock): correct stock ageing value for moving average and lifo items (backport #56693)
This commit is contained in:
@@ -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,45 @@ 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, self.filters.get("company")
|
||||
)
|
||||
|
||||
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, company)
|
||||
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 +636,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 +650,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 +727,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(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
|
||||
@@ -63,6 +65,131 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
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 = [
|
||||
|
||||
Reference in New Issue
Block a user