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.
This commit is contained in:
Sudharsanan11
2026-07-09 04:16:12 +05:30
committed by Mihir Kandoi
parent b625525b03
commit 0574a0d95e
4 changed files with 145 additions and 11 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
)
@@ -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
)