mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-29 14:48:26 +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.serial_no_details = {}
|
||||||
self.batch_no_details = {}
|
self.batch_no_details = {}
|
||||||
self.batchwise_valuation_by_batch = {}
|
self.batchwise_valuation_by_batch = {}
|
||||||
|
self.valuation_method_by_item = {}
|
||||||
self.filters = filters
|
self.filters = filters
|
||||||
self.sle = sle
|
self.sle = sle
|
||||||
|
|
||||||
@@ -307,9 +308,10 @@ class FIFOSlots:
|
|||||||
self.prepare_stock_reco_voucher_wise_count()
|
self.prepare_stock_reco_voucher_wise_count()
|
||||||
|
|
||||||
if stock_ledger_entries is None:
|
if stock_ledger_entries is None:
|
||||||
# nested queries invalidate the streaming cursor below,
|
# streaming path: nested queries invalidate the streaming cursor below,
|
||||||
# so batchwise valuation flags must be resolved beforehand
|
# so batchwise valuation flags and item valuation methods must be resolved beforehand
|
||||||
self._prefetch_batchwise_valuations()
|
self._prefetch_batchwise_valuations()
|
||||||
|
self._prefetch_valuation_methods()
|
||||||
|
|
||||||
with frappe.db.unbuffered_cursor():
|
with frappe.db.unbuffered_cursor():
|
||||||
if stock_ledger_entries is None:
|
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
|
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
|
||||||
del stock_ledger_entries
|
del stock_ledger_entries
|
||||||
|
|
||||||
|
self._recompute_moving_average_slots()
|
||||||
|
|
||||||
if not self.filters.get("show_warehouse_wise_stock"):
|
if not self.filters.get("show_warehouse_wise_stock"):
|
||||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||||
self.item_details = self._aggregate_details_by_item(self.item_details)
|
self.item_details = self._aggregate_details_by_item(self.item_details)
|
||||||
|
|
||||||
return 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]:
|
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||||
if stock_ledger_entries is not None:
|
if stock_ledger_entries is not None:
|
||||||
return frappe._dict({}), frappe._dict({})
|
return frappe._dict({}), frappe._dict({})
|
||||||
@@ -347,7 +365,10 @@ class FIFOSlots:
|
|||||||
if row.actual_qty > 0:
|
if row.actual_qty > 0:
|
||||||
self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
||||||
else:
|
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._update_balances(row, key)
|
||||||
self._trim_serial_fifo_queue(row, key, fifo_queue)
|
self._trim_serial_fifo_queue(row, key, fifo_queue)
|
||||||
@@ -460,6 +481,45 @@ class FIFOSlots:
|
|||||||
for batch_no, use_batchwise_valuation in query.run():
|
for batch_no, use_batchwise_valuation in query.run():
|
||||||
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
|
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:
|
def _init_key_stores(self, row: dict) -> tuple:
|
||||||
"Initialise keys and FIFO Queue."
|
"Initialise keys and FIFO Queue."
|
||||||
|
|
||||||
@@ -576,7 +636,13 @@ class FIFOSlots:
|
|||||||
fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference)
|
fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference)
|
||||||
|
|
||||||
def _compute_outgoing_stock(
|
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."
|
"Update FIFO Queue on outward stock."
|
||||||
if serial_nos:
|
if serial_nos:
|
||||||
@@ -584,7 +650,7 @@ class FIFOSlots:
|
|||||||
elif batch_nos:
|
elif batch_nos:
|
||||||
self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos)
|
self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos)
|
||||||
else:
|
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:
|
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]
|
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])
|
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)
|
qty_to_pop = abs(row.actual_qty)
|
||||||
stock_value = abs(row.stock_value_difference)
|
stock_value = abs(row.stock_value_difference)
|
||||||
|
|
||||||
while qty_to_pop:
|
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_qty = flt(slot[FIFO_QTY_INDEX])
|
||||||
slot_value = flt(slot[FIFO_VALUE_INDEX])
|
slot_value = flt(slot[FIFO_VALUE_INDEX])
|
||||||
|
|
||||||
if 0 < slot_qty <= qty_to_pop:
|
if 0 < slot_qty <= qty_to_pop:
|
||||||
qty_to_pop -= slot_qty
|
qty_to_pop -= slot_qty
|
||||||
stock_value -= slot_value
|
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:
|
elif not fifo_queue:
|
||||||
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
|
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
|
||||||
self.transferred_item_details[transfer_key].append(
|
self.transferred_item_details[transfer_key].append(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
|
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"])
|
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
|
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):
|
def test_insufficient_balance(self):
|
||||||
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
||||||
sle = [
|
sle = [
|
||||||
|
|||||||
Reference in New Issue
Block a user