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
This commit is contained in:
Mihir Kandoi
2025-11-16 15:08:08 +05:30
committed by GitHub
parent 9b303a2272
commit e5e26cd92a
22 changed files with 383 additions and 59 deletions

View File

@@ -550,6 +550,8 @@ class SubcontractingController(StockController):
frappe.delete_doc("Serial and Batch Bundle", item.serial_and_batch_bundle, force=True) 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): 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" doctype = "BOM Item" if not exploded_item else "BOM Explosion Item"
fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"] fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"]
@@ -558,7 +560,7 @@ class SubcontractingController(StockController):
"name": "bom_detail_no", "name": "bom_detail_no",
"source_warehouse": "reserve_warehouse", "source_warehouse": "reserve_warehouse",
} }
for field in [ fields_list = [
"item_code", "item_code",
"name", "name",
"rate", "rate",
@@ -567,7 +569,12 @@ class SubcontractingController(StockController):
"description", "description",
"item_name", "item_name",
"stock_uom", "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)}") fields.append(f"`tab{doctype}`.`{field}` As {alias_dict.get(field, field)}")
filters = [ filters = [
@@ -577,7 +584,19 @@ class SubcontractingController(StockController):
[doctype, "sourced_by_supplier", "=", 0], [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): def __update_reserve_warehouse(self, row, item):
if ( if (

View File

@@ -1141,6 +1141,28 @@ class TestSubcontractingController(IntegrationTestCase):
itemwise_details.get(doc.items[0].item_code)["serial_no"][5:6], 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): def add_second_row_in_scr(scr):
item_dict = {} item_dict = {}
@@ -1313,6 +1335,7 @@ def make_subcontracted_items():
"create_new_batch": 1, "create_new_batch": 1,
"batch_number_series": "SBAT.####", "batch_number_series": "SBAT.####",
}, },
"Top Level Parent": {},
} }
for item, properties in sub_contracted_items.items(): for item, properties in sub_contracted_items.items():
@@ -1364,6 +1387,7 @@ def make_service_items():
"Subcontracted Service Item 8": {}, "Subcontracted Service Item 8": {},
"Subcontracted Service Item 9": {}, "Subcontracted Service Item 9": {},
"Subcontracted Service Item 10": {}, "Subcontracted Service Item 10": {},
"Subcontracted Service Item 11": {},
} }
for item, properties in service_items.items(): 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 SA7": ["Subcontracted SRM Item 1"],
"Subcontracted Item SA8": ["Subcontracted SRM Item 8"], "Subcontracted Item SA8": ["Subcontracted SRM Item 8"],
"Subcontracted Item SA10": ["Subcontracted SRM Item 10"], "Subcontracted Item SA10": ["Subcontracted SRM Item 10"],
"Subcontracted Service Item 11": ["Top Level Parent"],
} }
for item_code, raw_materials in boms.items(): for item_code, raw_materials in boms.items():

View File

