mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-16 08:05:00 +00:00
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:
@@ -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 (
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user