Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: BOM Update Tool #31072

Merged
merged 22 commits into from
Jun 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b6e46ee
perf: `get_boms_in_bottom_up_order`
marination May 18, 2022
5932e9d
fix: DB update child items, remove redundancy, fix perf
marination May 19, 2022
9dc3083
fix: Call `calculate_cost` for Draft BOM and typo in argument
marination May 19, 2022
9a7e9d9
perf: Use cached doc instead of `get_doc`
marination May 19, 2022
dd99c00
fix: Get fresh RM rate in `calculate_rm_cost`
marination May 19, 2022
90d4dc0
fix: `test_work_order_with_non_stock_item`
marination May 19, 2022
ab2d95a
feat: Level-wise BOM cost updation
marination May 23, 2022
9f5f18e
style: Update docstrings and fix/add type hints + Collapsible progres…
marination May 24, 2022
eabd829
feat: Only update exploded items rate and amount
marination May 25, 2022
5949946
chore: Change BOM Progress field types to Long Text
marination May 27, 2022
2de2491
perf: `get_next_higher_level_boms`
marination May 27, 2022
978ba52
fix: Safe cast `row.rate` (in case of faulty exploded items, edge cas…
marination May 27, 2022
a62bc9b
chore: Limit Update Cost jobs & `db_update` only if changed values
marination May 31, 2022
62857e3
feat: Track progress in Log Batch/Job wise
marination Jun 2, 2022
934db57
chore: Miscellanous fixes/enhancements
marination Jun 6, 2022
1510119
chore: `get_valuation_rate` in bom.py must always return float & goto…
marination Jun 6, 2022
6bde1bb
test: Util to update cost in all BOMs
marination Jun 7, 2022
ff0a6b7
Merge branch 'develop' into perf-bom-update-tool
marination Jun 7, 2022
9f2793c
test: Fix `test_update_bom_cost_in_all_boms`
marination Jun 7, 2022
7e41d84
chore: `get_valuation_rate` sider fixes
marination Jun 8, 2022
a6edce2
Merge branch 'develop' into perf-bom-update-tool
ankush Jun 9, 2022
3fa0a46
chore: Less hacky tests, versioning (replace bom) and clearing log da…
marination Jun 9, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion erpnext/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,12 @@

scheduler_events = {
"cron": {
"0/5 * * * *": [
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
],
"0/30 * * * *": [
"erpnext.utilities.doctype.video.video.update_youtube_data",
]
],
},
"all": [
"erpnext.projects.doctype.project.project.project_status_update_reminder",
Expand Down
209 changes: 104 additions & 105 deletions erpnext/manufacturing/doctype/bom/bom.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt

import functools
import re
from collections import deque
from operator import itemgetter
from typing import List
from typing import Dict, List

import frappe
from frappe import _
Expand Down Expand Up @@ -189,6 +189,7 @@ def validate(self):
self.validate_transfer_against()
self.set_routing_operations()
self.validate_operations()
self.update_exploded_items(save=False)
self.calculate_cost()
self.update_stock_qty()
self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False)
Expand Down Expand Up @@ -386,40 +387,14 @@ def update_cost(self, update_parent=True, from_child_bom=False, update_hour_rate

existing_bom_cost = self.total_cost

for d in self.get("items"):
if not d.item_code:
continue

rate = self.get_rm_rate(
{
"company": self.company,
"item_code": d.item_code,
"bom_no": d.bom_no,
"qty": d.qty,
"uom": d.uom,
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
}
)

if rate:
d.rate = rate
d.amount = flt(d.rate) * flt(d.qty)
d.base_rate = flt(d.rate) * flt(self.conversion_rate)
d.base_amount = flt(d.amount) * flt(self.conversion_rate)

if save:
d.db_update()

if self.docstatus == 1:
self.flags.ignore_validate_update_after_submit = True
self.calculate_cost(update_hour_rate)

self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate)

if save:
self.db_update()

self.update_exploded_items(save=save)

# update parent BOMs
if self.total_cost != existing_bom_cost and update_parent:
parent_boms = frappe.db.sql_list(
Expand Down Expand Up @@ -608,11 +583,15 @@ def _get_children(bom_no):
bom_list.reverse()
return bom_list

def calculate_cost(self, update_hour_rate=False):
def calculate_cost(self, save_updates=False, update_hour_rate=False):
"""Calculate bom totals"""
self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost()
self.calculate_sm_cost()
self.calculate_rm_cost(save=save_updates)
self.calculate_sm_cost(save=save_updates)
if save_updates:
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost()

self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost
self.base_total_cost = (
self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost
Expand Down Expand Up @@ -654,12 +633,26 @@ def update_rate_and_time(self, row, update_hour_rate=False):
if update_hour_rate:
row.db_update()

def calculate_rm_cost(self):
def calculate_rm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_rm_cost = 0
base_total_rm_cost = 0

for d in self.get("items"):
old_rate = d.rate
d.rate = self.get_rm_rate(
{
"company": self.company,
"item_code": d.item_code,
"bom_no": d.bom_no,
"qty": d.qty,
"uom": d.uom,
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
}
)

d.base_rate = flt(d.rate) * flt(self.conversion_rate)
d.amount = flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty"))
d.base_amount = d.amount * flt(self.conversion_rate)
Expand All @@ -669,11 +662,13 @@ def calculate_rm_cost(self):

total_rm_cost += d.amount
base_total_rm_cost += d.base_amount
if save and (old_rate != d.rate):
d.db_update()

self.raw_material_cost = total_rm_cost
self.base_raw_material_cost = base_total_rm_cost

def calculate_sm_cost(self):
def calculate_sm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0
base_total_sm_cost = 0
Expand All @@ -688,10 +683,45 @@ def calculate_sm_cost(self):
)
total_sm_cost += d.amount
base_total_sm_cost += d.base_amount
if save:
d.db_update()

self.scrap_material_cost = total_sm_cost
self.base_scrap_material_cost = base_total_sm_cost

def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM."
rm_rate_map = self.get_rm_rate_map()

for row in self.get("exploded_items"):
old_rate = flt(row.rate)
row.rate = rm_rate_map.get(row.item_code)
row.amount = flt(row.stock_qty) * flt(row.rate)

if old_rate != row.rate:
# Only db_update if changed
row.db_update()

def get_rm_rate_map(self) -> Dict[str, float]:
"Create Raw Material-Rate map for Exploded Items. Fetch rate from Items table or Subassembly BOM."
rm_rate_map = {}

for item in self.get("items"):
if item.bom_no:
# Get Item-Rate from Subassembly BOM
explosion_items = frappe.get_all(
"BOM Explosion Item",
filters={"parent": item.bom_no},
fields=["item_code", "rate"],
order_by=None, # to avoid sort index creation at db level (granular change)
)
explosion_item_rate = {item.item_code: flt(item.rate) for item in explosion_items}
rm_rate_map.update(explosion_item_rate)
else:
rm_rate_map[item.item_code] = flt(item.base_rate) / flt(item.conversion_factor or 1.0)

return rm_rate_map

def update_exploded_items(self, save=True):
"""Update Flat BOM, following will be correct data"""
self.get_exploded_items()
Expand Down Expand Up @@ -902,44 +932,46 @@ def get_bom_item_rate(args, bom_doc):
return flt(rate)


def get_valuation_rate(args):
"""Get weighted average of valuation rate from all warehouses"""

total_qty, total_value, valuation_rate = 0.0, 0.0, 0.0
item_bins = frappe.db.sql(
"""
select
bin.actual_qty, bin.stock_value
from
`tabBin` bin, `tabWarehouse` warehouse
where
bin.item_code=%(item)s
and bin.warehouse = warehouse.name
and warehouse.company=%(company)s""",
{"item": args["item_code"], "company": args["company"]},
as_dict=1,
)

for d in item_bins:
total_qty += flt(d.actual_qty)
total_value += flt(d.stock_value)

if total_qty:
valuation_rate = total_value / total_qty

if valuation_rate <= 0:
last_valuation_rate = frappe.db.sql(
"""select valuation_rate
from `tabStock Ledger Entry`
where item_code = %s and valuation_rate > 0 and is_cancelled = 0
order by posting_date desc, posting_time desc, creation desc limit 1""",
args["item_code"],
)

valuation_rate = flt(last_valuation_rate[0][0]) if last_valuation_rate else 0
def get_valuation_rate(data):
"""
1) Get average valuation rate from all warehouses
2) If no value, get last valuation rate from SLE
3) If no value, get valuation rate from Item
"""
from frappe.query_builder.functions import Sum

item_code, company = data.get("item_code"), data.get("company")
valuation_rate = 0.0

bin_table = frappe.qb.DocType("Bin")
wh_table = frappe.qb.DocType("Warehouse")
item_valuation = (
frappe.qb.from_(bin_table)
.join(wh_table)
.on(bin_table.warehouse == wh_table.name)
.select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate"))
.where((bin_table.item_code == item_code) & (wh_table.company == company))
).run(as_dict=True)[0]

valuation_rate = item_valuation.get("valuation_rate")

if (valuation_rate is not None) and valuation_rate <= 0:
# Explicit null value check. If None, Bins don't exist, neither does SLE
sle = frappe.qb.DocType("Stock Ledger Entry")
last_val_rate = (
frappe.qb.from_(sle)
.select(sle.valuation_rate)
.where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
.orderby(sle.posting_date, order=frappe.qb.desc)
.orderby(sle.posting_time, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run(as_dict=True)

valuation_rate = flt(last_val_rate[0].get("valuation_rate")) if last_val_rate else 0

if not valuation_rate:
valuation_rate = frappe.db.get_value("Item", args["item_code"], "valuation_rate")
valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate")

return flt(valuation_rate)

Expand Down Expand Up @@ -1125,39 +1157,6 @@ def get_children(parent=None, is_root=False, **filters):
return bom_items


def get_boms_in_bottom_up_order(bom_no=None):
def _get_parent(bom_no):
return frappe.db.sql_list(
"""
select distinct bom_item.parent from `tabBOM Item` bom_item
where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM'
and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1)
""",
bom_no,
)

count = 0
bom_list = []
if bom_no:
bom_list.append(bom_no)
else:
# get all leaf BOMs
bom_list = frappe.db.sql_list(
"""select name from `tabBOM` bom
where docstatus=1 and is_active=1
and not exists(select bom_no from `tabBOM Item`
where parent=bom.name and ifnull(bom_no, '')!='')"""
)

while count < len(bom_list):
for child_bom in _get_parent(bom_list[count]):
if child_bom not in bom_list:
bom_list.append(child_bom)
count += 1

return bom_list


def add_additional_cost(stock_entry, work_order):
# Add non stock items cost in the additional cost
stock_entry.additional_costs = []
Expand Down
27 changes: 17 additions & 10 deletions erpnext/manufacturing/doctype/bom/test_bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query, make_variant_bom
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
update_cost_in_all_boms_in_test,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
Expand Down Expand Up @@ -69,26 +71,31 @@ def _get_default_bom_in_item():

def test_update_bom_cost_in_all_boms(self):
# get current rate for '_Test Item 2'
rm_rate = frappe.db.sql(
"""select rate from `tabBOM Item`
where parent='BOM-_Test Item Home Desktop Manufactured-001'
and item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'"""
bom_rates = frappe.db.get_values(
"BOM Item",
{
"parent": "BOM-_Test Item Home Desktop Manufactured-001",
"item_code": "_Test Item 2",
"docstatus": 1,
},
fieldname=["rate", "base_rate"],
as_dict=True,
)
rm_rate = rm_rate[0][0] if rm_rate else 0
rm_base_rate = bom_rates[0].get("base_rate") if bom_rates else 0

# Reset item valuation rate
reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_rate + 10)
reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_base_rate + 10)

# update cost of all BOMs based on latest valuation rate
update_cost()
update_cost_in_all_boms_in_test()

# check if new valuation rate updated in all BOMs
for d in frappe.db.sql(
"""select rate from `tabBOM Item`
"""select base_rate from `tabBOM Item`
where item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""",
as_dict=1,
):
self.assertEqual(d.rate, rm_rate + 10)
self.assertEqual(d.base_rate, rm_base_rate + 10)

def test_bom_cost(self):
bom = frappe.copy_doc(test_records[2])
Expand Down
1 change: 1 addition & 0 deletions erpnext/manufacturing/doctype/bom/test_records.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"is_active": 1,
"is_default": 1,
"item": "_Test Item Home Desktop Manufactured",
"company": "_Test Company",
"quantity": 1.0
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,15 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-10-08 16:21:29.386212",
"modified": "2022-05-27 13:42:23.305455",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Explosion Item",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
Empty file.
Loading