From 0e31182dca166240cea6c6c4f14574243aa7b6cc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 17 Sep 2026 11:07:27 +0530 Subject: [PATCH] perf(stock): skip the negative batch scan when nothing is negative (#59061) --- .../stock/report/stock_ageing/stock_ageing.py | 16 ++++++ .../report/stock_ageing/test_stock_ageing.py | 51 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 20508f3c4ec..ab9f3592230 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -288,6 +288,7 @@ class FIFOSlots: self.transferred_item_details = {} self.serial_no_details = {} self.batch_no_details = {} + self.batches_with_negative_slots = set() self.batchwise_valuation_by_batch = {} self.valuation_method_by_item = {} self.filters = filters @@ -659,6 +660,11 @@ class FIFOSlots: if not qty: return qty, stock_value_difference + if (batch_no, row.warehouse) not in self.batches_with_negative_slots: + return qty, stock_value_difference + + negative_slot_may_remain = False + for slot in list(fifo_queue): if not self._is_matching_negative_batch_slot(slot, batch_no, use_batchwise_valuation): continue @@ -679,10 +685,16 @@ class FIFOSlots: if not flt(slot[BATCH_SLOT_QTY_INDEX]) and not flt(slot[BATCH_SLOT_VALUE_INDEX]): fifo_queue.remove(slot) + elif flt(slot[BATCH_SLOT_QTY_INDEX]) < 0: + negative_slot_may_remain = True if not qty: + negative_slot_may_remain = True break + if not negative_slot_may_remain: + self.batches_with_negative_slots.discard((batch_no, row.warehouse)) + return qty, stock_value_difference def _is_matching_negative_batch_slot( @@ -795,9 +807,13 @@ class FIFOSlots: qty: float, stock_value_difference: float, ) -> None: + """The only place a batch slot goes negative, so it is also where the warehouse + is recorded as owing stock on that batch. The record is discarded again by a walk + that reaches the end of the queue and leaves nothing negative behind.""" fifo_queue.append( [batch_no, use_batchwise_valuation, -(qty), row.posting_date, -(stock_value_difference)] ) + self.batches_with_negative_slots.add((batch_no, row.warehouse)) self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference]) def _consume_fifo_slots( diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 0dbb98c8d33..fa716394335 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1884,6 +1884,57 @@ class TestStockAgeing(ERPNextTestSuite): [[5.0, "2021-09-01", 50.0]], ) + def test_second_negative_batch_slot_survives_a_partial_refill(self): + """Ledger (same wh, batch B): two issues against no stock, then two receipts. + The first receipt clears only the first negative slot, so the second receipt + must still find and clear the one left behind.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Twice Negative Batch", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-TWICE-NEGATIVE-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) + + qty_after = 0 + + def make_sle(posting_date, voucher_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-12-01", "001", -10), + make_sle("2021-12-02", "002", -5), + make_sle("2021-12-03", "003", 10), + make_sle("2021-12-04", "004", 5), + ] + + result = FIFOSlots(self.filters, sle).generate()[item_code] + + self.assertEqual(result["fifo_queue"], []) + self.assertEqual(result["total_qty"], 0) + def test_batchwise_valuation_negative_stock_same_voucher(self): from erpnext.stock.doctype.item.test_item import make_item