From c9394c030f2f0927016c47fae79fe3de6e8a6456 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 21 Jul 2026 13:44:36 +0530 Subject: [PATCH 1/5] fix: rescale stock ageing FIFO slot values on stock reconciliation A reconciliation's stock_value_difference includes the revaluation of stock already in the FIFO queue, but the whole amount was attached to the qty-delta slot while older slots kept pre-revaluation values. A downward revaluation therefore produced negative bucket values in the Stock Ageing report, and repeated recos let the queue total drift away from Stock Balance. Re-derive every slot value as qty * valuation_rate after processing a reco SLE, since a reconciliation values the entire balance at its rate. Covers both single-SLE recos and the zero-out/re-add pair that flows through the transfer bucket. --- .../stock/report/stock_ageing/stock_ageing.py | 9 ++ .../report/stock_ageing/test_stock_ageing.py | 85 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 343ec5539fe..8f01dcb136c 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -370,6 +370,7 @@ class FIFOSlots: row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end ) + self._revalue_stock_reconciliation_slots(row, fifo_queue) self._update_balances(row, key) self._trim_serial_fifo_queue(row, key, fifo_queue) @@ -393,6 +394,14 @@ class FIFOSlots: # Stock reconciliation stores the final balance; FIFO needs the movement delta. row.actual_qty = flt(row.qty_after_transaction) - flt(prev_balance_qty) + def _revalue_stock_reconciliation_slots(self, row: dict, fifo_queue: list) -> None: + if row.voucher_type != "Stock Reconciliation" or row.has_serial_no or row.has_batch_no: + return + + for slot in fifo_queue: + if is_qty_slot(slot): + slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * flt(row.valuation_rate)) + def _get_serial_and_batch_nos( self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict ) -> tuple[list, list]: diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 180a424b209..6e32bde647d 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -379,6 +379,91 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual(queue, [[60.0, "2025-11-30", 60.0], [30.0, "2026-01-31", 30.0]]) self.assertEqual(report_data[0][7:15], [30.0, 30.0, 0.0, 0.0, 60.0, 60.0, 0.0, 0.0]) + def test_stock_reco_revaluation_rescales_queue_values(self): + "Ledger (same wh): [+15 @ 100, reco reset >> 20 @ 50]" + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=15, + qty_after_transaction=15, + stock_value_difference=1500, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="Flask Item", + actual_qty=0, + qty_after_transaction=20, + stock_value_difference=(-500), + valuation_rate=50, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots["Flask Item"]["fifo_queue"] + + self.assertEqual(queue, [[15.0, "2021-12-01", 750.0], [5.0, "2021-12-02", 250.0]]) + + def test_stock_reco_with_split_out_and_in_sles_revalues_queue(self): + "Ledger (same wh): [+10 @ 100, reco out >> 0, reco in >> 12 @ 2]" + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=10, + qty_after_transaction=10, + stock_value_difference=1000, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="Flask Item", + actual_qty=(-10), + qty_after_transaction=0, + stock_value_difference=(-1000), + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="Flask Item", + actual_qty=12, + qty_after_transaction=12, + stock_value_difference=24, + valuation_rate=2, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots["Flask Item"]["fifo_queue"] + + self.assertEqual(queue, [[10.0, "2021-12-01", 20.0], [2.0, "2021-12-02", 4.0]]) + def test_sequential_stock_reco_same_warehouse(self): """ Test back to back stock recos (same warehouse). From 6b3b03fcd8172bc6a698b7b97d03581cc7f7c0dd Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 21 Jul 2026 13:49:21 +0530 Subject: [PATCH 2/5] fix: rescale batch FIFO slot values on stock reconciliation Batch items take the batch-slot path, which mirrors the same value arithmetic: the reco's incoming entry dumps the revaluation remainder on one slot. Rescale each reconciled batch's slots at its post-reco rate (stock_value_difference / qty of the incoming bundle entry). --- .../stock/report/stock_ageing/stock_ageing.py | 21 ++++++-- .../report/stock_ageing/test_stock_ageing.py | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 8f01dcb136c..ef43b684fcd 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -370,7 +370,7 @@ class FIFOSlots: row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end ) - self._revalue_stock_reconciliation_slots(row, fifo_queue) + self._revalue_stock_reconciliation_slots(row, fifo_queue, batch_nos) self._update_balances(row, key) self._trim_serial_fifo_queue(row, key, fifo_queue) @@ -394,14 +394,29 @@ class FIFOSlots: # Stock reconciliation stores the final balance; FIFO needs the movement delta. row.actual_qty = flt(row.qty_after_transaction) - flt(prev_balance_qty) - def _revalue_stock_reconciliation_slots(self, row: dict, fifo_queue: list) -> None: - if row.voucher_type != "Stock Reconciliation" or row.has_serial_no or row.has_batch_no: + def _revalue_stock_reconciliation_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None: + if row.voucher_type != "Stock Reconciliation" or row.has_serial_no: + return + + if row.has_batch_no: + if flt(row.actual_qty) > 0: + self._revalue_reconciled_batch_slots(fifo_queue, batch_nos) return for slot in fifo_queue: if is_qty_slot(slot): slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * flt(row.valuation_rate)) + def _revalue_reconciled_batch_slots(self, fifo_queue: list, batch_nos: list) -> None: + for batch_no, _use_batchwise_valuation, qty, stock_value_difference in batch_nos: + if not flt(qty): + continue + + rate = flt(stock_value_difference) / flt(qty) + for slot in fifo_queue: + if is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no: + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate) + def _get_serial_and_batch_nos( self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict ) -> tuple[list, list]: diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 6e32bde647d..4875ea1bace 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -464,6 +464,56 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual(queue, [[10.0, "2021-12-01", 20.0], [2.0, "2021-12-02", 4.0]]) + def test_batch_stock_reco_revaluation_rescales_slot_values(self): + "Ledger (same wh, batch B): [+10 @ 100, reco out >> 0, reco in >> 12 @ 2]" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Reco Revaluation", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-RECO-REVALUE-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_type, voucher_no, 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=abs(stock_value_difference / actual_qty), + warehouse="WH 1", + posting_date=posting_date, + voucher_type=voucher_type, + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "Stock Entry", "001", 10, 10, 1000), + make_sle("2021-12-02", "Stock Reconciliation", "002", -10, 0, -1000), + make_sle("2021-12-02", "Stock Reconciliation", "002", 12, 12, 24), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual( + queue, + [ + [batch_no, 1, 10.0, "2021-12-01", 20.0], + [batch_no, 1, 2.0, "2021-12-02", 4.0], + ], + ) + def test_sequential_stock_reco_same_warehouse(self): """ Test back to back stock recos (same warehouse). From a4bf50656a3e5ea4c689896cf9e9f29ba855c5a6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 21 Jul 2026 14:09:28 +0530 Subject: [PATCH 3/5] fix: revalue batch reco slots only when the entry covers the full batch stock_value_difference / qty equals the new batch rate only when the reco entry carries the entire batch, as the split out/in reco SLEs and batches reconciled from zero do. Partial direct-batch_no entries mix a qty delta with existing stock, so their slots keep prior values. Plain items need no such guard: the valuation engine collapses the FIFO stack to qty_after * valuation_rate on every reconciliation, so rescaling remaining slots at the reco rate matches the ledger. Lock that with a test. --- .../stock/report/stock_ageing/stock_ageing.py | 13 ++- .../report/stock_ageing/test_stock_ageing.py | 104 +++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index ef43b684fcd..9ae14684a88 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -412,10 +412,17 @@ class FIFOSlots: if not flt(qty): continue + slots = [ + slot + for slot in fifo_queue + if is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no + ] + if flt(sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - flt(qty), 6): + continue + rate = flt(stock_value_difference) / flt(qty) - for slot in fifo_queue: - if is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no: - slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate) + for slot in slots: + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate) def _get_serial_and_batch_nos( self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 4875ea1bace..f072dfeba4d 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -464,6 +464,57 @@ class TestStockAgeing(ERPNextTestSuite): self.assertEqual(queue, [[10.0, "2021-12-01", 20.0], [2.0, "2021-12-02", 4.0]]) + def test_stock_reco_decrease_rescales_slots_at_reco_rate(self): + """Ledger (same wh): [+10 @ 100, +20 @ 250, reco reset >> 25 @ 220] + The valuation engine collapses the FIFO stack to qty_after * valuation_rate + on a reco, so remaining slot values follow the reco rate, not the lot rates.""" + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=10, + qty_after_transaction=10, + stock_value_difference=1000, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="Flask Item", + actual_qty=20, + qty_after_transaction=30, + stock_value_difference=5000, + valuation_rate=200, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="Flask Item", + actual_qty=0, + qty_after_transaction=25, + stock_value_difference=(-500), + valuation_rate=220, + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Reconciliation", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots["Flask Item"]["fifo_queue"] + + self.assertEqual(queue, [[5.0, "2021-12-01", 1100.0], [20.0, "2021-12-02", 4400.0]]) + def test_batch_stock_reco_revaluation_rescales_slot_values(self): "Ledger (same wh, batch B): [+10 @ 100, reco out >> 0, reco in >> 12 @ 2]" from erpnext.stock.doctype.item.test_item import make_item @@ -486,7 +537,7 @@ class TestStockAgeing(ERPNextTestSuite): actual_qty=actual_qty, qty_after_transaction=qty_after, stock_value_difference=stock_value_difference, - valuation_rate=abs(stock_value_difference / actual_qty), + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, warehouse="WH 1", posting_date=posting_date, voucher_type=voucher_type, @@ -514,6 +565,57 @@ class TestStockAgeing(ERPNextTestSuite): ], ) + def test_partial_batch_reco_keeps_existing_slot_values(self): + """Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12] + The reco entry qty (delta 2) does not cover the whole batch, so + stock_value_difference / qty is not the batch rate: skip the rescale.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Partial Batch Reco", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-PARTIAL-RECO-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_type, voucher_no, 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=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type=voucher_type, + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "Stock Entry", "001", 10, 10, 1000), + make_sle("2021-12-02", "Stock Reconciliation", "002", 0, 12, -400), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual( + queue, + [ + [batch_no, 1, 10.0, "2021-12-01", 1000.0], + [batch_no, 1, 2.0, "2021-12-01", 400.0], + ], + ) + def test_sequential_stock_reco_same_warehouse(self): """ Test back to back stock recos (same warehouse). From 92217b2c45ae6703495e058ddc52b7fbd5e111f2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 21 Jul 2026 14:18:19 +0530 Subject: [PATCH 4/5] fix: use system float precision for batch qty comparison --- erpnext/stock/report/stock_ageing/stock_ageing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 9ae14684a88..181c57577c9 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -408,6 +408,7 @@ class FIFOSlots: slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * flt(row.valuation_rate)) def _revalue_reconciled_batch_slots(self, fifo_queue: list, batch_nos: list) -> None: + precision = get_float_precision() for batch_no, _use_batchwise_valuation, qty, stock_value_difference in batch_nos: if not flt(qty): continue @@ -417,7 +418,7 @@ class FIFOSlots: for slot in fifo_queue if is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no ] - if flt(sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - flt(qty), 6): + if flt(sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - flt(qty), precision): continue rate = flt(stock_value_difference) / flt(qty) From 0f252542c30d4de64965fbba7ad3b8d92733b373 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 21 Jul 2026 14:34:08 +0530 Subject: [PATCH 5/5] fix: resolve float precision before streaming stock ledger entries get_single_value inside _revalue_reconciled_batch_slots runs while rows stream through the unbuffered cursor on MariaDB, killing the active iterator. Resolve it once in generate() with the other prefetches. --- erpnext/stock/report/stock_ageing/stock_ageing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 181c57577c9..e880e8db9b9 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -306,6 +306,7 @@ class FIFOSlots: # prepare single sle voucher detail lookup self.prepare_stock_reco_voucher_wise_count() + self.float_precision = get_float_precision() if stock_ledger_entries is None: # streaming path: nested queries invalidate the streaming cursor below, @@ -408,7 +409,6 @@ class FIFOSlots: slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * flt(row.valuation_rate)) def _revalue_reconciled_batch_slots(self, fifo_queue: list, batch_nos: list) -> None: - precision = get_float_precision() for batch_no, _use_batchwise_valuation, qty, stock_value_difference in batch_nos: if not flt(qty): continue @@ -418,7 +418,7 @@ class FIFOSlots: for slot in fifo_queue if is_batch_slot(slot) and slot[BATCH_SLOT_BATCH_INDEX] == batch_no ] - if flt(sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - flt(qty), precision): + if flt(sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) - flt(qty), self.float_precision): continue rate = flt(stock_value_difference) / flt(qty)