mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-16 18:24:10 +00:00
fix(stock): consume batch slots newest first for LIFO items (#59062)
* fix(stock): consume batch slots newest first for LIFO items The valuation method decided which end of the queue an issue consumed from, but only for stock carrying no batch or serial number. Batch slots were always consumed from the head, so a LIFO item reported its oldest stock as still on hand when it had been issued. Slots of one batch valued batchwise share a date, so the direction of the walk cannot change what they report. Slots pooled across batches carry the date of the batch that filled them, and there the wrong stock aged. Pass the valuation method through to the batch walk and read the queue from the tail for a LIFO item, as the untagged walk already does. * test(stock): cover LIFO consumption of pooled batch slots Issue against the newer of two pooled batches on a LIFO item and assert the September slot is consumed rather than the January one.
This commit is contained in:
@@ -717,7 +717,7 @@ class FIFOSlots:
|
||||
if serial_nos:
|
||||
self._consume_serial_fifo_slots(fifo_queue, serial_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, from_end)
|
||||
else:
|
||||
self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end)
|
||||
|
||||
@@ -725,12 +725,16 @@ class FIFOSlots:
|
||||
fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos]
|
||||
|
||||
def _consume_batch_fifo_slots(
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, batch_nos: list
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, batch_nos: list, from_end: bool = False
|
||||
) -> None:
|
||||
"""LIFO consumes the most recent inward first, so walk the queue from the tail.
|
||||
Slots of one batch valued batchwise share a date and the walk cannot tell them
|
||||
apart, but slots pooled across batches carry the date of the batch that filled
|
||||
them."""
|
||||
for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos:
|
||||
items_to_remove = []
|
||||
|
||||
for slot in fifo_queue:
|
||||
for slot in reversed(fifo_queue) if from_end else fifo_queue:
|
||||
if not self._can_consume_batch_slot(slot, batch_no, use_batchwise_valuation):
|
||||
continue
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import patch
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
BATCH_SLOT_DATE_INDEX,
|
||||
BATCH_SLOT_QTY_INDEX,
|
||||
BATCH_SLOT_VALUE_INDEX,
|
||||
FIFOSlots,
|
||||
@@ -783,6 +784,59 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(warehouse_filtered[(item_code, "WH 2")]["fifo_queue"], aged_from_transfer)
|
||||
self.assertEqual(unfiltered[(item_code, "WH 1")]["fifo_queue"], [])
|
||||
|
||||
def test_lifo_batch_item_consumes_the_most_recent_slot(self):
|
||||
"""Ledger (same wh, LIFO item): +100 of an old pooled batch in January,
|
||||
+50 of a newer one in September, then -20. Pooled slots are interchangeable,
|
||||
so LIFO has to take the September slot, not the January one."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing LIFO Batch",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "LIFO"},
|
||||
).name
|
||||
|
||||
older_batch = "SA-LIFO-OLDER"
|
||||
newer_batch = "SA-LIFO-NEWER"
|
||||
for batch_id in (older_batch, newer_batch):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_id, "item": item_code}).insert(
|
||||
ignore_permissions=True, ignore_if_duplicate=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 0)
|
||||
|
||||
qty_after = 0
|
||||
|
||||
def make_sle(posting_date, voucher_no, batch_no, actual_qty):
|
||||
nonlocal qty_after
|
||||
|
||||
qty_after += actual_qty
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=actual_qty * 10,
|
||||
valuation_rate=10,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-01-01", "001", older_batch, 100),
|
||||
make_sle("2021-09-01", "002", newer_batch, 50),
|
||||
make_sle("2021-09-10", "003", newer_batch, -20),
|
||||
]
|
||||
|
||||
queue = FIFOSlots(self.filters, sle).generate()[item_code]["fifo_queue"]
|
||||
quantities = {slot[BATCH_SLOT_DATE_INDEX]: slot[BATCH_SLOT_QTY_INDEX] for slot in queue}
|
||||
|
||||
self.assertEqual(quantities["2021-01-01"], 100.0)
|
||||
self.assertEqual(quantities["2021-09-01"], 30.0)
|
||||
|
||||
def test_batch_pooling_preserves_total_on_repeating_rate(self):
|
||||
"""Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0]
|
||||
The pooled rate does not terminate, so assert the redistributed
|
||||
|
||||
Reference in New Issue
Block a user