Browse Source

fix: order and time of operations for multilevel bom

- Order of operations was being sorted by idx of individual operations in
BOM table, which made the ordering useless.
- This adds ordering that's sorted from lowest level item to top level
item.
- chore: remove dead functionality. There's no `items` table. Required
  item level operations get overwritten on fetching of items /
  operations e.g. when clicking on multi-level BOM checkbox.
- test: add test for tree representation
- feat: BOMTree class to get complete representation of a tree
develop
Ankush Menat 3 years ago
committed by Ankush
parent
commit
805ac4ffdf
  1. 85
      erpnext/manufacturing/doctype/bom/bom.py
  2. 82
      erpnext/manufacturing/doctype/bom/test_bom.py
  3. 55
      erpnext/manufacturing/doctype/work_order/work_order.py

85
erpnext/manufacturing/doctype/bom/bom.py

@ -1,7 +1,8 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals from typing import List
from collections import deque
import frappe, erpnext import frappe, erpnext
from frappe.utils import cint, cstr, flt, today from frappe.utils import cint, cstr, flt, today
from frappe import _ from frappe import _
@ -16,14 +17,85 @@ from frappe.model.mapper import get_mapped_doc
import functools import functools
from six import string_types
from operator import itemgetter from operator import itemgetter
form_grid_templates = { form_grid_templates = {
"items": "templates/form_grid/item_grid.html" "items": "templates/form_grid/item_grid.html"
} }
class BOMTree:
"""Full tree representation of a BOM"""
# specifying the attributes to save resources
# ref: https://docs.python.org/3/reference/datamodel.html#slots
__slots__ = ["name", "child_items", "is_bom", "item_code", "exploded_qty", "qty"]
def __init__(self, name: str, is_bom: bool = True, exploded_qty: float = 1.0, qty: float = 1) -> None:
self.name = name # name of node, BOM number if is_bom else item_code
self.child_items: List["BOMTree"] = [] # list of child items
self.is_bom = is_bom # true if the node is a BOM and not a leaf item
self.item_code: str = None # item_code associated with node
self.qty = qty # required unit quantity to make one unit of parent item.
self.exploded_qty = exploded_qty # total exploded qty required for making root of tree.
if not self.is_bom:
self.item_code = self.name
else:
self.__create_tree()
def __create_tree(self):
bom = frappe.get_cached_doc("BOM", self.name)
self.item_code = bom.item
for item in bom.get("items", []):
qty = item.qty / bom.quantity # quantity per unit
exploded_qty = self.exploded_qty * qty
if item.bom_no:
child = BOMTree(item.bom_no, exploded_qty=exploded_qty, qty=qty)
self.child_items.append(child)
else:
self.child_items.append(
BOMTree(item.item_code, is_bom=False, exploded_qty=exploded_qty, qty=qty)
)
def level_order_traversal(self) -> List["BOMTree"]:
"""Get level order traversal of tree.
E.g. for following tree the traversal will return list of nodes in order from top to bottom.
BOM:
- SubAssy1
- item1
- item2
- SubAssy2
- item3
- item4
returns = [SubAssy1, item1, item2, SubAssy2, item3, item4]
"""
traversal = []
q = deque()
q.append(self)
while q:
node = q.popleft()
for child in node.child_items:
traversal.append(child)
q.append(child)
return traversal
def __str__(self) -> str:
return (
f"{self.item_code}{' - ' + self.name if self.is_bom else ''} qty(per unit): {self.qty}"
f" exploded_qty: {self.exploded_qty}"
)
def __repr__(self, level: int = 0) -> str:
rep = "" * (level - 1) + "┣━ " * (level > 0) + str(self) + "\n"
for child in self.child_items:
rep += child.__repr__(level=level + 1)
return rep
class BOM(WebsiteGenerator): class BOM(WebsiteGenerator):
website = frappe._dict( website = frappe._dict(
# page_title_field = "item_name", # page_title_field = "item_name",
@ -152,7 +224,7 @@ class BOM(WebsiteGenerator):
if not args: if not args:
args = frappe.form_dict.get('args') args = frappe.form_dict.get('args')
if isinstance(args, string_types): if isinstance(args, str):
import json import json
args = json.loads(args) args = json.loads(args)
@ -600,6 +672,11 @@ class BOM(WebsiteGenerator):
if not d.batch_size or d.batch_size <= 0: if not d.batch_size or d.batch_size <= 0:
d.batch_size = 1 d.batch_size = 1
def get_tree_representation(self) -> BOMTree:
"""Get a complete tree representation preserving order of child items."""
return BOMTree(self.name)
def get_bom_item_rate(args, bom_doc): def get_bom_item_rate(args, bom_doc):
if bom_doc.rm_cost_as_per == 'Valuation Rate': if bom_doc.rm_cost_as_per == 'Valuation Rate':
rate = get_valuation_rate(args) * (args.get("conversion_factor") or 1) rate = get_valuation_rate(args) * (args.get("conversion_factor") or 1)

82
erpnext/manufacturing/doctype/bom/test_bom.py

@ -2,14 +2,13 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals from collections import deque
import unittest import unittest
import frappe import frappe
from frappe.utils import cstr, flt from frappe.utils import cstr, flt
from frappe.test_runner import make_test_records from frappe.test_runner import make_test_records
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation
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 six import string_types
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
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.tests.test_subcontracting import set_backflush_based_on from erpnext.tests.test_subcontracting import set_backflush_based_on
@ -227,11 +226,88 @@ class TestBOM(unittest.TestCase):
supplied_items = sorted([d.rm_item_code for d in po.supplied_items]) supplied_items = sorted([d.rm_item_code for d in po.supplied_items])
self.assertEqual(bom_items, supplied_items) self.assertEqual(bom_items, supplied_items)
def test_bom_tree_representation(self):
bom_tree = {
"Assembly": {
"SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},},
"SubAssembly2": {"ChildPart3": {}},
"SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}},
"ChildPart5": {},
"ChildPart6": {},
"SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}},
}
}
parent_bom = create_nested_bom(bom_tree, prefix="")
created_tree = parent_bom.get_tree_representation()
reqd_order = level_order_traversal(bom_tree)[1:] # skip first item
created_order = created_tree.level_order_traversal()
self.assertEqual(len(reqd_order), len(created_order))
for reqd_item, created_item in zip(reqd_order, created_order):
self.assertEqual(reqd_item, created_item.item_code)
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})
def level_order_traversal(node):
traversal = []
q = deque()
q.append(node)
while q:
node = q.popleft()
for node_name, subtree in node.items():
traversal.append(node_name)
q.append(subtree)
return traversal
def create_nested_bom(tree, prefix="_Test bom "):
""" Helper function to create a simple nested bom from tree describing item names. (along with required items)
"""
def create_items(bom_tree):
for item_code, subtree in bom_tree.items():
bom_item_code = prefix + item_code
if not frappe.db.exists("Item", bom_item_code):
frappe.get_doc(doctype="Item", item_code=bom_item_code, item_group="_Test Item Group").insert()
create_items(subtree)
create_items(tree)
def dfs(tree, node):
"""naive implementation for searching right subtree"""
for node_name, subtree in tree.items():
if node_name == node:
return subtree
else:
result = dfs(subtree, node)
if result is not None:
return result
order_of_creating_bom = reversed(level_order_traversal(tree))
for item in order_of_creating_bom:
child_items = dfs(tree, item)
if child_items:
bom_item_code = prefix + item
bom = frappe.get_doc(doctype="BOM", item=bom_item_code)
for child_item in child_items.keys():
bom.append("items", {"item_code": prefix + child_item})
bom.insert()
bom.submit()
return bom # parent bom is last bom
def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=None): def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=None):
if warehouse_list and isinstance(warehouse_list, string_types): if warehouse_list and isinstance(warehouse_list, str):
warehouse_list = [warehouse_list] warehouse_list = [warehouse_list]
if not warehouse_list: if not warehouse_list:

