* feat: provision to exclude exploded items in the BOM (#29450)
(cherry picked from commit b75b00fefc)
* fix(ux): "New Version" button BOM
"duplicate" technically creates a new version but that's not intuitive
at all.
* fix: only erase BOM when do_not_explode is set
* fix: allow non-explosive recrusive BOMs
Recursion should be allowed as long as child item is not "exploded"
further by a BOM.
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
Co-authored-by: Ankush Menat <ankush@frappe.io>
This commit is contained in:
@@ -93,6 +93,11 @@ frappe.ui.form.on("BOM", {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
frm.add_custom_button(__("New Version"), function() {
|
||||||
|
let new_bom = frappe.model.copy_doc(frm.doc);
|
||||||
|
frappe.set_route("Form", "BOM", new_bom.name);
|
||||||
|
});
|
||||||
|
|
||||||
if(frm.doc.docstatus==1) {
|
if(frm.doc.docstatus==1) {
|
||||||
frm.add_custom_button(__("Work Order"), function() {
|
frm.add_custom_button(__("Work Order"), function() {
|
||||||
frm.trigger("make_work_order");
|
frm.trigger("make_work_order");
|
||||||
@@ -331,7 +336,7 @@ frappe.ui.form.on("BOM", {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (has_template_rm) {
|
if (has_template_rm && has_template_rm.length) {
|
||||||
dialog.fields_dict.items.grid.refresh();
|
dialog.fields_dict.items.grid.refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -467,7 +472,8 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) {
|
|||||||
"uom": d.uom,
|
"uom": d.uom,
|
||||||
"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,
|
||||||
|
"do_not_explode": d.do_not_explode
|
||||||
},
|
},
|
||||||
callback: function(r) {
|
callback: function(r) {
|
||||||
d = locals[cdt][cdn];
|
d = locals[cdt][cdn];
|
||||||
@@ -640,6 +646,13 @@ frappe.ui.form.on("BOM Operation", "workstation", function(frm, cdt, cdn) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
frappe.ui.form.on("BOM Item", {
|
||||||
|
do_not_explode: function(frm, cdt, cdn) {
|
||||||
|
get_bom_material_detail(frm.doc, cdt, cdn, false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
frappe.ui.form.on("BOM Item", "qty", function(frm, cdt, cdn) {
|
frappe.ui.form.on("BOM Item", "qty", function(frm, cdt, cdn) {
|
||||||
var d = locals[cdt][cdn];
|
var d = locals[cdt][cdn];
|
||||||
d.stock_qty = d.qty * d.conversion_factor;
|
d.stock_qty = d.qty * d.conversion_factor;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ from erpnext.stock.get_item_details import get_conversion_factor, get_price_list
|
|||||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||||
|
|
||||||
|
|
||||||
|
class BOMRecursionError(frappe.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BOMTree:
|
class BOMTree:
|
||||||
"""Full tree representation of a BOM"""
|
"""Full tree representation of a BOM"""
|
||||||
|
|
||||||
@@ -250,6 +254,9 @@ class BOM(WebsiteGenerator):
|
|||||||
for item in self.get("items"):
|
for item in self.get("items"):
|
||||||
self.validate_bom_currency(item)
|
self.validate_bom_currency(item)
|
||||||
|
|
||||||
|
if item.do_not_explode:
|
||||||
|
item.bom_no = ""
|
||||||
|
|
||||||
ret = self.get_bom_material_detail(
|
ret = self.get_bom_material_detail(
|
||||||
{
|
{
|
||||||
"company": self.company,
|
"company": self.company,
|
||||||
@@ -263,8 +270,10 @@ class BOM(WebsiteGenerator):
|
|||||||
"stock_uom": item.stock_uom,
|
"stock_uom": item.stock_uom,
|
||||||
"conversion_factor": item.conversion_factor,
|
"conversion_factor": item.conversion_factor,
|
||||||
"sourced_by_supplier": item.sourced_by_supplier,
|
"sourced_by_supplier": item.sourced_by_supplier,
|
||||||
|
"do_not_explode": item.do_not_explode,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
for r in ret:
|
for r in ret:
|
||||||
if not item.get(r):
|
if not item.get(r):
|
||||||
item.set(r, ret[r])
|
item.set(r, ret[r])
|
||||||
@@ -321,6 +330,9 @@ class BOM(WebsiteGenerator):
|
|||||||
"sourced_by_supplier": args.get("sourced_by_supplier", 0),
|
"sourced_by_supplier": args.get("sourced_by_supplier", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if args.get("do_not_explode"):
|
||||||
|
ret_item["bom_no"] = ""
|
||||||
|
|
||||||
return ret_item
|
return ret_item
|
||||||
|
|
||||||
def validate_bom_currency(self, item):
|
def validate_bom_currency(self, item):
|
||||||
@@ -545,35 +557,27 @@ class BOM(WebsiteGenerator):
|
|||||||
"""Check whether recursion occurs in any bom"""
|
"""Check whether recursion occurs in any bom"""
|
||||||
|
|
||||||
def _throw_error(bom_name):
|
def _throw_error(bom_name):
|
||||||
frappe.throw(_("BOM recursion: {0} cannot be parent or child of {0}").format(bom_name))
|
frappe.throw(
|
||||||
|
_("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name),
|
||||||
|
exc=BOMRecursionError,
|
||||||
|
)
|
||||||
|
|
||||||
bom_list = self.traverse_tree()
|
bom_list = self.traverse_tree()
|
||||||
child_items = (
|
child_items = frappe.get_all(
|
||||||
frappe.get_all(
|
"BOM Item",
|
||||||
"BOM Item",
|
fields=["bom_no", "item_code"],
|
||||||
fields=["bom_no", "item_code"],
|
filters={"parent": ("in", bom_list), "parenttype": "BOM"},
|
||||||
filters={"parent": ("in", bom_list), "parenttype": "BOM"},
|
|
||||||
)
|
|
||||||
or []
|
|
||||||
)
|
)
|
||||||
|
|
||||||
child_bom = {d.bom_no for d in child_items}
|
for item in child_items:
|
||||||
child_items_codes = {d.item_code for d in child_items}
|
if self.name == item.bom_no:
|
||||||
|
_throw_error(self.name)
|
||||||
|
if self.item == item.item_code and item.bom_no:
|
||||||
|
# Same item but with different BOM should not be allowed.
|
||||||
|
# Same item can appear recursively once as long as it doesn't have BOM.
|
||||||
|
_throw_error(item.bom_no)
|
||||||
|
|
||||||
if self.name in child_bom:
|
if self.name in {d.bom_no for d in self.items}:
|
||||||
_throw_error(self.name)
|
|
||||||
|
|
||||||
if self.item in child_items_codes:
|
|
||||||
_throw_error(self.item)
|
|
||||||
|
|
||||||
bom_nos = (
|
|
||||||
frappe.get_all(
|
|
||||||
"BOM Item", fields=["parent"], filters={"bom_no": self.name, "parenttype": "BOM"}
|
|
||||||
)
|
|
||||||
or []
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.name in {d.parent for d in bom_nos}:
|
|
||||||
_throw_error(self.name)
|
_throw_error(self.name)
|
||||||
|
|
||||||
def traverse_tree(self, bom_list=None):
|
def traverse_tree(self, bom_list=None):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase
|
|||||||
from frappe.utils import cstr, flt
|
from frappe.utils import cstr, flt
|
||||||
|
|
||||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||||
from erpnext.manufacturing.doctype.bom.bom import item_query
|
from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query
|
||||||
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
|
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
|
||||||
from erpnext.stock.doctype.item.test_item import make_item
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||||
@@ -259,43 +259,36 @@ class TestBOM(FrappeTestCase):
|
|||||||
|
|
||||||
def test_bom_recursion_1st_level(self):
|
def test_bom_recursion_1st_level(self):
|
||||||
"""BOM should not allow BOM item again in child"""
|
"""BOM should not allow BOM item again in child"""
|
||||||
item_code = "_Test BOM Recursion"
|
item_code = make_item(properties={"is_stock_item": 1}).name
|
||||||
make_item(item_code, {"is_stock_item": 1})
|
|
||||||
|
|
||||||
bom = frappe.new_doc("BOM")
|
bom = frappe.new_doc("BOM")
|
||||||
bom.item = item_code
|
bom.item = item_code
|
||||||
bom.append("items", frappe._dict(item_code=item_code))
|
bom.append("items", frappe._dict(item_code=item_code))
|
||||||
with self.assertRaises(frappe.ValidationError) as err:
|
bom.save()
|
||||||
|
with self.assertRaises(BOMRecursionError):
|
||||||
|
bom.items[0].bom_no = bom.name
|
||||||
bom.save()
|
bom.save()
|
||||||
|
|
||||||
self.assertTrue("recursion" in str(err.exception).lower())
|
|
||||||
frappe.delete_doc("BOM", bom.name, ignore_missing=True)
|
|
||||||
|
|
||||||
def test_bom_recursion_transitive(self):
|
def test_bom_recursion_transitive(self):
|
||||||
item1 = "_Test BOM Recursion"
|
item1 = make_item(properties={"is_stock_item": 1}).name
|
||||||
item2 = "_Test BOM Recursion 2"
|
item2 = make_item(properties={"is_stock_item": 1}).name
|
||||||
make_item(item1, {"is_stock_item": 1})
|
|
||||||
make_item(item2, {"is_stock_item": 1})
|
|
||||||
|
|
||||||
bom1 = frappe.new_doc("BOM")
|
bom1 = frappe.new_doc("BOM")
|
||||||
bom1.item = item1
|
bom1.item = item1
|
||||||
bom1.append("items", frappe._dict(item_code=item2))
|
bom1.append("items", frappe._dict(item_code=item2))
|
||||||
bom1.save()
|
bom1.save()
|
||||||
bom1.submit()
|
|
||||||
|
|
||||||
bom2 = frappe.new_doc("BOM")
|
bom2 = frappe.new_doc("BOM")
|
||||||
bom2.item = item2
|
bom2.item = item2
|
||||||
bom2.append("items", frappe._dict(item_code=item1))
|
bom2.append("items", frappe._dict(item_code=item1))
|
||||||
|
bom2.save()
|
||||||
|
|
||||||
with self.assertRaises(frappe.ValidationError) as err:
|
bom2.items[0].bom_no = bom1.name
|
||||||
|
bom1.items[0].bom_no = bom2.name
|
||||||
|
|
||||||
|
with self.assertRaises(BOMRecursionError):
|
||||||
|
bom1.save()
|
||||||
bom2.save()
|
bom2.save()
|
||||||
bom2.submit()
|
|
||||||
|
|
||||||
self.assertTrue("recursion" in str(err.exception).lower())
|
|
||||||
|
|
||||||
bom1.cancel()
|
|
||||||
frappe.delete_doc("BOM", bom1.name, ignore_missing=True, force=True)
|
|
||||||
frappe.delete_doc("BOM", bom2.name, ignore_missing=True, force=True)
|
|
||||||
|
|
||||||
def test_bom_with_process_loss_item(self):
|
def test_bom_with_process_loss_item(self):
|
||||||
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
|
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
|
||||||
@@ -501,6 +494,24 @@ class TestBOM(FrappeTestCase):
|
|||||||
bom.submit()
|
bom.submit()
|
||||||
self.assertEqual(bom.items[0].rate, 42)
|
self.assertEqual(bom.items[0].rate, 42)
|
||||||
|
|
||||||
|
def test_exclude_exploded_items_from_bom(self):
|
||||||
|
bom_no = get_default_bom()
|
||||||
|
new_bom = frappe.copy_doc(frappe.get_doc("BOM", bom_no))
|
||||||
|
for row in new_bom.items:
|
||||||
|
if row.item_code == "_Test Item Home Desktop Manufactured":
|
||||||
|
self.assertTrue(row.bom_no)
|
||||||
|
row.do_not_explode = True
|
||||||
|
|
||||||
|
new_bom.docstatus = 0
|
||||||
|
new_bom.save()
|
||||||
|
new_bom.load_from_db()
|
||||||
|
|
||||||
|
for row in new_bom.items:
|
||||||
|
if row.item_code == "_Test Item Home Desktop Manufactured" and row.do_not_explode:
|
||||||
|
self.assertFalse(row.bom_no)
|
||||||
|
|
||||||
|
new_bom.delete()
|
||||||
|
|
||||||
|
|
||||||
def get_default_bom(item_code="_Test FG Item 2"):
|
def get_default_bom(item_code="_Test FG Item 2"):
|
||||||
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
|
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"item_name",
|
"item_name",
|
||||||
"operation",
|
"operation",
|
||||||
"column_break_3",
|
"column_break_3",
|
||||||
|
"do_not_explode",
|
||||||
"bom_no",
|
"bom_no",
|
||||||
"source_warehouse",
|
"source_warehouse",
|
||||||
"allow_alternative_item",
|
"allow_alternative_item",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"depends_on": "eval:!doc.do_not_explode",
|
||||||
"fieldname": "bom_no",
|
"fieldname": "bom_no",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_filter": 1,
|
"in_filter": 1,
|
||||||
@@ -284,18 +286,25 @@
|
|||||||
"fieldname": "sourced_by_supplier",
|
"fieldname": "sourced_by_supplier",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Sourced by Supplier"
|
"label": "Sourced by Supplier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "do_not_explode",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Do Not Explode"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2020-10-08 14:19:37.563300",
|
"modified": "2022-01-24 16:57:57.020232",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Manufacturing",
|
"module": "Manufacturing",
|
||||||
"name": "BOM Item",
|
"name": "BOM Item",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC"
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user