fix(stock): scope stock ageing batch and serial age to the warehouse (#59058)

* fix(stock): scope stock ageing batch and serial age to the warehouse

The first inward posting date of a batch or serial number was cached
under the identity alone, so the age of a row depended on which stock
ledger entries the filters let the report scan.

A batch received into WH A and transferred to WH B aged from the WH A
receipt in an unfiltered run, but from the transfer date once a
warehouse filter was applied. Same stock, same warehouse, same to date,
two different ages.

Key the cache on the warehouse as well. Repeated receipts of one batch
into one warehouse still age from the first of them, and a transfer now
restarts the clock in the destination warehouse, as it already does for
stock that carries no batch or serial number.

* test(stock): cover warehouse scoped batch age in stock ageing

A batch received into one warehouse and transferred to another aged
from the first receipt in an unfiltered run and from the transfer once
the warehouse filter narrowed the scan. Assert both runs report the
transfer date.

* test(stock): cover warehouse scoped serial age in stock ageing

The cached date is keyed on the warehouse for serial numbers as well as
batches, and only the batch half was covered. Assert a serial
transferred between warehouses ages from the transfer in both a full
and a warehouse filtered scan.

Insert the batch fixture with ignore_if_duplicate instead of checking
for it first.
This commit is contained in:
Mihir Kandoi
2026-09-14 17:51:14 +05:30
committed by GitHub
parent 9d3675405a
commit 990a43ed16
2 changed files with 132 additions and 8 deletions

View File

@@ -629,7 +629,7 @@ class FIFOSlots:
def _add_serial_fifo_slots(self, row: dict, fifo_queue: list, serial_nos: list) -> None: def _add_serial_fifo_slots(self, row: dict, fifo_queue: list, serial_nos: list) -> None:
valuation = row.stock_value_difference / row.actual_qty valuation = row.stock_value_difference / row.actual_qty
for serial_no in serial_nos: for serial_no in serial_nos:
posting_date = self.serial_no_details.setdefault(serial_no, row.posting_date) posting_date = self.serial_no_details.setdefault((serial_no, row.warehouse), row.posting_date)
fifo_queue.append([serial_no, posting_date, valuation]) fifo_queue.append([serial_no, posting_date, valuation])
def _add_batch_fifo_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None: def _add_batch_fifo_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None:
@@ -641,7 +641,7 @@ class FIFOSlots:
if not qty: if not qty:
continue continue
posting_date = self.batch_no_details.setdefault(batch_no, row.posting_date) posting_date = self.batch_no_details.setdefault((batch_no, row.warehouse), row.posting_date)
fifo_queue.append([batch_no, use_batchwise_valuation, qty, posting_date, stock_value_difference]) fifo_queue.append([batch_no, use_batchwise_valuation, qty, posting_date, stock_value_difference])
def _neutralize_negative_batch_stock( def _neutralize_negative_batch_stock(
@@ -852,13 +852,25 @@ class FIFOSlots:
transfer_qty_to_pop -= transfer_qty transfer_qty_to_pop -= transfer_qty
stock_value -= transfer_value stock_value -= transfer_value
self._add_incoming_transfer_slots( self._add_incoming_transfer_slots(
fifo_queue, batch_nos, transfer_qty, transfer_date, transfer_value, serial_nos fifo_queue,
row.warehouse,
batch_nos,
transfer_qty,
transfer_date,
transfer_value,
serial_nos,
) )
transfer_data.pop(0) transfer_data.pop(0)
elif not transfer_data: elif not transfer_data:
# transfer bucket is empty, extra incoming qty # transfer bucket is empty, extra incoming qty
self._add_incoming_transfer_slots( self._add_incoming_transfer_slots(
fifo_queue, batch_nos, transfer_qty_to_pop, row.posting_date, stock_value, serial_nos fifo_queue,
row.warehouse,
batch_nos,
transfer_qty_to_pop,
row.posting_date,
stock_value,
serial_nos,
) )
transfer_qty_to_pop = 0 transfer_qty_to_pop = 0
stock_value = 0 stock_value = 0
@@ -868,6 +880,7 @@ class FIFOSlots:
transfer_data[0][FIFO_VALUE_INDEX] -= stock_value transfer_data[0][FIFO_VALUE_INDEX] -= stock_value
self._add_incoming_transfer_slots( self._add_incoming_transfer_slots(
fifo_queue, fifo_queue,
row.warehouse,
batch_nos, batch_nos,
transfer_qty_to_pop, transfer_qty_to_pop,
transfer_data[0][FIFO_DATE_INDEX], transfer_data[0][FIFO_DATE_INDEX],
@@ -880,17 +893,21 @@ class FIFOSlots:
def _add_incoming_transfer_slots( def _add_incoming_transfer_slots(
self, self,
fifo_queue: list, fifo_queue: list,
warehouse: str,
batch_nos: list, batch_nos: list,
qty: float, qty: float,
posting_date: str, posting_date: str,
value: float, value: float,
serial_nos: list | None = None, serial_nos: list | None = None,
) -> None: ) -> None:
for slot in self._get_incoming_transfer_slots(batch_nos, qty, posting_date, value, serial_nos): for slot in self._get_incoming_transfer_slots(
warehouse, batch_nos, qty, posting_date, value, serial_nos
):
self._add_transfer_slot_to_fifo_queue(fifo_queue, slot) self._add_transfer_slot_to_fifo_queue(fifo_queue, slot)
def _get_incoming_transfer_slots( def _get_incoming_transfer_slots(
self, self,
warehouse: str,
batch_nos: list, batch_nos: list,
qty: float, qty: float,
posting_date: str, posting_date: str,
@@ -898,7 +915,7 @@ class FIFOSlots:
serial_nos: list | None = None, serial_nos: list | None = None,
) -> list: ) -> list:
if serial_nos: if serial_nos:
return self._get_serial_incoming_transfer_slots(serial_nos, qty, posting_date, value) return self._get_serial_incoming_transfer_slots(serial_nos, warehouse, qty, posting_date, value)
if not batch_nos: if not batch_nos:
return [[qty, posting_date, value]] return [[qty, posting_date, value]]
@@ -932,7 +949,7 @@ class FIFOSlots:
return incoming_slots return incoming_slots
def _get_serial_incoming_transfer_slots( def _get_serial_incoming_transfer_slots(
self, serial_nos: list, qty: float, posting_date: str, value: float self, serial_nos: list, warehouse: str, qty: float, posting_date: str, value: float
) -> list: ) -> list:
incoming_slots = [] incoming_slots = []
remaining_value = flt(value) remaining_value = flt(value)
@@ -941,7 +958,7 @@ class FIFOSlots:
for index in range(serial_count): for index in range(serial_count):
serial_no = serial_nos.pop(0) serial_no = serial_nos.pop(0)
serial_value = remaining_value if index == serial_count - 1 else flt(value / serial_count) serial_value = remaining_value if index == serial_count - 1 else flt(value / serial_count)
serial_posting_date = self.serial_no_details.setdefault(serial_no, posting_date) serial_posting_date = self.serial_no_details.setdefault((serial_no, warehouse), posting_date)
incoming_slots.append([serial_no, serial_posting_date, serial_value]) incoming_slots.append([serial_no, serial_posting_date, serial_value])
remaining_value = flt(remaining_value - serial_value) remaining_value = flt(remaining_value - serial_value)

View File

@@ -676,6 +676,113 @@ class TestStockAgeing(ERPNextTestSuite):
], ],
) )
def test_batch_age_in_warehouse_ignores_receipt_in_another_warehouse(self):
"""Ledger (batch B): +10 into WH 1, transferred to WH 2 four days later.
WH 2 has held the batch since the transfer, so it ages from the transfer
whether or not a warehouse filter narrowed the scan to WH 2."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Warehouse Scope",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-WAREHOUSE-SCOPE-BATCH"
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True, ignore_if_duplicate=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, warehouse, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=10,
warehouse=warehouse,
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,
)
def make_transfer_in():
return make_sle("2021-12-05", "002", "WH 2", 10, 10, 100)
def make_whole_ledger():
return [
make_sle("2021-12-01", "001", "WH 1", 10, 10, 100),
make_sle("2021-12-05", "002", "WH 1", -10, 0, -100),
make_transfer_in(),
]
self.filters.show_warehouse_wise_stock = True
try:
unfiltered = FIFOSlots(self.filters, make_whole_ledger()).generate()
warehouse_filtered = FIFOSlots(self.filters, [make_transfer_in()]).generate()
finally:
self.filters.show_warehouse_wise_stock = False
aged_from_transfer = [[batch_no, 1, 10.0, "2021-12-05", 100.0]]
self.assertEqual(unfiltered[(item_code, "WH 2")]["fifo_queue"], aged_from_transfer)
self.assertEqual(warehouse_filtered[(item_code, "WH 2")]["fifo_queue"], aged_from_transfer)
self.assertEqual(unfiltered[(item_code, "WH 1")]["fifo_queue"], [])
def test_serial_age_in_warehouse_ignores_receipt_in_another_warehouse(self):
"""Ledger (serial SN): received into WH 1, transferred to WH 2 four days later.
WH 2 has held the serial since the transfer, whether or not the warehouse
filter narrowed the scanned entries to WH 2."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Serial Warehouse Scope",
{"is_stock_item": 1, "has_serial_no": 1, "valuation_method": "FIFO"},
).name
serial_no = "SA-WAREHOUSE-SCOPE-SN1"
def make_sle(posting_date, voucher_no, warehouse, actual_qty, qty_after):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=actual_qty * 100,
valuation_rate=100,
warehouse=warehouse,
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=True,
has_batch_no=False,
serial_no=serial_no,
batch_no=None,
)
def make_transfer_in():
return make_sle("2021-12-05", "002", "WH 2", 1, 1)
def make_whole_ledger():
return [
make_sle("2021-12-01", "001", "WH 1", 1, 1),
make_sle("2021-12-05", "002", "WH 1", -1, 0),
make_transfer_in(),
]
self.filters.show_warehouse_wise_stock = True
try:
unfiltered = FIFOSlots(self.filters, make_whole_ledger()).generate()
warehouse_filtered = FIFOSlots(self.filters, [make_transfer_in()]).generate()
finally:
self.filters.show_warehouse_wise_stock = False
aged_from_transfer = [[serial_no, "2021-12-05", 100.0]]
self.assertEqual(unfiltered[(item_code, "WH 2")]["fifo_queue"], aged_from_transfer)
self.assertEqual(warehouse_filtered[(item_code, "WH 2")]["fifo_queue"], aged_from_transfer)
self.assertEqual(unfiltered[(item_code, "WH 1")]["fifo_queue"], [])
def test_batch_pooling_preserves_total_on_repeating_rate(self): def test_batch_pooling_preserves_total_on_repeating_rate(self):
"""Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0] """Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0]
The pooled rate does not terminate, so assert the redistributed The pooled rate does not terminate, so assert the redistributed