diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 2337b8d0246..3bef5d30712 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -40,10 +40,29 @@ frappe.ui.form.on("Production Plan", { }); frm.set_query("for_warehouse", function (doc) { + // when a group is chosen, For Warehouse must be one of its child warehouses + if (doc.raw_material_group_warehouse) { + return { + query: "erpnext.manufacturing.doctype.production_plan.production_plan.get_child_warehouses", + filters: { + group_warehouse: doc.raw_material_group_warehouse, + company: doc.company, + }, + }; + } + return { + filters: [ + ["Warehouse", "company", "=", doc.company], + ["Warehouse", "is_group", "=", 0], + ], + }; + }); + + frm.set_query("raw_material_group_warehouse", function (doc) { return { filters: { company: doc.company, - is_group: 0, + is_group: 1, }, }; }); @@ -102,6 +121,13 @@ frappe.ui.form.on("Production Plan", { }); }, + raw_material_group_warehouse(frm) { + // For Warehouse must sit inside the chosen group, so drop a stale selection + if (frm.doc.for_warehouse) { + frm.set_value("for_warehouse", null); + } + }, + refresh(frm) { if (frm.doc.docstatus === 1) { frm.trigger("show_progress"); @@ -451,6 +477,7 @@ frappe.ui.form.on("Production Plan", { frm.events.get_items_for_material_requests(frm); } else { const title = __("Transfer Materials For Warehouse {0}", [frm.doc.for_warehouse]); + const source_warehouse = frm.doc.raw_material_group_warehouse; var dialog = new frappe.ui.Dialog({ title: title, fields: [ @@ -459,6 +486,7 @@ frappe.ui.form.on("Production Plan", { fieldtype: "Table MultiSelect", fieldname: "warehouses", options: "Production Plan Material Request Warehouse", + default: source_warehouse ? [{ warehouse: source_warehouse }] : [], get_query: function () { return { filters: { @@ -515,8 +543,9 @@ frappe.ui.form.on("Production Plan", { download_materials_required(frm) { const warehouses_data = []; - if (frm.doc.for_warehouse) { - warehouses_data.push({ warehouse: frm.doc.for_warehouse }); + const availability_warehouse = frm.doc.raw_material_group_warehouse || frm.doc.for_warehouse; + if (availability_warehouse) { + warehouses_data.push({ warehouse: availability_warehouse }); } const fields = [ diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 32a67eae228..d7e5c48de1c 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -52,6 +52,7 @@ "include_safety_stock", "ignore_existing_ordered_qty", "column_break_25", + "raw_material_group_warehouse", "for_warehouse", "get_items_for_mr", "transfer_materials", @@ -318,6 +319,13 @@ "label": "For Warehouse", "options": "Warehouse" }, + { + "description": "Optional group warehouse. Raw material availability is checked across its child warehouses; material is still received into For Warehouse.", + "fieldname": "raw_material_group_warehouse", + "fieldtype": "Link", + "label": "Raw Material Group Warehouse", + "options": "Warehouse" + }, { "fieldname": "warehouses", "fieldtype": "Table MultiSelect", @@ -445,7 +453,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-08-12 19:48:09.302503", + "modified": "2026-07-07 00:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index be57e1108b8..64c1f81447b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -102,6 +102,7 @@ class ProductionPlan(Document): posting_date: DF.Date prod_plan_references: DF.Table[ProductionPlanItemReference] project: DF.Link | None + raw_material_group_warehouse: DF.Link | None reserve_stock: DF.Check sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"] sales_orders: DF.Table[ProductionPlanSalesOrder] @@ -144,8 +145,30 @@ class ProductionPlan(Document): validate_uom_is_integer(self, "stock_uom", "planned_qty") self.validate_sales_orders() self.validate_material_request_type() + self.validate_raw_material_group_warehouse() self.enable_auto_reserve_stock() + def validate_raw_material_group_warehouse(self): + if not self.raw_material_group_warehouse: + return + + group = frappe.db.get_value( + "Warehouse", self.raw_material_group_warehouse, ["lft", "rgt", "is_group"], as_dict=True + ) + if not group.is_group: + frappe.throw( + _("{0} must be a group warehouse.").format(frappe.bold(_("Raw Material Group Warehouse"))) + ) + + if self.for_warehouse: + child = frappe.db.get_value("Warehouse", self.for_warehouse, ["lft", "rgt"], as_dict=True) + if not (group.lft <= child.lft and child.rgt <= group.rgt): + frappe.throw( + _("For Warehouse {0} must be a child of the group warehouse {1}.").format( + frappe.bold(self.for_warehouse), frappe.bold(self.raw_material_group_warehouse) + ) + ) + def enable_auto_reserve_stock(self): if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"): self.reserve_stock = 1 @@ -466,3 +489,26 @@ class ProductionPlan(Document): def all_items_completed(self): return SubAssemblyService(self).all_items_completed() + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_child_warehouses( + doctype: str | None, txt: str, searchfield: str | None, start: int, page_len: int, filters: dict +): + "Leaf warehouses under the given group warehouse, for the For Warehouse link query." + bounds = frappe.db.get_value("Warehouse", filters.get("group_warehouse"), ["lft", "rgt"], as_dict=True) + if not bounds: + return [] + + wh = frappe.qb.DocType("Warehouse") + query = ( + frappe.qb.from_(wh) + .select(wh.name) + .where((wh.is_group == 0) & (wh.lft >= bounds.lft) & (wh.rgt <= bounds.rgt)) + ) + if filters.get("company"): + query = query.where(wh.company == filters.get("company")) + if txt: + query = query.where(wh[searchfield].like(f"%{txt}%")) + return query.limit(page_len).offset(start).run() diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index c4f12d8f1b7..fc2ad13df73 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -97,6 +97,13 @@ class MaterialRequestService: def _material_request_item(self, item, material_request_type, schedule_date): from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None + # a group warehouse cannot receive stock; it must never reach a Material Request line + if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"): + frappe.throw( + _("Cannot create Material Request for item {0} in group warehouse {1}.").format( + frappe.bold(item.item_code), frappe.bold(item.warehouse) + ) + ) project = ( frappe.db.get_value("Sales Order", item.sales_order, "project") if item.sales_order else None ) @@ -132,13 +139,14 @@ class MaterialRequestService: @frappe.whitelist() def get_items_for_material_requests( - doc: str | frappe._dict | Document, + doc: str | dict | Document, warehouses: str | list | None = None, get_parent_warehouse_data: bool | int | None = None, ): frappe.has_permission("Production Plan", "read", throw=True) doc = _normalize_mr_doc(doc) + _validate_group_warehouse_target(doc) warehouses = _filter_warehouses(doc, warehouses, get_parent_warehouse_data) doc["mr_items"] = [] @@ -163,6 +171,17 @@ def _normalize_mr_doc(doc): return doc +def _validate_group_warehouse_target(doc): + # the group only scopes availability; raw materials still need a concrete + # receiving warehouse, so for_warehouse is required once we generate items. + if doc.get("raw_material_group_warehouse") and not doc.get("for_warehouse"): + frappe.throw( + _("{0} is required to get raw materials when {1} is set.").format( + frappe.bold(_("For Warehouse")), frappe.bold(_("Raw Material Group Warehouse")) + ) + ) + + def _filter_warehouses(doc, warehouses, get_parent_warehouse_data): if not warehouses: return warehouses @@ -355,13 +374,18 @@ def _accumulate_so_items(so_item_details, sales_order, item_details, qty_precisi def _build_mr_items(doc, so_item_details, ignore_ordered_qty): mr_items = [] consumed_qty = defaultdict(float) - warehouse = doc.get("for_warehouse") + # raw_material_group_warehouse (optional, group) only widens the availability + # scope to its child warehouses; material is still received into for_warehouse. + target_warehouse = doc.get("for_warehouse") + scope_warehouse = doc.get("raw_material_group_warehouse") or target_warehouse company = doc.get("company") include_safety_stock = doc.get("include_safety_stock") for sales_order, item_dict in so_item_details.items(): for details in item_dict.values(): - warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse") + fallback = details.get("source_warehouse") or details.get("default_warehouse") + scope_warehouse = scope_warehouse or fallback + target_warehouse = target_warehouse or fallback row = _mr_item_for_details( doc, details, @@ -369,7 +393,8 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty): company, ignore_ordered_qty, include_safety_stock, - warehouse, + scope_warehouse, + target_warehouse, consumed_qty, ) if row: @@ -378,10 +403,19 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty): def _mr_item_for_details( - doc, details, sales_order, company, ignore_ordered_qty, include_safety_stock, warehouse, consumed_qty + doc, + details, + sales_order, + company, + ignore_ordered_qty, + include_safety_stock, + warehouse, + target_warehouse, + consumed_qty, ): - bin_dict = get_bin_details(details, doc.company, warehouse) - bin_dict = bin_dict[0] if bin_dict else {} + # get_bin_details scopes to the warehouse's descendants, returning one row per + # child warehouse; sum them so a group warehouse reflects combined child stock. + bin_dict = _aggregate_bin_details(get_bin_details(details, doc.company, warehouse)) if details.qty <= 0: return None return get_material_request_items( @@ -392,11 +426,27 @@ def _mr_item_for_details( ignore_ordered_qty, include_safety_stock, warehouse, + target_warehouse, bin_dict, consumed_qty, ) +def _aggregate_bin_details(bin_list): + qty_fields = ( + "projected_qty", + "actual_qty", + "ordered_qty", + "reserved_qty_for_production", + "planned_qty", + ) + aggregated = {field: 0 for field in qty_fields} + for row in bin_list or []: + for field in qty_fields: + aggregated[field] += flt(row.get(field)) + return aggregated + + def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data): if not ((ignore_ordered_qty or get_parent_warehouse_data) and warehouses): return mr_items @@ -428,6 +478,7 @@ def get_material_request_items( ignore_existing_ordered_qty, include_safety_stock, warehouse, + target_warehouse, bin_dict, consumed_qty, ): @@ -438,7 +489,7 @@ def get_material_request_items( item_group_defaults = get_item_group_defaults(row.item_code, company) conversion_factor = _mr_purchase_conversion_factor(row) return _material_request_item_row( - row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults + row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults ) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index ac2b38ea216..e63bc8a2b09 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1592,6 +1592,104 @@ class TestProductionPlan(ERPNextTestSuite): for row in plan.mr_items: self.assertFalse(row.from_warehouse) + def _setup_group_rm_warehouse(self): + """FG + RM with a group raw-material warehouse (C1, C2) partially stocked (3 + 4).""" + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + group_warehouse = "_Test Warehouse Group - _TC" + child_1 = "_Test Warehouse Group-C1 - _TC" + child_2 = "_Test Warehouse Group-C2 - _TC" + + fg_item = "Test PP Group FG" + rm_item = "Test PP Group RM" + create_item(rm_item, valuation_rate=100) + create_item(fg_item, valuation_rate=100) + if not frappe.db.get_value("BOM", {"item": fg_item, "is_active": 1}): + create_nested_bom({fg_item: {rm_item: {}}}, prefix="") + + make_stock_entry(item_code=rm_item, qty=3, rate=100, target=child_1) + make_stock_entry(item_code=rm_item, qty=4, rate=100, target=child_2) + + return frappe._dict( + group_warehouse=group_warehouse, + children={child_1, child_2}, + for_wh=child_1, # a leaf inside the group, used as For Warehouse + fg_item=fg_item, + rm_item=rm_item, + ) + + def test_group_raw_material_warehouse_aggregates_child_stock(self): + "Combined child stock (3 + 4) is used as projected qty; material targets For Warehouse." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + for_warehouse=data.for_wh, + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + mr_items = get_items_for_material_requests(plan.as_dict()) + + rm_rows = [d for d in mr_items if d.get("item_code") == data.rm_item] + self.assertEqual(len(rm_rows), 1) + # projected qty reflects the sum across both child warehouses, not a single child + self.assertEqual(flt(rm_rows[0].get("projected_qty")), 7.0) + # the group is only an availability scope; the row targets For Warehouse + self.assertEqual(rm_rows[0].get("warehouse"), data.for_wh) + + def test_group_raw_material_warehouse_transfers_from_child_warehouses(self): + "Material is transferred only from actual child warehouses, never the group node." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + ignore_existing_ordered_qty=1, + for_warehouse=data.for_wh, + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + mr_items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": data.group_warehouse}] + ) + + transfer_rows = [d for d in mr_items if d.get("material_request_type") == "Material Transfer"] + self.assertTrue(transfer_rows) + for row in transfer_rows: + self.assertIn(row.get("from_warehouse"), data.children) + for row in mr_items: + # a group warehouse must never be a Material Request target + self.assertNotEqual(row.get("warehouse"), data.group_warehouse) + + def test_for_warehouse_must_be_child_of_group(self): + "A For Warehouse outside the chosen group warehouse is rejected on save." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + for_warehouse="_Test Warehouse - _TC", # outside the group + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + self.assertRaises(frappe.ValidationError, plan.save) + + def test_for_warehouse_required_with_group_when_getting_raw_materials(self): + "A group warehouse without a For Warehouse is rejected when raw materials are fetched." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + raw_material_group_warehouse=data.group_warehouse, + skip_getting_mr_items=1, + ) + self.assertRaises(frappe.ValidationError, get_items_for_material_requests, plan.as_dict()) + def test_skip_available_qty_for_sub_assembly_items(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom @@ -3122,6 +3220,7 @@ def create_production_plan(**args): "sub_assembly_warehouse": args.sub_assembly_warehouse, "reserve_stock": args.reserve_stock or 0, "for_warehouse": args.for_warehouse or None, + "raw_material_group_warehouse": args.raw_material_group_warehouse or None, } )