@@ -45,7 +45,7 @@ frappe.ui.form.on("BOM", {
return { return {
query: "erpnext.manufacturing.doctype.bom.bom.item_query", query: "erpnext.manufacturing.doctype.bom.bom.item_query",
filters: { 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( frm.add_custom_button(
__("Work Order"), __("Work Order"),
function () { function () {
@@ -529,6 +529,14 @@ frappe.ui.form.on("BOM", {
frm.set_value("process_loss_qty", qty); 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", { frappe.ui.form.on("BOM Operation", {

View File

@@ -16,6 +16,7 @@
"is_default", "is_default",
"allow_alternative_item", "allow_alternative_item",
"set_rate_of_sub_assembly_item_based_on_bom", "set_rate_of_sub_assembly_item_based_on_bom",
"is_phantom_bom",
"project", "project",
"image", "image",
"currency_detail", "currency_detail",
@@ -201,6 +202,7 @@
}, },
{ {
"collapsible": 1, "collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "currency_detail", "fieldname": "currency_detail",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Cost Configuration" "label": "Cost Configuration"
@@ -293,6 +295,7 @@
}, },
{ {
"collapsible": 1, "collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_section", "fieldname": "scrap_section",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Scrap & Process Loss" "label": "Scrap & Process Loss"
@@ -310,6 +313,7 @@
"oldfieldtype": "Section Break" "oldfieldtype": "Section Break"
}, },
{ {
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "operating_cost", "fieldname": "operating_cost",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Operating Cost", "label": "Operating Cost",
@@ -324,6 +328,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_material_cost", "fieldname": "scrap_material_cost",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Scrap Material Cost", "label": "Scrap Material Cost",
@@ -336,6 +341,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "base_operating_cost", "fieldname": "base_operating_cost",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Operating Cost (Company Currency)", "label": "Operating Cost (Company Currency)",
@@ -352,6 +358,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "base_scrap_material_cost", "fieldname": "base_scrap_material_cost",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Scrap Material Cost(Company Currency)", "label": "Scrap Material Cost(Company Currency)",
@@ -380,6 +387,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "project", "fieldname": "project",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Project", "label": "Project",
@@ -427,6 +435,7 @@
}, },
{ {
"collapsible": 1, "collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "website_section", "fieldname": "website_section",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Website" "label": "Website"
@@ -536,6 +545,7 @@
{ {
"collapsible": 1, "collapsible": 1,
"collapsible_depends_on": "eval:doc.with_operations", "collapsible_depends_on": "eval:doc.with_operations",
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "operations_section_section", "fieldname": "operations_section_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Operations" "label": "Operations"
@@ -570,6 +580,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "quality_inspection_section_break", "fieldname": "quality_inspection_section_break",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Quality Inspection" "label": "Quality Inspection"
@@ -659,6 +670,12 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Default Target Warehouse", "label": "Default Target Warehouse",
"options": "Warehouse" "options": "Warehouse"
},
{
"default": "0",
"fieldname": "is_phantom_bom",
"fieldtype": "Check",
"label": "Is Phantom BOM"
} }
], ],
"icon": "fa fa-sitemap", "icon": "fa fa-sitemap",
@@ -666,7 +683,7 @@
"image_field": "image", "image_field": "image",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-10-29 17:43:12.966753", "modified": "2025-11-06 15:27:54.806116",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM", "name": "BOM",

View File

@@ -135,6 +135,7 @@ class BOM(WebsiteGenerator):
inspection_required: DF.Check inspection_required: DF.Check
is_active: DF.Check is_active: DF.Check
is_default: DF.Check is_default: DF.Check
is_phantom_bom: DF.Check
item: DF.Link item: DF.Link
item_name: DF.Data | None item_name: DF.Data | None
items: DF.Table[BOMItem] 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 "", "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, "conversion_factor": args["conversion_factor"] if args.get("conversion_factor") else 1,
"bom_no": args["bom_no"], "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, "rate": rate,
"qty": args.get("qty") or args.get("stock_qty") or 1, "qty": args.get("qty") or args.get("stock_qty") or 1,
"stock_qty": args.get("stock_qty") or args.get("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), "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"): if args.get("do_not_explode"):
ret_item["bom_no"] = "" 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( if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get(
"sourced_by_supplier" "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) rate = flt(self.get_bom_unitcost(arg["bom_no"])) * (arg.get("conversion_factor") or 1)
else: else:
rate = get_bom_item_rate(arg, self) rate = get_bom_item_rate(arg, self)
@@ -888,7 +897,7 @@ class BOM(WebsiteGenerator):
for d in self.get("items"): for d in self.get("items"):
old_rate = d.rate 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( d.rate = self.get_rm_rate(
{ {
"company": self.company, "company": self.company,
@@ -899,6 +908,7 @@ class BOM(WebsiteGenerator):
"stock_uom": d.stock_uom, "stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor, "conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier, "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 where
bom_item.docstatus < 2 bom_item.docstatus < 2
and bom.name = %(bom)s 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} {where_conditions}
{group_by_cond} {group_by_cond}
order by idx""" 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): if cint(fetch_exploded):
query = query.format( query = query.format(
table="BOM Explosion Item", table="BOM Explosion Item",
where_conditions="", where_conditions=")",
is_stock_item=is_stock_item, is_stock_item=is_stock_item,
qty_field="stock_qty", qty_field="stock_qty",
group_by_cond=group_by_cond, group_by_cond=group_by_cond,
@@ -1301,7 +1311,7 @@ def get_bom_items_as_dict(
elif fetch_scrap_items: elif fetch_scrap_items:
query = query.format( query = query.format(
table="BOM Scrap Item", table="BOM Scrap Item",
where_conditions="", where_conditions=")",
select_columns=", item.description", select_columns=", item.description",
is_stock_item=is_stock_item, is_stock_item=is_stock_item,
qty_field="stock_qty", qty_field="stock_qty",
@@ -1312,12 +1322,12 @@ def get_bom_items_as_dict(
else: else:
query = query.format( query = query.format(
table="BOM Item", table="BOM Item",
where_conditions="", where_conditions="or bom_item.is_phantom_item)",
is_stock_item=is_stock_item, is_stock_item=is_stock_item,
qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", 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, 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.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, group_by_cond=group_by_cond,
) )
items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True) 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: if item.operation_row_id:
key = (item.item_code, 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) item_dict[key]["qty"] += flt(item.qty)
else: else:
item_dict[key] = item item_dict[key] = item
@@ -1379,7 +1406,7 @@ def validate_bom_no(item, bom_no):
@frappe.whitelist() @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": if not parent or parent == "BOM":
frappe.msgprint(_("Please select a BOM")) frappe.msgprint(_("Please select a BOM"))
return 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) bom_doc = frappe.get_cached_doc("BOM", frappe.form_dict.parent)
frappe.has_permission("BOM", doc=bom_doc, throw=True) 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_items = frappe.get_all(
"BOM Item", "BOM Item",
fields=["item_code", "bom_no as value", "stock_qty", "qty"], fields=["item_code", "bom_no as value", "stock_qty", "qty", "is_phantom_item", "bom_no"],
filters=[["parent", "=", frappe.form_dict.parent]], filters=filters,
order_by="idx", order_by="idx",
) )

View File

@@ -12,7 +12,10 @@
{{ __("Description") }} {{ __("Description") }}
</h4> </h4>
<div style="padding-top: 10px;"> <div style="padding-top: 10px;">
{{ data.description }} {% if data.is_phantom_item %}
<p><b>{{ __("Phantom Item") }}</b></p>
{% endif %}
<p>{{ data.description }}</p>
</div> </div>
<hr style="margin: 15px -15px;"> <hr style="margin: 15px -15px;">
<p> <p>

View File

@@ -794,7 +794,7 @@ def level_order_traversal(node):
return traversal 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)""" """Helper function to create a simple nested bom from tree describing item names. (along with required items)"""
def create_items(bom_tree): def create_items(bom_tree):
@@ -806,6 +806,9 @@ def create_nested_bom(tree, prefix="_Test bom ", submit=True):
).insert() ).insert()
create_items(subtree) create_items(subtree)
if not phantom_items:
phantom_items = []
create_items(tree) create_items(tree)
def dfs(tree, node): def dfs(tree, node):
@@ -824,7 +827,7 @@ def create_nested_bom(tree, prefix="_Test bom ", submit=True):
child_items = dfs(tree, item) child_items = dfs(tree, item)
if child_items: if child_items:
bom_item_code = prefix + item 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(): for child_item in child_items.keys():
bom.append("items", {"item_code": prefix + child_item}) bom.append("items", {"item_code": prefix + child_item})
bom.company = "_Test Company" 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}) return make_item(item_code, {"stock_uom": stock_uom, "valuation_rate": 100})
else: else:
return frappe.get_doc("Item", item_code) 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"]

View File

@@ -6,7 +6,7 @@ from collections import OrderedDict
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document 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 from erpnext.manufacturing.doctype.bom.bom import get_bom_item_rate
@@ -29,6 +29,7 @@ BOM_ITEM_FIELDS = [
"conversion_factor", "conversion_factor",
"do_not_explode", "do_not_explode",
"operation", "operation",
"is_phantom_item",
] ]
@@ -305,6 +306,7 @@ class BOMCreator(Document):
"allow_alternative_item": 1, "allow_alternative_item": 1,
"bom_creator": self.name, "bom_creator": self.name,
"bom_creator_item": bom_creator_item, "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, "bom_no": bom_no,
"allow_alternative_item": 1, "allow_alternative_item": 1,
"allow_scrap_items": 1, "allow_scrap_items": not item.get("is_phantom_item"),
"include_item_in_manufacturing": 1, "include_item_in_manufacturing": 1,
} }
) )
@@ -456,12 +458,16 @@ def add_sub_assembly(**kwargs):
"is_expandable": 1, "is_expandable": 1,
"stock_uom": item_info.stock_uom, "stock_uom": item_info.stock_uom,
"operation": bom_item.operation, "operation": bom_item.operation,
"is_phantom_item": sbool(kwargs.phantom),
}, },
) )
parent_row_no = item_row.idx parent_row_no = item_row.idx
name = "" name = ""
else: 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) parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
for row in bom_item.get("items"): for row in bom_item.get("items"):

View File

@@ -15,6 +15,7 @@
"sourced_by_supplier", "sourced_by_supplier",
"bom_created", "bom_created",
"is_subcontracted", "is_subcontracted",
"is_phantom_item",
"operation_section", "operation_section",
"operation", "operation",
"column_break_cbnk", "column_break_cbnk",
@@ -159,8 +160,8 @@
"fieldname": "amount", "fieldname": "amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Amount", "label": "Amount",
"read_only": 1, "options": "currency",
"options": "currency" "read_only": 1
}, },
{ {
"fieldname": "column_break_yuca", "fieldname": "column_break_yuca",
@@ -229,6 +230,7 @@
"print_hide": 1 "print_hide": 1
}, },
{ {
"depends_on": "eval:!doc.is_phantom_item",
"fieldname": "operation_section", "fieldname": "operation_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Operation" "label": "Operation"
@@ -245,22 +247,31 @@
}, },
{ {
"default": "0", "default": "0",
"depends_on": "eval:!doc.is_phantom_item",
"fieldname": "is_subcontracted", "fieldname": "is_subcontracted",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Is Subcontracted", "label": "Is Subcontracted",
"read_only": 1 "read_only": 1
},
{
"default": "0",
"fieldname": "is_phantom_item",
"fieldtype": "Check",
"label": "Is Phantom Item",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-11-25 18:13:34.542391", "modified": "2025-11-05 21:15:55.187671",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Creator Item", "name": "BOM Creator Item",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@@ -25,6 +25,7 @@ class BOMCreatorItem(Document):
fg_reference_id: DF.Data | None fg_reference_id: DF.Data | None
instruction: DF.SmallText | None instruction: DF.SmallText | None
is_expandable: DF.Check is_expandable: DF.Check
is_phantom_item: DF.Check
is_subcontracted: DF.Check is_subcontracted: DF.Check
item_code: DF.Link item_code: DF.Link
item_group: DF.Link | None item_group: DF.Link | None

View File

@@ -42,7 +42,8 @@
"original_item", "original_item",
"column_break_33", "column_break_33",
"sourced_by_supplier", "sourced_by_supplier",
"is_sub_assembly_item" "is_sub_assembly_item",
"is_phantom_item"
], ],
"fields": [ "fields": [
{ {
@@ -81,6 +82,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"in_filter": 1, "in_filter": 1,
"label": "BOM No", "label": "BOM No",
"mandatory_depends_on": "eval:doc.is_phantom_item",
"oldfieldname": "bom_no", "oldfieldname": "bom_no",
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "BOM", "options": "BOM",
@@ -278,6 +280,7 @@
}, },
{ {
"default": "0", "default": "0",
"depends_on": "eval:!doc.is_phantom_item",
"fieldname": "sourced_by_supplier", "fieldname": "sourced_by_supplier",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Sourced by Supplier" "label": "Sourced by Supplier"
@@ -286,7 +289,8 @@
"default": "0", "default": "0",
"fieldname": "do_not_explode", "fieldname": "do_not_explode",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Do Not Explode" "label": "Do Not Explode",
"read_only_depends_on": "eval:doc.is_phantom_item"
}, },
{ {
"default": "0", "default": "0",
@@ -304,18 +308,26 @@
}, },
{ {
"default": "0", "default": "0",
"depends_on": "eval:!doc.is_phantom_item",
"fieldname": "is_sub_assembly_item", "fieldname": "is_sub_assembly_item",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Is Sub Assembly Item", "label": "Is Sub Assembly Item",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
},
{
"default": "0",
"fieldname": "is_phantom_item",
"fieldtype": "Check",
"label": "Is Phantom Item",
"read_only": 1
} }
], ],
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-08-12 20:01:59.532613", "modified": "2025-11-05 19:00:38.646539",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Item", "name": "BOM Item",

