mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-16 18:24:10 +00:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user