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)
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 (

View File

@@ -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():

View File

@@ -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", {

View File

@@ -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",

View File

@@ -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",
)

View File

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

View File

@@ -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"]

View File

@@ -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"):

View File

@@ -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": []
}
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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
):

View File

@@ -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):
"""

View File

@@ -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");
},

View File

@@ -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")

View File

@@ -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},

View File

@@ -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():

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.name,
"",
"",
float(bom.items[idx].stock_qty / bom.quantity),

View File

@@ -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

View File

@@ -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,
]
)

View File

@@ -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;