View File

@@ -25,6 +25,7 @@ class BOMItem(Document):
has_variants: DF.Check has_variants: DF.Check
image: DF.Attach | None image: DF.Attach | None
include_item_in_manufacturing: DF.Check include_item_in_manufacturing: DF.Check
is_phantom_item: DF.Check
is_stock_item: DF.Check is_stock_item: DF.Check
is_sub_assembly_item: DF.Check is_sub_assembly_item: DF.Check
item_code: DF.Link item_code: DF.Link

View File

@@ -1354,14 +1354,19 @@ def get_subitems(
item.purchase_uom, item.purchase_uom,
item_uom.conversion_factor, item_uom.conversion_factor,
bom.item.as_("main_bom_item"), bom.item.as_("main_bom_item"),
bom_item.is_phantom_item,
) )
.where( .where(
(bom.name == bom_no) (bom.name == bom_no)
& (bom_item.is_sub_assembly_item == 0) & (bom_item.is_sub_assembly_item == 0)
& (bom_item.docstatus < 2) & (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) .groupby(bom_item.item_code)
.orderby(bom_item.idx)
).run(as_dict=True) ).run(as_dict=True)
for d in items: for d in items:
@@ -1374,10 +1379,12 @@ def get_subitems(
item_details[d.item_code] = d 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 ( if (
d.default_material_request_type in ["Manufacture", "Purchase"] and not d.is_sub_contracted (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_sub_contracted and include_subcontracted_items)
or d.is_phantom_item
):
if d.qty > 0: if d.qty > 0:
get_subitems( get_subitems(
doc, doc,
@@ -1389,7 +1396,7 @@ def get_subitems(
include_subcontracted_items, include_subcontracted_items,
d.qty, 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( 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") ] += d.get("qty")
sub_assembly_items = {k[:2]: v for k, v in sub_assembly_items.items()} 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: for data in po_items:
if not data.get("include_exploded_items") and doc.get("sub_assembly_items"): if not data.get("include_exploded_items") and doc.get("sub_assembly_items"):
data["include_exploded_items"] = 1 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, sub_assembly_items,
planned_qty=planned_qty, planned_qty=planned_qty,
) )
elif data.get("include_exploded_items") and include_subcontracted_items: elif data.get("include_exploded_items") and include_subcontracted_items:
# fetch exploded items from BOM # fetch exploded items from BOM
item_details = get_exploded_items( 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 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, "item_name": item_master.item_name,
"default_bom": doc.bom, "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, "min_order_qty": item_master.min_order_qty,
"default_material_request_type": item_master.default_material_request_type, "default_material_request_type": item_master.default_material_request_type,
"qty": planned_qty or 1, "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, "item_code": item_master.name,
"description": item_master.description, "description": item_master.description,
"stock_uom": item_master.stock_uom, "stock_uom": item_master.stock_uom,
@@ -1873,8 +1896,9 @@ def get_sub_assembly_items(
warehouse=None, warehouse=None,
indent=0, indent=0,
skip_available_sub_assembly_item=False, 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: for d in data:
if d.expandable: if d.expandable:
parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") 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) "projected_qty": bin_details[d.item_code][0].get("projected_qty", 0)
if bin_details.get(d.item_code) if bin_details.get(d.item_code)
else 0, else 0,
"main_bom": bom_no,
} }
) )
) )
@@ -1933,6 +1958,7 @@ def get_sub_assembly_items(
warehouse, warehouse,
indent=indent + 1, indent=indent + 1,
skip_available_sub_assembly_item=skip_available_sub_assembly_item, 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"), item.name.as_("item_code"),
bei.description, bei.description,
bei.stock_uom, bei.stock_uom,
bei.is_phantom_item,
bei.bom_no, bei.bom_no,
item.min_order_qty, item.min_order_qty,
bei.source_warehouse, bei.source_warehouse,
@@ -2056,13 +2083,19 @@ def get_raw_materials_of_sub_assembly_items(
(bei.docstatus == 1) (bei.docstatus == 1)
& (bei.is_sub_assembly_item == 0) & (bei.is_sub_assembly_item == 0)
& (bom.name == bom_no) & (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) .groupby(bei.item_code, bei.stock_uom)
) )
for item in query.run(as_dict=True): for item in query.run(as_dict=True):
key = (item.item_code, item.bom_no) 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 ( 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 (item.item_code, item.bom_no or item.main_bom) in existing_sub_assembly_items
): ):

