From b625525b038805a3a41a16113dd00a065d2f4708 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 7 Jul 2026 16:57:28 +0530 Subject: [PATCH 1/3] fix(manufacturing): accept plain dict for doc in get_items_for_material_requests the whitelisted endpoint typed doc as str | frappe._dict | Document, so a json request body (a plain dict) failed pydantic type validation. widen to str | dict | Document, matching the convention used elsewhere in erpnext. --- .../doctype/production_plan/services/material_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index c4f12d8f1b7..21b21aabff1 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -132,7 +132,7 @@ 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, ): From 0574a0d95e7cd73c297126e9c4ae3b4ae12820e3 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 9 Jul 2026 04:16:12 +0530 Subject: [PATCH 2/3] feat(manufacturing): allow group warehouse for raw material availability in production plan add an optional raw material group warehouse on production plan. when set, raw material availability is checked across its child warehouses (bin rows aggregated), while material is still received into for warehouse. for warehouse is restricted to a child of the group and required when raw materials are fetched; a group warehouse can never reach a material request line. when the group is left blank, availability falls back to for warehouse and the previous flow. --- .../production_plan/production_plan.js | 35 +++++++++- .../production_plan/production_plan.json | 10 ++- .../production_plan/production_plan.py | 46 +++++++++++++ .../services/material_request.py | 65 +++++++++++++++++-- 4 files changed, 145 insertions(+), 11 deletions(-) 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 ) From 2247ef9a500dcf46550eae8a0bacc95d97c5f9b0 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 9 Jul 2026 04:16:30 +0530 Subject: [PATCH 3/3] test(manufacturing): add production plan group warehouse tests verify child-stock aggregation, transfer sourcing from child warehouses, that a for warehouse outside the group is rejected, and that a for warehouse is required when raw materials are fetched. --- .../production_plan/test_production_plan.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) 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, } )