Merge pull request #56948 from aerele/feat/production-plan-group-rm-warehouse

feat(manufacturing): allow group warehouse for raw material availability in production plan
This commit is contained in:
Mihir Kandoi
2026-07-09 20:52:18 +05:30
committed by GitHub
5 changed files with 245 additions and 12 deletions

View File

@@ -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 = [

View File

@@ -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",

View File

@@ -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()

View File

@@ -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
)

View File

@@ -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,
}
)