View File

@@ -2368,7 +2368,6 @@ class TestProductionPlan(IntegrationTestCase):
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_production_plan_for_partial_sub_assembly_items(self): 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 from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
frappe.flags.test_print = False frappe.flags.test_print = False
@@ -2421,6 +2420,30 @@ class TestProductionPlan(IntegrationTestCase):
for row in plan.sub_assembly_items: for row in plan.sub_assembly_items:
self.assertEqual(row.ordered_qty, 10.0) 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): def create_production_plan(**args):
""" """

View File

@@ -2,6 +2,16 @@
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on("Routing", { frappe.ui.form.on("Routing", {
setup: function (frm) {
frm.set_query("bom_no", "operations", function () {
return {
filters: {
is_phantom_bom: 0,
},
};
});
},
refresh: function (frm) { refresh: function (frm) {
frm.trigger("display_sequence_id_column"); frm.trigger("display_sequence_id_column");
}, },

View File

@@ -3270,6 +3270,14 @@ class TestWorkOrder(IntegrationTestCase):
) )
frappe.db.set_single_value("Stock Settings", "auto_reserve_serial_and_batch", original_auto_reserve) 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): def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry") doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -21,7 +21,17 @@ def get_exploded_items(bom, data, indent=0, qty=1):
exploded_items = frappe.get_all( exploded_items = frappe.get_all(
"BOM Item", "BOM Item",
filters={"parent": bom}, 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", order_by="idx ASC",
) )
@@ -37,6 +47,7 @@ def get_exploded_items(bom, data, indent=0, qty=1):
"qty": item.qty * qty, "qty": item.qty * qty,
"uom": item.uom, "uom": item.uom,
"description": item.description, "description": item.description,
"is_phantom_item": item.is_phantom_item,
} }
) )
if item.bom_no: if item.bom_no:
@@ -54,6 +65,7 @@ def get_columns():
}, },
{"label": _("Item Name"), "fieldtype": "data", "fieldname": "item_name", "width": 100}, {"label": _("Item Name"), "fieldtype": "data", "fieldname": "item_name", "width": 100},
{"label": _("BOM"), "fieldtype": "Link", "fieldname": "bom", "width": 150, "options": "BOM"}, {"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": _("Qty"), "fieldtype": "data", "fieldname": "qty", "width": 100},
{"label": _("UOM"), "fieldtype": "data", "fieldname": "uom", "width": 100}, {"label": _("UOM"), "fieldtype": "data", "fieldname": "uom", "width": 100},
{"label": _("BOM Level"), "fieldtype": "Int", "fieldname": "bom_level", "width": 100}, {"label": _("BOM Level"), "fieldtype": "Int", "fieldname": "bom_level", "width": 100},

