fix(manufacturing): share transfer stock across Production Plan rows (#58812)

* fix(manufacturing): share transfer stock across Production Plan rows

Fetch available stock once per item for the total demand and consume it across rows. Combine batch splits into one transfer row per source warehouse and demand. Round transfer quantities to the plan item precision so the shared balance never leaves a float residue as an extra row.

* test(manufacturing): cover shared transfer stock across Production Plan rows
This commit is contained in:
Mihir Kandoi
2026-09-07 19:04:26 +05:30
committed by GitHub
parent 65048863db
commit 5cea05b302
2 changed files with 152 additions and 15 deletions

View File

@@ -456,6 +456,7 @@ def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_pa
return mr_items
new_mr_items = []
locations_by_item = _get_transfer_locations(mr_items, warehouses, doc.get("company"))
for item in mr_items:
get_materials_from_other_locations(
item,
@@ -463,10 +464,30 @@ def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_pa
new_mr_items,
doc.get("company"),
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
locations=locations_by_item[item.get("item_code")],
)
return new_mr_items
def _get_transfer_locations(mr_items, warehouses, company):
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
required_qty_by_item = defaultdict(float)
for item in mr_items:
required_qty_by_item[item.get("item_code")] += max(
0, flt(item.get("quantity")) * flt(item.get("conversion_factor"))
)
return {
item_code: get_available_item_locations(
item_code, warehouses, required_qty, company, ignore_validation=True
)
if required_qty > 0
else []
for item_code, required_qty in required_qty_by_item.items()
}
def _set_default_suppliers(mr_items, company):
procurement_types = ("Purchase", "Subcontracting")
items = {
@@ -731,17 +752,10 @@ def _material_request_item_row(
def get_materials_from_other_locations(
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False, locations=None
):
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
locations = get_available_item_locations(
item.get("item_code"),
warehouses,
item.get("quantity") * item.get("conversion_factor"),
company,
ignore_validation=True,
)
if locations is None:
locations = _get_transfer_locations([item], warehouses, company)[item.get("item_code")]
required_qty = item.get("quantity")
if item.get("conversion_factor") and item.get("purchase_uom") != item.get("stock_uom"):
@@ -753,23 +767,35 @@ def get_materials_from_other_locations(
def _transfer_from_locations(item, locations, new_mr_items, required_qty):
# get available material by transferring to production warehouse
precision = frappe.get_precision("Material Request Plan Item", "quantity")
transfers_by_warehouse = {}
for d in locations:
if required_qty <= 0:
if flt(required_qty, precision) <= 0:
return required_qty
quantity = flt(min(required_qty, d.get("qty")), precision)
if quantity <= 0:
continue
d["qty"] -= quantity
required_qty -= quantity
warehouse = d.get("warehouse")
if warehouse in transfers_by_warehouse:
transfer = transfers_by_warehouse[warehouse]
transfer["quantity"] = flt(transfer["quantity"] + quantity, precision)
continue
new_dict = copy.deepcopy(item)
quantity = required_qty if d.get("qty") > required_qty else d.get("qty")
new_dict.update(
{
"quantity": quantity,
"material_request_type": "Material Transfer",
"uom": new_dict.get("stock_uom"), # internal transfer should be in stock UOM
"from_warehouse": d.get("warehouse"),
"from_warehouse": warehouse,
"conversion_factor": 1.0,
}
)
required_qty -= quantity
transfers_by_warehouse[warehouse] = new_dict
new_mr_items.append(new_dict)
return required_qty

View File

@@ -2248,6 +2248,117 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(row.warehouse, mrp_warhouse)
self.assertEqual(row.quantity, 12.0)
def test_transfer_batches_share_stock_across_requirements(self):
rm_item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"safety_stock": 100,
"min_order_qty": 1234,
}
).name
source_warehouse = "_Test Warehouse 1 - _TC"
for qty in (1, 1, 5, 3, 3, 4, 100):
make_stock_entry(item_code=rm_item, qty=qty, rate=100, target=source_warehouse)
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 1000
pln.for_warehouse = "_Test Warehouse - _TC"
warehouses = [{"warehouse": source_warehouse}]
for safety_stock in (0, 1):
for minimum_order_qty in (0, 1):
with self.subTest(safety_stock=safety_stock, minimum_order_qty=minimum_order_qty):
pln.include_safety_stock = safety_stock
pln.consider_minimum_order_qty = minimum_order_qty
items = get_items_for_material_requests(pln.as_dict(), warehouses=warehouses)
self.assertEqual(
[row["material_request_type"] for row in items],
["Material Transfer", "Purchase", "Purchase"],
)
purchase_qty = [1234, 0] if minimum_order_qty else [133 + safety_stock * 100, 1000]
self.assertEqual([row["quantity"] for row in items], [117, *purchase_qty])
self.assertEqual(items[0]["from_warehouse"], source_warehouse)
self.assertEqual([row["warehouse"] for row in items], [pln.for_warehouse] * 3)
self.assertEqual([row["required_bom_qty"] for row in items], [250, 250, 1000])
self.assertEqual(
[row["sales_order"] for row in items],
[pln.po_items[0].sales_order] * 2 + [pln.po_items[1].sales_order],
)
self.assertEqual(
items, get_items_for_material_requests(pln.as_dict(), warehouses=warehouses)
)
def test_transfer_batches_keep_source_warehouses_and_requirements_separate(self):
rm_item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1, "create_new_batch": 1}).name
source_warehouses = ["_Test Warehouse 1 - _TC", "_Test Warehouse 2 - _TC"]
for warehouse, quantities in zip(source_warehouses, ((10, 20), (50, 60)), strict=True):
for qty in quantities:
make_stock_entry(item_code=rm_item, qty=qty, rate=100, target=warehouse)
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
pln.po_items[1].planned_qty = 100
pln.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(
pln.as_dict(), warehouses=[{"warehouse": warehouse} for warehouse in source_warehouses]
)
transfers = [row for row in items if row["material_request_type"] == "Material Transfer"]
self.assertEqual(len(transfers), 3)
self.assertEqual(
{(row["sales_order"], row["from_warehouse"]): row["quantity"] for row in transfers},
{
(pln.po_items[0].sales_order, source_warehouses[0]): 30,
(pln.po_items[0].sales_order, source_warehouses[1]): 20,
(pln.po_items[1].sales_order, source_warehouses[1]): 90,
},
)
purchases = [row for row in items if row["material_request_type"] == "Purchase"]
self.assertEqual(len(purchases), 1)
self.assertEqual(purchases[0]["quantity"], 10)
self.assertEqual(purchases[0]["sales_order"], pln.po_items[1].sales_order)
def test_transfer_shared_stock_uses_stock_uom(self):
rm_item = make_item(
properties={"is_stock_item": 1, "stock_uom": "Nos", "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": 10}],
).name
source_warehouse = "_Test Warehouse 1 - _TC"
make_stock_entry(item_code=rm_item, qty=60, rate=100, target=source_warehouse)
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
pln.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": source_warehouse}])
self.assertEqual(
[row["material_request_type"] for row in items],
["Material Transfer", "Material Transfer", "Purchase"],
)
self.assertEqual([row["quantity"] for row in items], [50, 10, 4])
self.assertEqual([row["uom"] for row in items], ["Nos", "Nos", "_Test UOM 1"])
self.assertEqual([row["conversion_factor"] for row in items], [1, 1, 10])
self.assertEqual(
[row["sales_order"] for row in items],
[pln.po_items[0].sales_order] + [pln.po_items[1].sales_order] * 2,
)
def test_transfer_shared_stock_rounds_away_float_residue(self):
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
_transfer_from_locations,
)
locations = [
frappe._dict(qty=0.7, warehouse="_Test Warehouse 1 - _TC"),
frappe._dict(qty=1, warehouse="_Test Warehouse 2 - _TC"),
]
transfers = []
for quantity in (0.1, 0.2, 0.4):
item = {"item_code": "Raw Material Item 1", "quantity": quantity, "conversion_factor": 1}
self.assertEqual(_transfer_from_locations(item, locations, transfers, quantity), 0)
self.assertEqual(
[(row["from_warehouse"], row["quantity"]) for row in transfers],
[("_Test Warehouse 1 - _TC", quantity) for quantity in (0.1, 0.2, 0.4)],
)
def test_purchase_uom_falls_back_to_uom_conversion_factor(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom