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 21b21aabff1..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 ) @@ -139,6 +146,7 @@ def get_items_for_material_requests( 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 )