From 5cea05b302c2302509691cf2aa1df4937f25e57b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 7 Sep 2026 19:04:26 +0530 Subject: [PATCH] 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 --- .../services/material_request.py | 56 ++++++--- .../production_plan/test_production_plan.py | 111 ++++++++++++++++++ 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index 0b91ed1a0bc..89d320c1b68 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -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 diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 8fb7118e2b4..2ffcd77731b 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -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