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