From 48082020e8fd020b3b274e38c595dfc4263a3c4d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 23 Jul 2026 15:56:42 +0530 Subject: [PATCH] fix: rebalance batch slot values at the pooled rate when driven negative A batch is one valuation pool, so consumption is valued at the pooled rate while slots may carry stale intra-batch detail (e.g. units reconciled at zero and later merged). Consuming such a slot leaves a negative value on positive qty. Spread the pool value across the batch's slots when that happens; non-batchwise slots pool per warehouse. --- .../stock/report/stock_ageing/stock_ageing.py | 28 ++++++++++ .../report/stock_ageing/test_stock_ageing.py | 52 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index e745a10ea7e..ebe91dbefb9 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -325,6 +325,7 @@ class FIFOSlots: del stock_ledger_entries self._recompute_moving_average_slots() + self._rebalance_negative_batch_slots() if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) @@ -346,6 +347,33 @@ class FIFOSlots: if is_qty_slot(slot): slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _rebalance_negative_batch_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_batch_no"): + self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"]) + + def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None: + """A batch is one valuation pool, so a slot driven negative by consumption + at the pooled rate is stale detail: spread the pool value over its slots.""" + groups = {} + for slot in fifo_queue: + if is_batch_slot(slot): + key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None + groups.setdefault(key, []).append(slot) + + for slots in groups.values(): + has_negative_slot = any( + flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0 + for slot in slots + ) + total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) + if not has_negative_slot or total_qty <= 0: + continue + + rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty + for slot in slots: + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate) + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: if stock_ledger_entries is not None: return frappe._dict({}), frappe._dict({}) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index fb488c47eff..658056afcd7 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -620,6 +620,58 @@ class TestStockAgeing(FrappeTestCase): ], ) + def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self): + """Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5] + Consuming the zero-valued head slot at the pooled rate drives it + negative; slot values are then rebalanced to the batch pool rate.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Rebalance", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-REBALANCE-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_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="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, 10, 0), + make_sle("2021-12-02", "002", 10, 20, 100), + make_sle("2021-12-03", "003", -4, 16, -20), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual( + queue, + [ + [batch_no, 1, 6.0, "2021-12-01", 30.0], + [batch_no, 1, 10.0, "2021-12-01", 50.0], + ], + ) + def test_sequential_stock_reco_same_warehouse(self): """ Test back to back stock recos (same warehouse).