View File

@@ -32,6 +32,7 @@ def get_report_data(last_purchase_rate, required_qty, row, manufacture_details):
return [ return [
row.item_code, row.item_code,
row.description, 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", []), add_quotes=False),
comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False), comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False),
qty_per_unit, qty_per_unit,
@@ -57,6 +58,13 @@ def get_columns():
"fieldtype": "Data", "fieldtype": "Data",
"width": 150, "width": 150,
}, },
{
"fieldname": "from_bom_no",
"label": _("From BOM No"),
"fieldtype": "Link",
"options": "BOM",
"width": 150,
},
{ {
"fieldname": "manufacturer", "fieldname": "manufacturer",
"label": _("Manufacturer"), "label": _("Manufacturer"),
@@ -103,10 +111,7 @@ def get_columns():
def get_bom_data(filters): def get_bom_data(filters):
if filters.get("show_exploded_view"): bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
bom_item_table = "BOM Explosion Item"
else:
bom_item_table = "BOM Item"
bom_item = frappe.qb.DocType(bom_item_table) bom_item = frappe.qb.DocType(bom_item_table)
bin = frappe.qb.DocType("Bin") bin = frappe.qb.DocType("Bin")
@@ -118,11 +123,13 @@ def get_bom_data(filters):
.select( .select(
bom_item.item_code, bom_item.item_code,
bom_item.description, bom_item.description,
bom_item.parent.as_("from_bom_no"),
bom_item.qty_consumed_per_unit.as_("qty_per_unit"), bom_item.qty_consumed_per_unit.as_("qty_per_unit"),
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
) )
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
.groupby(bom_item.item_code) .groupby(bom_item.item_code)
.orderby(bom_item.idx)
) )
if filters.get("warehouse"): if filters.get("warehouse"):
@@ -146,7 +153,36 @@ def get_bom_data(filters):
else: else:
query = query.where(bin.warehouse == filters.get("warehouse")) 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(): def get_manufacturer_records():

