mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 15:38:39 +00:00
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.
This commit is contained in:
@@ -325,6 +325,7 @@ class FIFOSlots:
|
|||||||
del stock_ledger_entries
|
del stock_ledger_entries
|
||||||
|
|
||||||
self._recompute_moving_average_slots()
|
self._recompute_moving_average_slots()
|
||||||
|
self._rebalance_negative_batch_slots()
|
||||||
|
|
||||||
if not self.filters.get("show_warehouse_wise_stock"):
|
if not self.filters.get("show_warehouse_wise_stock"):
|
||||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||||
@@ -346,6 +347,33 @@ class FIFOSlots:
|
|||||||
if is_qty_slot(slot):
|
if is_qty_slot(slot):
|
||||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
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]:
|
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||||
if stock_ledger_entries is not None:
|
if stock_ledger_entries is not None:
|
||||||
return frappe._dict({}), frappe._dict({})
|
return frappe._dict({}), frappe._dict({})
|
||||||
|
|||||||
@@ -616,6 +616,58 @@ class TestStockAgeing(ERPNextTestSuite):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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):
|
def test_sequential_stock_reco_same_warehouse(self):
|
||||||
"""
|
"""
|
||||||
Test back to back stock recos (same warehouse).
|
Test back to back stock recos (same warehouse).
|
||||||
|
|||||||
Reference in New Issue
Block a user