mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-17 08:28:44 +00:00
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:
committed by
Mihir Kandoi
parent
b625525b03
commit
0574a0d95e
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user