Compare commits

...

6 Commits

Author SHA1 Message Date
Mihir Kandoi
9cff072e04 Merge branch 'fix/stock-ageing-warehouse-scoped-age' of https://github.com/frappe/erpnext into fix/stock-ageing-depleted-batch-age
# Conflicts:
#	erpnext/stock/report/stock_ageing/test_stock_ageing.py
2026-09-14 14:22:23 +05:30
Mihir Kandoi
750b9cbac0 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.
2026-09-14 14:19:13 +05:30
Mihir Kandoi
0a26409ce5 test(stock): cover re-receipt of a depleted batch in stock ageing
Assert a batchwise batch ages from the later receipt once the warehouse
has emptied, and that a pooled batch keeps the earlier date.
2026-09-14 14:09:27 +05:30
Mihir Kandoi
30037b495e fix(stock): age a re-received batch from its own receipt
The first inward date of a batch was cached for the whole report run and
never dropped, so a batch that left a warehouse and was received again
months later still aged from the receipt before it emptied.

Drop the cached date once the batch has no slot left in that warehouse.
Whether a slot survives is read off the walk the consumption already
makes, so no second pass over the queue is added.

A slot is still present while stock is negative, so a backdated issue
followed by a receipt keeps its date. Batches that are not valued
batchwise share one pool of slots, where the owning batch cannot be
identified, and keep the cached date.
2026-09-14 14:09:26 +05:30
Mihir Kandoi
9c6a49f291 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.
2026-09-14 13:49:13 +05:30
Mihir Kandoi
9d090cc8b8 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.
2026-09-14 13:48:34 +05:30
2 changed files with 208 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:
valuation = row.stock_value_difference / row.actual_qty
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])
def _add_batch_fifo_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None:
@@ -641,7 +641,7 @@ class FIFOSlots:
if not qty:
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])
def _neutralize_negative_batch_stock(
@@ -729,9 +729,11 @@ class FIFOSlots:
) -> None:
for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos:
items_to_remove = []
has_remaining_slot = False
for slot in fifo_queue:
if not self._can_consume_batch_slot(slot, batch_no, use_batchwise_valuation):
has_remaining_slot = has_remaining_slot or self._is_slot_of_batch(slot, batch_no)
continue
slot_qty = flt(slot[BATCH_SLOT_QTY_INDEX])
@@ -753,6 +755,7 @@ class FIFOSlots:
)
qty = 0
stock_value_difference = 0
has_remaining_slot = True
break
for item in items_to_remove:
@@ -768,6 +771,22 @@ class FIFOSlots:
qty,
stock_value_difference,
)
has_remaining_slot = True
self._drop_depleted_batch_date(row, batch_no, use_batchwise_valuation, has_remaining_slot)
def _drop_depleted_batch_date(
self, row: dict, batch_no: str, use_batchwise_valuation: bool, has_remaining_slot: bool
) -> None:
"""Stock received into a warehouse the batch has fully left is new stock and
ages from its own date. Negative stock still holds a slot. Batches valued as one
pool share their slots, so the batch that owns a slot cannot be told apart there
and the date is kept."""
if use_batchwise_valuation and not has_remaining_slot:
self.batch_no_details.pop((batch_no, row.warehouse), None)
def _is_slot_of_batch(self, slot: list, batch_no: str) -> bool:
return is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no
def _can_consume_batch_slot(self, slot: list, batch_no: str, use_batchwise_valuation: bool) -> bool:
if not is_batch_slot(slot):
@@ -852,13 +871,25 @@ class FIFOSlots:
transfer_qty_to_pop -= transfer_qty
stock_value -= transfer_value
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)
elif not transfer_data:
# transfer bucket is empty, extra incoming qty
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
stock_value = 0
@@ -868,6 +899,7 @@ class FIFOSlots:
transfer_data[0][FIFO_VALUE_INDEX] -= stock_value
self._add_incoming_transfer_slots(
fifo_queue,
row.warehouse,
batch_nos,
transfer_qty_to_pop,
transfer_data[0][FIFO_DATE_INDEX],
@@ -880,17 +912,21 @@ class FIFOSlots:
def _add_incoming_transfer_slots(
self,
fifo_queue: list,
warehouse: str,
batch_nos: list,
qty: float,
posting_date: str,
value: float,
serial_nos: list | 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)
def _get_incoming_transfer_slots(
self,
warehouse: str,
batch_nos: list,
qty: float,
posting_date: str,
@@ -898,7 +934,7 @@ class FIFOSlots:
serial_nos: list | None = None,
) -> list:
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:
return [[qty, posting_date, value]]
@@ -932,7 +968,7 @@ class FIFOSlots:
return incoming_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:
incoming_slots = []
remaining_value = flt(value)
@@ -941,7 +977,7 @@ class FIFOSlots:
for index in range(serial_count):
serial_no = serial_nos.pop(0)
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])
remaining_value = flt(remaining_value - serial_value)

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,
@@ -676,6 +677,169 @@ 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_received_again_after_depletion_ages_from_the_new_receipt(self):
"""Ledger (same wh): +100 and -100 in January, +50 in September, for a
batchwise batch and a pooled one. The batchwise stock left the warehouse and
comes back new; the pooled batch shares its slots and keeps the old date."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Depleted Batch",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batchwise_batch = "SA-DEPLETED-BATCHWISE"
pooled_batch = "SA-DEPLETED-POOLED"
for batch_id, use_batchwise_valuation in ((batchwise_batch, 1), (pooled_batch, 0)):
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", use_batchwise_valuation)
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", batchwise_batch, 100),
make_sle("2021-01-01", "002", pooled_batch, 100),
make_sle("2021-01-15", "003", batchwise_batch, -100),
make_sle("2021-01-15", "004", pooled_batch, -100),
make_sle("2021-09-01", "005", batchwise_batch, 50),
make_sle("2021-09-01", "006", pooled_batch, 50),
]
queue = FIFOSlots(self.filters, sle).generate()[item_code]["fifo_queue"]
dates = {slot[0]: slot[BATCH_SLOT_DATE_INDEX] for slot in queue}
self.assertEqual(dates[batchwise_batch], "2021-09-01")
self.assertEqual(dates[pooled_batch], "2021-01-01")
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