55
erpnext/manufacturing/doctype/work_order/work_order.py

@ -389,46 +389,47 @@ class WorkOrder(Document):
def set_work_order_operations(self): def set_work_order_operations(self):
"""Fetch operations from BOM and set in 'Work Order'""" """Fetch operations from BOM and set in 'Work Order'"""
self.set('operations', [])
def _get_operations(bom_no, qty=1):
return frappe.db.sql(
f"""select
operation, description, workstation, idx,
base_hour_rate as hour_rate, time_in_mins * {qty} as time_in_mins,
"Pending" as status, parent as bom, batch_size, sequence_id
from
`tabBOM Operation`
where
parent = %s order by idx
""", bom_no, as_dict=1)
self.set('operations', [])
if not self.bom_no: if not self.bom_no:
return return
if self.use_multi_level_bom: operations = []
bom_list = frappe.get_doc("BOM", self.bom_no).traverse_tree() if not self.use_multi_level_bom:
bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity")
operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty))
else: else:
bom_list = [self.bom_no] bom_tree = frappe.get_doc("BOM", self.bom_no).get_tree_representation()
bom_traversal = list(reversed(bom_tree.level_order_traversal()))
operations = frappe.db.sql(""" bom_traversal.append(bom_tree) # add operation on top level item last
select
operation, description, workstation, idx,
base_hour_rate as hour_rate, time_in_mins,
"Pending" as status, parent as bom, batch_size, sequence_id
from
`tabBOM Operation`
where
parent in (%s) order by idx
""" % ", ".join(["%s"]*len(bom_list)), tuple(bom_list), as_dict=1)
self.set('operations', operations) for d in bom_traversal:
if d.is_bom:
operations.extend(_get_operations(d.name, qty=d.exploded_qty))
if self.use_multi_level_bom and self.get('operations') and self.get('items'): for correct_index, operation in enumerate(operations, start=1):
raw_material_operations = [d.operation for d in self.get('items')] operation.idx = correct_index
operations = [d.operation for d in self.get('operations')]
for operation in raw_material_operations:
if operation not in operations:
self.append('operations', {
'operation': operation
})
self.set('operations', operations)
self.calculate_time() self.calculate_time()
def calculate_time(self): def calculate_time(self):
bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity")
for d in self.get("operations"): for d in self.get("operations"):
d.time_in_mins = flt(d.time_in_mins) / flt(bom_qty) * (flt(self.qty) / flt(d.batch_size)) d.time_in_mins = flt(d.time_in_mins) * (flt(self.qty) / flt(d.batch_size))
self.calculate_operating_cost() self.calculate_operating_cost()

Loading…
Cancel
Save