From e5e26cd92a764afba791ac2d50a1f9eb45ffd3ac Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 16 Nov 2025 15:08:08 +0530 Subject: [PATCH] feat: phantom bom (#50351) * feat: add phantom bom settings in bom doctype * feat: new explosion logic for production plan, subcontracting and work order/manufacturing * feat: modify explosion logic in reports and bom creator * fix: failing test * feat: add convert to phantom item support in bom creator * test: added test cases * fix: always fetch rm rate if phantom bom * refactor: PP phantom explosion logic * fix: report test cases * feat: add phantom item in description of item if phantom item in bom tree * fix: hide create button if bom is phantom * fix: bugs found by coderabbit --- .../controllers/subcontracting_controller.py | 25 +++++++-- .../tests/test_subcontracting_controller.py | 25 +++++++++ erpnext/manufacturing/doctype/bom/bom.js | 12 ++++- erpnext/manufacturing/doctype/bom/bom.json | 19 ++++++- erpnext/manufacturing/doctype/bom/bom.py | 54 ++++++++++++++----- .../doctype/bom/bom_item_preview.html | 5 +- erpnext/manufacturing/doctype/bom/test_bom.py | 19 ++++++- .../doctype/bom_creator/bom_creator.py | 10 +++- .../bom_creator_item/bom_creator_item.json | 19 +++++-- .../bom_creator_item/bom_creator_item.py | 1 + .../doctype/bom_item/bom_item.json | 18 +++++-- .../doctype/bom_item/bom_item.py | 1 + .../production_plan/production_plan.py | 53 ++++++++++++++---- .../production_plan/test_production_plan.py | 25 ++++++++- .../manufacturing/doctype/routing/routing.js | 10 ++++ .../doctype/work_order/test_work_order.py | 8 +++ .../report/bom_explorer/bom_explorer.py | 14 ++++- .../bom_stock_calculated.py | 46 ++++++++++++++-- .../test_bom_stock_calculated.py | 1 + .../bom_stock_report/bom_stock_report.py | 25 ++++++++- .../bom_stock_report/test_bom_stock_report.py | 3 ++ .../bom_configurator.bundle.js | 49 +++++++++++++---- 22 files changed, 383 insertions(+), 59 deletions(-) diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 850e4ad0a6c..6848a345d9b 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -550,6 +550,8 @@ class SubcontractingController(StockController): frappe.delete_doc("Serial and Batch Bundle", item.serial_and_batch_bundle, force=True) def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0): + data = [] + doctype = "BOM Item" if not exploded_item else "BOM Explosion Item" fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"] @@ -558,7 +560,7 @@ class SubcontractingController(StockController): "name": "bom_detail_no", "source_warehouse": "reserve_warehouse", } - for field in [ + fields_list = [ "item_code", "name", "rate", @@ -567,7 +569,12 @@ class SubcontractingController(StockController): "description", "item_name", "stock_uom", - ]: + ] + + if doctype == "BOM Item": + fields_list.extend(["is_phantom_item", "bom_no"]) + + for field in fields_list: fields.append(f"`tab{doctype}`.`{field}` As {alias_dict.get(field, field)}") filters = [ @@ -577,7 +584,19 @@ class SubcontractingController(StockController): [doctype, "sourced_by_supplier", "=", 0], ] - return frappe.get_all("BOM", fields=fields, filters=filters, order_by=f"`tab{doctype}`.`idx`") or [] + data = frappe.get_all("BOM", fields=fields, filters=filters, order_by=f"`tab{doctype}`.`idx`") or [] + to_remove = [] + for item in data: + if item.is_phantom_item: + data += self.__get_materials_from_bom( + item.rm_item_code, item.bom_no, exploded_item=exploded_item + ) + to_remove.append(item) + + for item in to_remove: + data.remove(item) + + return data def __update_reserve_warehouse(self, row, item): if ( diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index 0f8352d78c6..bd6afdf56a7 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -1141,6 +1141,28 @@ class TestSubcontractingController(IntegrationTestCase): itemwise_details.get(doc.items[0].item_code)["serial_no"][5:6], ) + def test_phantom_bom_explosion(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_tree_for_phantom_bom_tests + + expected = create_tree_for_phantom_bom_tests() + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 11", + "qty": 5, + "rate": 100, + "fg_item": "Top Level Parent", + "fg_item_qty": 5, + }, + ] + sco = get_subcontracting_order(service_items=service_items, do_not_submit=True) + sco.items[0].include_exploded_items = 0 + sco.save() + sco.submit() + sco.reload() + + self.assertEqual([item.rm_item_code for item in sco.supplied_items], expected) + def add_second_row_in_scr(scr): item_dict = {} @@ -1313,6 +1335,7 @@ def make_subcontracted_items(): "create_new_batch": 1, "batch_number_series": "SBAT.####", }, + "Top Level Parent": {}, } for item, properties in sub_contracted_items.items(): @@ -1364,6 +1387,7 @@ def make_service_items(): "Subcontracted Service Item 8": {}, "Subcontracted Service Item 9": {}, "Subcontracted Service Item 10": {}, + "Subcontracted Service Item 11": {}, } for item, properties in service_items.items(): @@ -1389,6 +1413,7 @@ def make_bom_for_subcontracted_items(): "Subcontracted Item SA7": ["Subcontracted SRM Item 1"], "Subcontracted Item SA8": ["Subcontracted SRM Item 8"], "Subcontracted Item SA10": ["Subcontracted SRM Item 10"], + "Subcontracted Service Item 11": ["Top Level Parent"], } for item_code, raw_materials in boms.items(): diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 5813593775d..c3740281f5a 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -45,7 +45,7 @@ frappe.ui.form.on("BOM", { return { query: "erpnext.manufacturing.doctype.bom.bom.item_query", filters: { - is_stock_item: 1, + is_stock_item: !frm.doc.is_phantom_bom, }, }; }); @@ -183,7 +183,7 @@ frappe.ui.form.on("BOM", { ); } - if (frm.doc.docstatus == 1) { + if (frm.doc.docstatus == 1 && !frm.doc.is_phantom_bom) { frm.add_custom_button( __("Work Order"), function () { @@ -529,6 +529,14 @@ frappe.ui.form.on("BOM", { frm.set_value("process_loss_qty", qty); }, + + is_phantom_bom(frm) { + frm.doc.item = ""; + frm.doc.uom = ""; + frm.doc.quantity = 1; + frm.doc.items = undefined; + frm.refresh(); + }, }); frappe.ui.form.on("BOM Operation", { diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 9b89f08d214..86ecff52c11 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -16,6 +16,7 @@ "is_default", "allow_alternative_item", "set_rate_of_sub_assembly_item_based_on_bom", + "is_phantom_bom", "project", "image", "currency_detail", @@ -201,6 +202,7 @@ }, { "collapsible": 1, + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "currency_detail", "fieldtype": "Section Break", "label": "Cost Configuration" @@ -293,6 +295,7 @@ }, { "collapsible": 1, + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "scrap_section", "fieldtype": "Tab Break", "label": "Scrap & Process Loss" @@ -310,6 +313,7 @@ "oldfieldtype": "Section Break" }, { + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "operating_cost", "fieldtype": "Currency", "label": "Operating Cost", @@ -324,6 +328,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "scrap_material_cost", "fieldtype": "Currency", "label": "Scrap Material Cost", @@ -336,6 +341,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "base_operating_cost", "fieldtype": "Currency", "label": "Operating Cost (Company Currency)", @@ -352,6 +358,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "base_scrap_material_cost", "fieldtype": "Currency", "label": "Scrap Material Cost(Company Currency)", @@ -380,6 +387,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "project", "fieldtype": "Link", "label": "Project", @@ -427,6 +435,7 @@ }, { "collapsible": 1, + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "website_section", "fieldtype": "Tab Break", "label": "Website" @@ -536,6 +545,7 @@ { "collapsible": 1, "collapsible_depends_on": "eval:doc.with_operations", + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "operations_section_section", "fieldtype": "Section Break", "label": "Operations" @@ -570,6 +580,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.is_phantom_bom", "fieldname": "quality_inspection_section_break", "fieldtype": "Section Break", "label": "Quality Inspection" @@ -659,6 +670,12 @@ "fieldtype": "Link", "label": "Default Target Warehouse", "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "is_phantom_bom", + "fieldtype": "Check", + "label": "Is Phantom BOM" } ], "icon": "fa fa-sitemap", @@ -666,7 +683,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2025-10-29 17:43:12.966753", + "modified": "2025-11-06 15:27:54.806116", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 39f0a0a4258..754a64e11bb 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -135,6 +135,7 @@ class BOM(WebsiteGenerator): inspection_required: DF.Check is_active: DF.Check is_default: DF.Check + is_phantom_bom: DF.Check item: DF.Link item_name: DF.Data | None items: DF.Table[BOMItem] @@ -447,6 +448,9 @@ class BOM(WebsiteGenerator): "uom": args["uom"] if args.get("uom") else item and args["stock_uom"] or "", "conversion_factor": args["conversion_factor"] if args.get("conversion_factor") else 1, "bom_no": args["bom_no"], + "is_phantom_item": frappe.get_value("BOM", args["bom_no"], "is_phantom_bom") + if args["bom_no"] + else 0, "rate": rate, "qty": args.get("qty") or args.get("stock_qty") or 1, "stock_qty": args.get("stock_qty") or args.get("qty") or 1, @@ -455,6 +459,9 @@ class BOM(WebsiteGenerator): "sourced_by_supplier": args.get("sourced_by_supplier", 0), } + if ret_item["is_phantom_item"]: + ret_item["do_not_explode"] = 0 + if args.get("do_not_explode"): ret_item["bom_no"] = "" @@ -481,7 +488,9 @@ class BOM(WebsiteGenerator): if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get( "sourced_by_supplier" ): - if arg.get("bom_no") and self.set_rate_of_sub_assembly_item_based_on_bom: + if arg.get("bom_no") and ( + self.set_rate_of_sub_assembly_item_based_on_bom or arg.get("is_phantom_item") + ): rate = flt(self.get_bom_unitcost(arg["bom_no"])) * (arg.get("conversion_factor") or 1) else: rate = get_bom_item_rate(arg, self) @@ -888,7 +897,7 @@ class BOM(WebsiteGenerator): for d in self.get("items"): old_rate = d.rate - if not self.bom_creator and d.is_stock_item: + if not self.bom_creator and (d.is_stock_item or d.is_phantom_item): d.rate = self.get_rm_rate( { "company": self.company, @@ -899,6 +908,7 @@ class BOM(WebsiteGenerator): "stock_uom": d.stock_uom, "conversion_factor": d.conversion_factor, "sourced_by_supplier": d.sourced_by_supplier, + "is_phantom_item": d.is_phantom_item, } ) @@ -1277,16 +1287,16 @@ def get_bom_items_as_dict( where bom_item.docstatus < 2 and bom.name = %(bom)s - and item.is_stock_item in (1, {is_stock_item}) + and (item.is_stock_item in (1, {is_stock_item}) {where_conditions} {group_by_cond} order by idx""" - is_stock_item = 0 if include_non_stock_items else 1 + is_stock_item = cint(not include_non_stock_items) if cint(fetch_exploded): query = query.format( table="BOM Explosion Item", - where_conditions="", + where_conditions=")", is_stock_item=is_stock_item, qty_field="stock_qty", group_by_cond=group_by_cond, @@ -1301,7 +1311,7 @@ def get_bom_items_as_dict( elif fetch_scrap_items: query = query.format( table="BOM Scrap Item", - where_conditions="", + where_conditions=")", select_columns=", item.description", is_stock_item=is_stock_item, qty_field="stock_qty", @@ -1312,12 +1322,12 @@ def get_bom_items_as_dict( else: query = query.format( table="BOM Item", - where_conditions="", + where_conditions="or bom_item.is_phantom_item)", is_stock_item=is_stock_item, qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", select_columns=""", bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier, - bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id """, + bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id, bom_item.is_phantom_item , bom_item.bom_no """, group_by_cond=group_by_cond, ) items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True) @@ -1327,7 +1337,24 @@ def get_bom_items_as_dict( if item.operation_row_id: key = (item.item_code, item.operation_row_id) - if key in item_dict: + if item.get("is_phantom_item"): + data = get_bom_items_as_dict( + item.get("bom_no"), + company, + qty=item.get("qty"), + fetch_exploded=fetch_exploded, + fetch_scrap_items=fetch_scrap_items, + include_non_stock_items=include_non_stock_items, + fetch_qty_in_stock_uom=fetch_qty_in_stock_uom, + ) + + for k, v in data.items(): + if item_dict.get(k): + item_dict[k]["qty"] += flt(v.qty) + else: + item_dict[k] = v + + elif key in item_dict: item_dict[key]["qty"] += flt(item.qty) else: item_dict[key] = item @@ -1379,7 +1406,7 @@ def validate_bom_no(item, bom_no): @frappe.whitelist() -def get_children(parent=None, is_root=False, **filters): +def get_children(parent=None, return_all=True, fetch_phantom_items=False, is_root=False, **filters): if not parent or parent == "BOM": frappe.msgprint(_("Please select a BOM")) return @@ -1391,10 +1418,13 @@ def get_children(parent=None, is_root=False, **filters): bom_doc = frappe.get_cached_doc("BOM", frappe.form_dict.parent) frappe.has_permission("BOM", doc=bom_doc, throw=True) + filters = [["parent", "=", frappe.form_dict.parent]] + if not return_all: + filters.append(["is_phantom_item", "=", cint(fetch_phantom_items)]) bom_items = frappe.get_all( "BOM Item", - fields=["item_code", "bom_no as value", "stock_qty", "qty"], - filters=[["parent", "=", frappe.form_dict.parent]], + fields=["item_code", "bom_no as value", "stock_qty", "qty", "is_phantom_item", "bom_no"], + filters=filters, order_by="idx", ) diff --git a/erpnext/manufacturing/doctype/bom/bom_item_preview.html b/erpnext/manufacturing/doctype/bom/bom_item_preview.html index 4cd06bbd024..06dd4365c67 100644 --- a/erpnext/manufacturing/doctype/bom/bom_item_preview.html +++ b/erpnext/manufacturing/doctype/bom/bom_item_preview.html @@ -12,7 +12,10 @@ {{ __("Description") }}
- {{ data.description }} + {% if data.is_phantom_item %} +

{{ __("Phantom Item") }}

+ {% endif %} +

{{ data.description }}


diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index a185b2d0962..000b4723e59 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -794,7 +794,7 @@ def level_order_traversal(node): return traversal -def create_nested_bom(tree, prefix="_Test bom ", submit=True): +def create_nested_bom(tree, prefix="_Test bom ", submit=True, phantom_items=None): """Helper function to create a simple nested bom from tree describing item names. (along with required items)""" def create_items(bom_tree): @@ -806,6 +806,9 @@ def create_nested_bom(tree, prefix="_Test bom ", submit=True): ).insert() create_items(subtree) + if not phantom_items: + phantom_items = [] + create_items(tree) def dfs(tree, node): @@ -824,7 +827,7 @@ def create_nested_bom(tree, prefix="_Test bom ", submit=True): child_items = dfs(tree, item) if child_items: bom_item_code = prefix + item - bom = frappe.get_doc(doctype="BOM", item=bom_item_code) + bom = frappe.get_doc(doctype="BOM", item=bom_item_code, is_phantom_bom=item in phantom_items) for child_item in child_items.keys(): bom.append("items", {"item_code": prefix + child_item}) bom.company = "_Test Company" @@ -906,3 +909,15 @@ def create_process_loss_bom_item(item_tuple): return make_item(item_code, {"stock_uom": stock_uom, "valuation_rate": 100}) else: return frappe.get_doc("Item", item_code) + + +def create_tree_for_phantom_bom_tests(): # returns expected explosion result + bom_tree_1 = { + "Top Level Parent": { + "Sub Assembly Level 1-1": {"Phantom Item Level 1-2": {"Item Level 1-3": {}}}, + "Phantom Item Level 2-1": {"Phantom Item Level 2-2": {"Item Level 2-3": {}}}, + } + } + phantom_list = ["Phantom Item Level 1-2", "Phantom Item Level 2-1", "Phantom Item Level 2-2"] + create_nested_bom(bom_tree_1, prefix="", phantom_items=phantom_list) + return ["Sub Assembly Level 1-1", "Item Level 2-3"] diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index 69954c47ecb..d67f0c3536b 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -6,7 +6,7 @@ from collections import OrderedDict import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, flt +from frappe.utils import cint, flt, sbool from erpnext.manufacturing.doctype.bom.bom import get_bom_item_rate @@ -29,6 +29,7 @@ BOM_ITEM_FIELDS = [ "conversion_factor", "do_not_explode", "operation", + "is_phantom_item", ] @@ -305,6 +306,7 @@ class BOMCreator(Document): "allow_alternative_item": 1, "bom_creator": self.name, "bom_creator_item": bom_creator_item, + "is_phantom_bom": row.get("is_phantom_item"), } ) @@ -332,7 +334,7 @@ class BOMCreator(Document): { "bom_no": bom_no, "allow_alternative_item": 1, - "allow_scrap_items": 1, + "allow_scrap_items": not item.get("is_phantom_item"), "include_item_in_manufacturing": 1, } ) @@ -456,12 +458,16 @@ def add_sub_assembly(**kwargs): "is_expandable": 1, "stock_uom": item_info.stock_uom, "operation": bom_item.operation, + "is_phantom_item": sbool(kwargs.phantom), }, ) parent_row_no = item_row.idx name = "" else: + if sbool(kwargs.phantom): + parent_row = next(item for item in doc.items if item.name == kwargs.fg_reference_id) + parent_row.db_set("is_phantom_item", 1) parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id) for row in bom_item.get("items"): diff --git a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json index baf31722838..c5b39d88735 100644 --- a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json +++ b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json @@ -15,6 +15,7 @@ "sourced_by_supplier", "bom_created", "is_subcontracted", + "is_phantom_item", "operation_section", "operation", "column_break_cbnk", @@ -159,8 +160,8 @@ "fieldname": "amount", "fieldtype": "Currency", "label": "Amount", - "read_only": 1, - "options": "currency" + "options": "currency", + "read_only": 1 }, { "fieldname": "column_break_yuca", @@ -229,6 +230,7 @@ "print_hide": 1 }, { + "depends_on": "eval:!doc.is_phantom_item", "fieldname": "operation_section", "fieldtype": "Section Break", "label": "Operation" @@ -245,22 +247,31 @@ }, { "default": "0", + "depends_on": "eval:!doc.is_phantom_item", "fieldname": "is_subcontracted", "fieldtype": "Check", "label": "Is Subcontracted", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_phantom_item", + "fieldtype": "Check", + "label": "Is Phantom Item", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-11-25 18:13:34.542391", + "modified": "2025-11-05 21:15:55.187671", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Creator Item", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py index 01f93719df4..d734cc0cda4 100644 --- a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py +++ b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py @@ -25,6 +25,7 @@ class BOMCreatorItem(Document): fg_reference_id: DF.Data | None instruction: DF.SmallText | None is_expandable: DF.Check + is_phantom_item: DF.Check is_subcontracted: DF.Check item_code: DF.Link item_group: DF.Link | None diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 1861207fd66..52e7d4da609 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -42,7 +42,8 @@ "original_item", "column_break_33", "sourced_by_supplier", - "is_sub_assembly_item" + "is_sub_assembly_item", + "is_phantom_item" ], "fields": [ { @@ -81,6 +82,7 @@ "fieldtype": "Link", "in_filter": 1, "label": "BOM No", + "mandatory_depends_on": "eval:doc.is_phantom_item", "oldfieldname": "bom_no", "oldfieldtype": "Link", "options": "BOM", @@ -278,6 +280,7 @@ }, { "default": "0", + "depends_on": "eval:!doc.is_phantom_item", "fieldname": "sourced_by_supplier", "fieldtype": "Check", "label": "Sourced by Supplier" @@ -286,7 +289,8 @@ "default": "0", "fieldname": "do_not_explode", "fieldtype": "Check", - "label": "Do Not Explode" + "label": "Do Not Explode", + "read_only_depends_on": "eval:doc.is_phantom_item" }, { "default": "0", @@ -304,18 +308,26 @@ }, { "default": "0", + "depends_on": "eval:!doc.is_phantom_item", "fieldname": "is_sub_assembly_item", "fieldtype": "Check", "label": "Is Sub Assembly Item", "no_copy": 1, "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_phantom_item", + "fieldtype": "Check", + "label": "Is Phantom Item", + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-08-12 20:01:59.532613", + "modified": "2025-11-05 19:00:38.646539", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.py b/erpnext/manufacturing/doctype/bom_item/bom_item.py index 91177bc72ef..6f58edb24b0 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.py +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.py @@ -25,6 +25,7 @@ class BOMItem(Document): has_variants: DF.Check image: DF.Attach | None include_item_in_manufacturing: DF.Check + is_phantom_item: DF.Check is_stock_item: DF.Check is_sub_assembly_item: DF.Check item_code: DF.Link diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 3de22cb72b0..b7b886ec5cc 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1354,14 +1354,19 @@ def get_subitems( item.purchase_uom, item_uom.conversion_factor, bom.item.as_("main_bom_item"), + bom_item.is_phantom_item, ) .where( (bom.name == bom_no) & (bom_item.is_sub_assembly_item == 0) & (bom_item.docstatus < 2) - & (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1) + & ( + (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1) + | (bom_item.is_phantom_item == 1) + ) ) .groupby(bom_item.item_code) + .orderby(bom_item.idx) ).run(as_dict=True) for d in items: @@ -1374,10 +1379,12 @@ def get_subitems( item_details[d.item_code] = d - if data.get("include_exploded_items") and d.default_bom: + if d.is_phantom_item or (data.get("include_exploded_items") and d.default_bom): if ( - d.default_material_request_type in ["Manufacture", "Purchase"] and not d.is_sub_contracted - ) or (d.is_sub_contracted and include_subcontracted_items): + (d.default_material_request_type in ["Manufacture", "Purchase"] and not d.is_sub_contracted) + or (d.is_sub_contracted and include_subcontracted_items) + or d.is_phantom_item + ): if d.qty > 0: get_subitems( doc, @@ -1389,7 +1396,7 @@ def get_subitems( include_subcontracted_items, d.qty, ) - return item_details + return {key: value for key, value in item_details.items() if not value.get("is_phantom_item")} def get_material_request_items( @@ -1654,6 +1661,23 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d ] += d.get("qty") sub_assembly_items = {k[:2]: v for k, v in sub_assembly_items.items()} + data = [] + for row in doc.get("po_items"): + get_sub_assembly_items( + [], + frappe._dict(), + row.get("bom_no"), + data, + row.get("planned_qty"), + doc.get("company"), + warehouse=doc.get("sub_assembly_warehouse"), + skip_available_sub_assembly_item=doc.get("skip_available_sub_assembly_item"), + fetch_phantom_items=True, + ) + + for d in data: + sub_assembly_items[(d.get("production_item"), d.get("bom_no"))] += d.get("stock_qty") + for data in po_items: if not data.get("include_exploded_items") and doc.get("sub_assembly_items"): data["include_exploded_items"] = 1 @@ -1691,7 +1715,6 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d sub_assembly_items, planned_qty=planned_qty, ) - elif data.get("include_exploded_items") and include_subcontracted_items: # fetch exploded items from BOM item_details = get_exploded_items( @@ -1721,7 +1744,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d get_uom_conversion_factor(item_master.name, purchase_uom) if item_master.purchase_uom else 1.0 ) - item_details[item_master.name] = frappe._dict( + item_details[item_master.item_code] = frappe._dict( { "item_name": item_master.item_name, "default_bom": doc.bom, @@ -1730,7 +1753,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d "min_order_qty": item_master.min_order_qty, "default_material_request_type": item_master.default_material_request_type, "qty": planned_qty or 1, - "is_sub_contracted": item_master.is_subcontracted_item, + "is_sub_contracted": item_master.is_sub_contracted_item, "item_code": item_master.name, "description": item_master.description, "stock_uom": item_master.stock_uom, @@ -1873,8 +1896,9 @@ def get_sub_assembly_items( warehouse=None, indent=0, skip_available_sub_assembly_item=False, + fetch_phantom_items=False, ): - data = get_bom_children(parent=bom_no) + data = get_bom_children(parent=bom_no, return_all=False, fetch_phantom_items=fetch_phantom_items) for d in data: if d.expandable: parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") @@ -1918,6 +1942,7 @@ def get_sub_assembly_items( "projected_qty": bin_details[d.item_code][0].get("projected_qty", 0) if bin_details.get(d.item_code) else 0, + "main_bom": bom_no, } ) ) @@ -1933,6 +1958,7 @@ def get_sub_assembly_items( warehouse, indent=indent + 1, skip_available_sub_assembly_item=skip_available_sub_assembly_item, + fetch_phantom_items=fetch_phantom_items, ) @@ -2040,6 +2066,7 @@ def get_raw_materials_of_sub_assembly_items( item.name.as_("item_code"), bei.description, bei.stock_uom, + bei.is_phantom_item, bei.bom_no, item.min_order_qty, bei.source_warehouse, @@ -2056,13 +2083,19 @@ def get_raw_materials_of_sub_assembly_items( (bei.docstatus == 1) & (bei.is_sub_assembly_item == 0) & (bom.name == bom_no) - & (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1) + & ( + (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1) + | (bei.is_phantom_item == 1) + ) ) .groupby(bei.item_code, bei.stock_uom) ) for item in query.run(as_dict=True): key = (item.item_code, item.bom_no) + if item.is_phantom_item: + sub_assembly_items[key] += item.get("qty") + if (item.bom_no and key not in sub_assembly_items) or ( (item.item_code, item.bom_no or item.main_bom) in existing_sub_assembly_items ): diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 233f8d06f75..841a1e42b22 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -2368,7 +2368,6 @@ class TestProductionPlan(IntegrationTestCase): frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) def test_production_plan_for_partial_sub_assembly_items(self): - from erpnext.controllers.status_updater import OverAllowanceError from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom frappe.flags.test_print = False @@ -2421,6 +2420,30 @@ class TestProductionPlan(IntegrationTestCase): for row in plan.sub_assembly_items: self.assertEqual(row.ordered_qty, 10.0) + def test_phantom_bom_explosion(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_tree_for_phantom_bom_tests + + create_tree_for_phantom_bom_tests() + + plan = create_production_plan( + item_code="Top Level Parent", + planned_qty=10, + use_multi_level_bom=0, + do_not_submit=True, + company="_Test Company", + skip_getting_mr_items=True, + ) + plan.get_sub_assembly_items() + plan.submit() + + plan.set("mr_items", []) + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + self.assertEqual(plan.sub_assembly_items[0].production_item, "Sub Assembly Level 1-1") + self.assertEqual([item.item_code for item in plan.mr_items], ["Item Level 1-3", "Item Level 2-3"]) + def create_production_plan(**args): """ diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index fe67fe3feb6..83b81690ec3 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -2,6 +2,16 @@ // For license information, please see license.txt frappe.ui.form.on("Routing", { + setup: function (frm) { + frm.set_query("bom_no", "operations", function () { + return { + filters: { + is_phantom_bom: 0, + }, + }; + }); + }, + refresh: function (frm) { frm.trigger("display_sequence_id_column"); }, diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index ff2d094df3e..282da4775f6 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3270,6 +3270,14 @@ class TestWorkOrder(IntegrationTestCase): ) frappe.db.set_single_value("Stock Settings", "auto_reserve_serial_and_batch", original_auto_reserve) + def test_phantom_bom_explosion(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_tree_for_phantom_bom_tests + + expected = create_tree_for_phantom_bom_tests() + + wo = make_wo_order_test_record(item="Top Level Parent") + self.assertEqual([item.item_code for item in wo.required_items], expected) + def get_reserved_entries(voucher_no, warehouse=None): doctype = frappe.qb.DocType("Stock Reservation Entry") diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index de6dec9ebb8..680cb83b312 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -21,7 +21,17 @@ def get_exploded_items(bom, data, indent=0, qty=1): exploded_items = frappe.get_all( "BOM Item", filters={"parent": bom}, - fields=["qty", "bom_no", "qty", "item_code", "item_name", "description", "uom", "idx"], + fields=[ + "qty", + "bom_no", + "qty", + "item_code", + "item_name", + "description", + "uom", + "idx", + "is_phantom_item", + ], order_by="idx ASC", ) @@ -37,6 +47,7 @@ def get_exploded_items(bom, data, indent=0, qty=1): "qty": item.qty * qty, "uom": item.uom, "description": item.description, + "is_phantom_item": item.is_phantom_item, } ) if item.bom_no: @@ -54,6 +65,7 @@ def get_columns(): }, {"label": _("Item Name"), "fieldtype": "data", "fieldname": "item_name", "width": 100}, {"label": _("BOM"), "fieldtype": "Link", "fieldname": "bom", "width": 150, "options": "BOM"}, + {"label": _("Is Phantom Item"), "fieldtype": "Check", "fieldname": "is_phantom_item"}, {"label": _("Qty"), "fieldtype": "data", "fieldname": "qty", "width": 100}, {"label": _("UOM"), "fieldtype": "data", "fieldname": "uom", "width": 100}, {"label": _("BOM Level"), "fieldtype": "Int", "fieldname": "bom_level", "width": 100}, diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py index 6bc05a468f1..4b5df4df4b2 100644 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py +++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py @@ -32,6 +32,7 @@ def get_report_data(last_purchase_rate, required_qty, row, manufacture_details): return [ row.item_code, row.description, + row.from_bom_no, comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False), comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False), qty_per_unit, @@ -57,6 +58,13 @@ def get_columns(): "fieldtype": "Data", "width": 150, }, + { + "fieldname": "from_bom_no", + "label": _("From BOM No"), + "fieldtype": "Link", + "options": "BOM", + "width": 150, + }, { "fieldname": "manufacturer", "label": _("Manufacturer"), @@ -103,10 +111,7 @@ def get_columns(): def get_bom_data(filters): - if filters.get("show_exploded_view"): - bom_item_table = "BOM Explosion Item" - else: - bom_item_table = "BOM Item" + bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" bom_item = frappe.qb.DocType(bom_item_table) bin = frappe.qb.DocType("Bin") @@ -118,11 +123,13 @@ def get_bom_data(filters): .select( bom_item.item_code, bom_item.description, + bom_item.parent.as_("from_bom_no"), bom_item.qty_consumed_per_unit.as_("qty_per_unit"), IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), ) .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) .groupby(bom_item.item_code) + .orderby(bom_item.idx) ) if filters.get("warehouse"): @@ -146,7 +153,36 @@ def get_bom_data(filters): else: query = query.where(bin.warehouse == filters.get("warehouse")) - return query.run(as_dict=True) + if bom_item_table == "BOM Item": + query = query.select(bom_item.bom_no, bom_item.is_phantom_item) + + data = query.run(as_dict=True) + return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data + + +def explode_phantom_boms(data, filters): + original_bom = filters.get("bom") + replacements = [] + + for idx, item in enumerate(data): + if not item.is_phantom_item: + continue + + filters["bom"] = item.bom_no + children = get_bom_data(filters) + filters["bom"] = original_bom + + for child in children: + child.qty_per_unit = (child.qty_per_unit or 0) * (item.qty_per_unit or 0) + + replacements.append((idx, children)) + + for idx, children in reversed(replacements): + data.pop(idx) + data[idx:idx] = children + + filters["bom"] = original_bom + return data def get_manufacturer_records(): diff --git a/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py index 8f5f768a698..6bbbacbeb77 100644 --- a/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py +++ b/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py @@ -102,6 +102,7 @@ def get_expected_data(bom, qty_to_make): [ bom.items[idx].item_code, bom.items[idx].item_code, + bom.name, "", "", float(bom.items[idx].stock_qty / bom.quantity), diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py index 5fe4d63ccbf..eeda32c64c7 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py @@ -22,8 +22,9 @@ def get_columns(): _("Item") + ":Link/Item:150", _("Item Name") + "::240", _("Description") + "::300", + _("From BOM No") + "::200", _("BOM Qty") + ":Float:160", - _("BOM UoM") + "::160", + _("BOM UOM") + "::160", _("Required Qty") + ":Float:120", _("In Stock Qty") + ":Float:120", _("Enough Parts to Build") + ":Float:200", @@ -72,6 +73,7 @@ def get_bom_stock(filters): BOM_ITEM.item_code, BOM_ITEM.item_name, BOM_ITEM.description, + BOM.name, Sum(BOM_ITEM.stock_qty), BOM_ITEM.stock_uom, (Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity, @@ -80,6 +82,25 @@ def get_bom_stock(filters): ) .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM")) .groupby(BOM_ITEM.item_code) + .orderby(BOM_ITEM.idx) ) - return QUERY.run() + if bom_item_table == "BOM Item": + QUERY = QUERY.select(BOM_ITEM.bom_no, BOM_ITEM.is_phantom_item) + + data = QUERY.run(as_list=True) + return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data + + +def explode_phantom_boms(data, filters): + expanded = [] + for row in data: + if row[-1]: # last element is `is_phantom_item` + phantom_filters = filters.copy() + phantom_filters["qty_to_produce"] = row[-5] + phantom_filters["bom"] = row[-2] + expanded.extend(get_bom_stock(phantom_filters)) + else: + expanded.append(row) + + return expanded diff --git a/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py index 860ba3f57f7..2bcb43b409f 100644 --- a/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py @@ -96,6 +96,7 @@ def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False): item.item_code, item.item_name, item.description, + bom.name, item.stock_qty, item.stock_uom, item.stock_qty * qty_to_produce / bom.quantity, @@ -103,6 +104,8 @@ def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False): floor(in_stock_qty / (item.stock_qty * qty_to_produce / bom.quantity)) if in_stock_qty else None, + item.bom_no, + item.is_phantom_item, ] ) diff --git a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js index facbdf15482..49eee62e14d 100644 --- a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js +++ b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js @@ -140,6 +140,17 @@ class BOMConfigurator { }, btnClass: "hidden-xs", }, + { + label: __(frappe.utils.icon("add", "sm") + " Phantom Item"), + click: function (node) { + let view = frappe.views.trees["BOM Configurator"]; + view.events.add_sub_assembly(node, view, true); + }, + condition: function (node) { + return node.expandable; + }, + btnClass: "hidden-xs", + }, { label: __("Collapse All"), click: function (node) { @@ -170,6 +181,17 @@ class BOMConfigurator { }, btnClass: "hidden-xs", }, + { + label: __(frappe.utils.icon("move", "sm") + " Phantom Item"), + click: function (node) { + let view = frappe.views.trees["BOM Configurator"]; + view.events.convert_to_sub_assembly(node, view, true); + }, + condition: function (node) { + return !node.expandable; + }, + btnClass: "hidden-xs", + }, { label: __(frappe.utils.icon("delete", "sm") + " Item"), click: function (node) { @@ -253,10 +275,10 @@ class BOMConfigurator { } } - add_sub_assembly(node, view) { + add_sub_assembly(node, view, phantom = false) { let dialog = new frappe.ui.Dialog({ - fields: view.events.get_sub_assembly_modal_fields(view, node.is_root), - title: __("Add Sub Assembly"), + fields: view.events.get_sub_assembly_modal_fields(view, node.is_root, false, phantom), + title: phantom ? __("Add Phantom Item") : __("Add Sub Assembly"), }); view.events.set_query_for_workstation(dialog); @@ -282,6 +304,7 @@ class BOMConfigurator { operation: node.data.operation, workstation_type: node.data.workstation_type, operation_time: node.data.operation_time, + phantom: phantom, }, callback: (r) => { view.events.load_tree(r, node); @@ -292,15 +315,18 @@ class BOMConfigurator { }); } - get_sub_assembly_modal_fields(view, is_root = false, read_only = false) { + get_sub_assembly_modal_fields(view, is_root = false, read_only = false, phantom = false) { let fields = [ { - label: __("Sub Assembly Item"), + label: phantom ? __("Phantom Item") : __("Sub Assembly Item"), fieldname: "item_code", fieldtype: "Link", options: "Item", reqd: 1, read_only: read_only, + filters: { + is_stock_item: !phantom, + }, }, { fieldtype: "Column Break" }, { @@ -320,7 +346,7 @@ class BOMConfigurator { }, ]; - if (is_root) { + if (is_root && !phantom) { fields.push( ...[ { fieldtype: "Section Break" }, @@ -384,10 +410,10 @@ class BOMConfigurator { return fields; } - convert_to_sub_assembly(node, view) { + convert_to_sub_assembly(node, view, phantom = false) { let dialog = new frappe.ui.Dialog({ - fields: view.events.get_sub_assembly_modal_fields(view, node.is_root, true), - title: __("Add Sub Assembly"), + fields: view.events.get_sub_assembly_modal_fields(view, node.is_root, true, phantom), + title: phantom ? __("Add Phantom Item") : __("Add Sub Assembly"), }); dialog.set_values({ @@ -400,7 +426,9 @@ class BOMConfigurator { let bom_item = dialog.get_values(); if (!bom_item.item_code) { - frappe.throw(__("Sub Assembly Item is mandatory")); + frappe.throw( + phantom ? __("Phantom Item is mandatory") : __("Sub Assembly Item is mandatory") + ); } bom_item.items.forEach((d) => { @@ -425,6 +453,7 @@ class BOMConfigurator { workstation_type: node.data.workstation_type, operation_time: node.data.operation_time, workstation: node.data.workstation, + phantom: phantom, }, callback: (r) => { node.expandable = true;