View File

@@ -102,6 +102,7 @@ def get_expected_data(bom, qty_to_make):
[ [
bom.items[idx].item_code, bom.items[idx].item_code,
bom.items[idx].item_code, bom.items[idx].item_code,
bom.name,
"", "",
"", "",
float(bom.items[idx].stock_qty / bom.quantity), float(bom.items[idx].stock_qty / bom.quantity),

View File

@@ -22,8 +22,9 @@ def get_columns():
_("Item") + ":Link/Item:150", _("Item") + ":Link/Item:150",
_("Item Name") + "::240", _("Item Name") + "::240",
_("Description") + "::300", _("Description") + "::300",
_("From BOM No") + "::200",
_("BOM Qty") + ":Float:160", _("BOM Qty") + ":Float:160",
_("BOM UoM") + "::160", _("BOM UOM") + "::160",
_("Required Qty") + ":Float:120", _("Required Qty") + ":Float:120",
_("In Stock Qty") + ":Float:120", _("In Stock Qty") + ":Float:120",
_("Enough Parts to Build") + ":Float:200", _("Enough Parts to Build") + ":Float:200",
@@ -72,6 +73,7 @@ def get_bom_stock(filters):
BOM_ITEM.item_code, BOM_ITEM.item_code,
BOM_ITEM.item_name, BOM_ITEM.item_name,
BOM_ITEM.description, BOM_ITEM.description,
BOM.name,
Sum(BOM_ITEM.stock_qty), Sum(BOM_ITEM.stock_qty),
BOM_ITEM.stock_uom, BOM_ITEM.stock_uom,
(Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity, (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")) .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
.groupby(BOM_ITEM.item_code) .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

View File

@@ -96,6 +96,7 @@ def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False):
item.item_code, item.item_code,
item.item_name, item.item_name,
item.description, item.description,
bom.name,
item.stock_qty, item.stock_qty,
item.stock_uom, item.stock_uom,
item.stock_qty * qty_to_produce / bom.quantity, 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)) floor(in_stock_qty / (item.stock_qty * qty_to_produce / bom.quantity))
if in_stock_qty if in_stock_qty
else None, else None,
item.bom_no,
item.is_phantom_item,
] ]
) )

