Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions erpnext/controllers/subcontracting_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,8 @@ def __remove_serial_and_batch_bundle(self, item):
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"]

Expand All @@ -558,7 +560,7 @@ def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0):
"name": "bom_detail_no",
"source_warehouse": "reserve_warehouse",
}
for field in [
fields_list = [
"item_code",
"name",
"rate",
Expand All @@ -567,7 +569,12 @@ def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0):
"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 = [
Expand All @@ -577,7 +584,19 @@ def __get_materials_from_bom(self, item_code, bom_no, exploded_item=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):
if (
Expand Down
25 changes: 25 additions & 0 deletions erpnext/controllers/tests/test_subcontracting_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,28 @@ def test_return_non_consumed_materials_with_serial_batch_fields(self):
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 = {}
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand Down
12 changes: 10 additions & 2 deletions erpnext/manufacturing/doctype/bom/bom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
});
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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", {
Expand Down
19 changes: 18 additions & 1 deletion erpnext/manufacturing/doctype/bom/bom.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -201,6 +202,7 @@
},
{
"collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "currency_detail",
"fieldtype": "Section Break",
"label": "Cost Configuration"
Expand Down Expand Up @@ -293,6 +295,7 @@
},
{
"collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_section",
"fieldtype": "Tab Break",
"label": "Scrap & Process Loss"
Expand All @@ -310,6 +313,7 @@
"oldfieldtype": "Section Break"
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "operating_cost",
"fieldtype": "Currency",
"label": "Operating Cost",
Expand All @@ -324,6 +328,7 @@
"read_only": 1
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "scrap_material_cost",
"fieldtype": "Currency",
"label": "Scrap Material Cost",
Expand All @@ -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)",
Expand All @@ -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)",
Expand Down Expand Up @@ -380,6 +387,7 @@
"read_only": 1
},
{
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
Expand Down Expand Up @@ -427,6 +435,7 @@
},
{
"collapsible": 1,
"depends_on": "eval:!doc.is_phantom_bom",
"fieldname": "website_section",
"fieldtype": "Tab Break",
"label": "Website"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -659,14 +670,20 @@
"fieldtype": "Link",
"label": "Default Target Warehouse",
"options": "Warehouse"
},
{
"default": "0",
"fieldname": "is_phantom_bom",
"fieldtype": "Check",
"label": "Is Phantom BOM"
}
],
"icon": "fa fa-sitemap",
"idx": 1,
"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",
Expand Down
54 changes: 42 additions & 12 deletions erpnext/manufacturing/doctype/bom/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -447,6 +448,9 @@ def get_bom_material_detail(self, args=None):
"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,
Expand All @@ -455,6 +459,9 @@ def get_bom_material_detail(self, args=None):
"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"] = ""

Expand All @@ -481,7 +488,9 @@ def get_rm_rate(self, arg):
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)
Expand Down Expand Up @@ -888,7 +897,7 @@ def calculate_rm_cost(self, save=False):

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,
Expand All @@ -899,6 +908,7 @@ def calculate_rm_cost(self, save=False):
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
"is_phantom_item": d.is_phantom_item,
}
)

Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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",
)

Expand Down
Loading