mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-11 13:41:47 +00:00
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:
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user