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:
Mihir Kandoi
2026-09-14 17:51:15 +05:30
committed by GitHub
parent 990a43ed16
commit 5a63b36c3b
2 changed files with 61 additions and 3 deletions

View File

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

View File

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