View File

@@ -140,6 +140,17 @@ class BOMConfigurator {
}, },
btnClass: "hidden-xs", 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"), label: __("Collapse All"),
click: function (node) { click: function (node) {
@@ -170,6 +181,17 @@ class BOMConfigurator {
}, },
btnClass: "hidden-xs", 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"), label: __(frappe.utils.icon("delete", "sm") + " Item"),
click: function (node) { 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({ let dialog = new frappe.ui.Dialog({
fields: view.events.get_sub_assembly_modal_fields(view, node.is_root), fields: view.events.get_sub_assembly_modal_fields(view, node.is_root, false, phantom),
title: __("Add Sub Assembly"), title: phantom ? __("Add Phantom Item") : __("Add Sub Assembly"),
}); });
view.events.set_query_for_workstation(dialog); view.events.set_query_for_workstation(dialog);
@@ -282,6 +304,7 @@ class BOMConfigurator {
operation: node.data.operation, operation: node.data.operation,
workstation_type: node.data.workstation_type, workstation_type: node.data.workstation_type,
operation_time: node.data.operation_time, operation_time: node.data.operation_time,
phantom: phantom,
}, },
callback: (r) => { callback: (r) => {
view.events.load_tree(r, node); 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 = [ let fields = [
{ {
label: __("Sub Assembly Item"), label: phantom ? __("Phantom Item") : __("Sub Assembly Item"),
fieldname: "item_code", fieldname: "item_code",
fieldtype: "Link", fieldtype: "Link",
options: "Item", options: "Item",
reqd: 1, reqd: 1,
read_only: read_only, read_only: read_only,
filters: {
is_stock_item: !phantom,
},
}, },
{ fieldtype: "Column Break" }, { fieldtype: "Column Break" },
{ {
@@ -320,7 +346,7 @@ class BOMConfigurator {
}, },
]; ];
if (is_root) { if (is_root && !phantom) {
fields.push( fields.push(
...[ ...[
{ fieldtype: "Section Break" }, { fieldtype: "Section Break" },
@@ -384,10 +410,10 @@ class BOMConfigurator {
return fields; return fields;
} }
convert_to_sub_assembly(node, view) { convert_to_sub_assembly(node, view, phantom = false) {
let dialog = new frappe.ui.Dialog({ let dialog = new frappe.ui.Dialog({
fields: view.events.get_sub_assembly_modal_fields(view, node.is_root, true), fields: view.events.get_sub_assembly_modal_fields(view, node.is_root, true, phantom),
title: __("Add Sub Assembly"), title: phantom ? __("Add Phantom Item") : __("Add Sub Assembly"),
}); });
dialog.set_values({ dialog.set_values({
@@ -400,7 +426,9 @@ class BOMConfigurator {
let bom_item = dialog.get_values(); let bom_item = dialog.get_values();
if (!bom_item.item_code) { 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) => { bom_item.items.forEach((d) => {
@@ -425,6 +453,7 @@ class BOMConfigurator {
workstation_type: node.data.workstation_type, workstation_type: node.data.workstation_type,
operation_time: node.data.operation_time, operation_time: node.data.operation_time,
workstation: node.data.workstation, workstation: node.data.workstation,
phantom: phantom,
}, },
callback: (r) => { callback: (r) => {
node.expandable = true; node.expandable = true;