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