From 87c2b3be0b2ad09ece570c8ad24d19a54e302f93 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 18 May 2022 13:00:00 +0530 Subject: [PATCH 01/55] perf: `get_boms_in_bottom_up_order` - Create child-parent map once and fetch value from child key to get parents - Get parents recursively for a leaf node (get all ancestors) - Approx. 44 secs for 4lakh 70k boms --- erpnext/manufacturing/doctype/bom/bom.py | 92 +++++++++++++++++------- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index f8fcd073951..007f4bbd8f1 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -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 collections import defaultdict, deque from operator import itemgetter -from typing import List +from typing import List, Optional import frappe from frappe import _ @@ -1120,35 +1120,77 @@ def get_children(doctype, 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, - ) +def get_boms_in_bottom_up_order(bom_no: Optional[str] = None) -> List: + def _generate_child_parent_map(): + bom = frappe.qb.DocType("BOM") + bom_item = frappe.qb.DocType("BOM Item") - count = 0 - bom_list = [] - if bom_no: - bom_list.append(bom_no) - else: - # get all leaf BOMs - bom_list = frappe.db.sql_list( + bom_parents = ( + frappe.qb.from_(bom_item) + .join(bom) + .on(bom_item.parent == bom.name) + .select(bom_item.bom_no, bom_item.parent) + .where( + (bom_item.bom_no.isnotnull()) + & (bom_item.bom_no != "") + & (bom.docstatus == 1) + & (bom.is_active == 1) + & (bom_item.parenttype == "BOM") + ) + ).run(as_dict=True) + + child_parent_map = defaultdict(list) + for bom in bom_parents: + child_parent_map[bom.bom_no].append(bom.parent) + + return child_parent_map + + def _get_flat_parent_map(leaf, child_parent_map): + parents_list = [] + + def _get_parents(node, parents_list): + "Returns updated ancestors list." + first_parents = child_parent_map.get(node) # immediate parents of node + if not first_parents: # top most node + return parents_list + + parents_list.extend(first_parents) + parents_list = list(dict.fromkeys(parents_list).keys()) # remove duplicates + + for nth_node in first_parents: + # recursively find parents + parents_list = _get_parents(nth_node, parents_list) + + return parents_list + + parents_list = _get_parents(leaf, parents_list) + return parents_list + + def _get_leaf_boms(): + return 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 + bom_list = [] + if bom_no: + bom_list.append(bom_no) + else: + bom_list = _get_leaf_boms() + + child_parent_map = _generate_child_parent_map() + + for leaf_bom in bom_list: + # generate list recursively bottom to top + parent_list = _get_flat_parent_map(leaf_bom, child_parent_map) + + if not parent_list: + continue + + bom_list.extend(parent_list) + bom_list = list(dict.fromkeys(bom_list).keys()) # remove duplicates return bom_list From cbc52a2e453e612ab10bf57e74e09ea0680f9a45 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 20:22:13 +0530 Subject: [PATCH 02/55] fix: DB update child items, remove redundancy, fix perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `get_boms_in_bottom_up_order` in bom update tool’s file - Remove repeated rm cost update from `update_cost`. `calculate_cost` handles RM cost update - db_update children in `calculate_cost` optionally - Don’t call `update_exploded_items` and regenerate exploded items in `update_cost`. They will stay the same (except cost) --- erpnext/manufacturing/doctype/bom/bom.py | 121 ++---------------- .../bom_update_tool/bom_update_tool.py | 97 +++++++++++++- 2 files changed, 105 insertions(+), 113 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 007f4bbd8f1..5a6187b346f 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -3,9 +3,9 @@ import functools import re -from collections import defaultdict, deque +from collections import deque from operator import itemgetter -from typing import List, Optional +from typing import List import frappe from frappe import _ @@ -373,35 +373,9 @@ class BOM(WebsiteGenerator): 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() @@ -603,11 +577,11 @@ class BOM(WebsiteGenerator): bom_list.reverse() return bom_list - def calculate_cost(self, update_hour_rate=False): + def calculate_cost(self, save_update=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_update) + self.calculate_sm_cost(save=save_update) 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 @@ -649,7 +623,7 @@ class BOM(WebsiteGenerator): 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 @@ -664,11 +638,13 @@ class BOM(WebsiteGenerator): total_rm_cost += d.amount base_total_rm_cost += d.base_amount + if save: + 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 @@ -683,6 +659,8 @@ class BOM(WebsiteGenerator): ) 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 @@ -1120,81 +1098,6 @@ def get_children(doctype, parent=None, is_root=False, **filters): return bom_items -def get_boms_in_bottom_up_order(bom_no: Optional[str] = None) -> List: - def _generate_child_parent_map(): - bom = frappe.qb.DocType("BOM") - bom_item = frappe.qb.DocType("BOM Item") - - bom_parents = ( - frappe.qb.from_(bom_item) - .join(bom) - .on(bom_item.parent == bom.name) - .select(bom_item.bom_no, bom_item.parent) - .where( - (bom_item.bom_no.isnotnull()) - & (bom_item.bom_no != "") - & (bom.docstatus == 1) - & (bom.is_active == 1) - & (bom_item.parenttype == "BOM") - ) - ).run(as_dict=True) - - child_parent_map = defaultdict(list) - for bom in bom_parents: - child_parent_map[bom.bom_no].append(bom.parent) - - return child_parent_map - - def _get_flat_parent_map(leaf, child_parent_map): - parents_list = [] - - def _get_parents(node, parents_list): - "Returns updated ancestors list." - first_parents = child_parent_map.get(node) # immediate parents of node - if not first_parents: # top most node - return parents_list - - parents_list.extend(first_parents) - parents_list = list(dict.fromkeys(parents_list).keys()) # remove duplicates - - for nth_node in first_parents: - # recursively find parents - parents_list = _get_parents(nth_node, parents_list) - - return parents_list - - parents_list = _get_parents(leaf, parents_list) - return parents_list - - def _get_leaf_boms(): - return 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, '')!='')""" - ) - - bom_list = [] - if bom_no: - bom_list.append(bom_no) - else: - bom_list = _get_leaf_boms() - - child_parent_map = _generate_child_parent_map() - - for leaf_bom in bom_list: - # generate list recursively bottom to top - parent_list = _get_flat_parent_map(leaf_bom, child_parent_map) - - if not parent_list: - continue - - bom_list.extend(parent_list) - bom_list = list(dict.fromkeys(bom_list).keys()) # remove duplicates - - return bom_list - - def add_additional_cost(stock_entry, work_order): # Add non stock items cost in the additional cost stock_entry.additional_costs = [] diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 4061c5af7c2..41922fb8245 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -2,7 +2,8 @@ # For license information, please see license.txt import json -from typing import TYPE_CHECKING, Dict, Optional, Union +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List, Optional, Union from typing_extensions import Literal @@ -12,8 +13,6 @@ if TYPE_CHECKING: import frappe from frappe.model.document import Document -from erpnext.manufacturing.doctype.bom.bom import get_boms_in_bottom_up_order - class BOMUpdateTool(Document): pass @@ -49,7 +48,10 @@ def update_cost() -> None: """Updates Cost for all BOMs from bottom to top.""" bom_list = get_boms_in_bottom_up_order() for bom in bom_list: - frappe.get_doc("BOM", bom).update_cost(update_parent=False, from_child_bom=True) + bom_doc = frappe.get_doc("BOM", bom) + bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) + # bom_doc.update_exploded_items(save=True) #TODO: edit exploded items rate + bom_doc.db_update() def create_bom_update_log( @@ -69,3 +71,90 @@ def create_bom_update_log( "update_type": update_type, } ).submit() + + +def get_boms_in_bottom_up_order(bom_no: Optional[str] = None) -> List: + """ + Eg: Main BOM + |- Sub BOM 1 + |- Leaf BOM 1 + |- Sub BOM 2 + |- Leaf BOM 2 + Result: [Leaf BOM 1, Leaf BOM 2, Sub BOM 1, Sub BOM 2, Main BOM] + """ + leaf_boms = [] + if bom_no: + leaf_boms.append(bom_no) + else: + leaf_boms = _get_leaf_boms() + + child_parent_map = _generate_child_parent_map() + bom_list = leaf_boms.copy() + + for leaf_bom in leaf_boms: + parent_list = _get_flat_parent_map(leaf_bom, child_parent_map) + + if not parent_list: + continue + + bom_list.extend(parent_list) + bom_list = list(dict.fromkeys(bom_list).keys()) # remove duplicates + + return bom_list + + +def _generate_child_parent_map(): + bom = frappe.qb.DocType("BOM") + bom_item = frappe.qb.DocType("BOM Item") + + bom_parents = ( + frappe.qb.from_(bom_item) + .join(bom) + .on(bom_item.parent == bom.name) + .select(bom_item.bom_no, bom_item.parent) + .where( + (bom_item.bom_no.isnotnull()) + & (bom_item.bom_no != "") + & (bom.docstatus == 1) + & (bom.is_active == 1) + & (bom_item.parenttype == "BOM") + ) + ).run(as_dict=True) + + child_parent_map = defaultdict(list) + for bom in bom_parents: + child_parent_map[bom.bom_no].append(bom.parent) + + return child_parent_map + + +def _get_flat_parent_map(leaf, child_parent_map): + "Get ancestors at all levels of a leaf BOM." + parents_list = [] + + def _get_parents(node, parents_list): + "Returns recursively updated ancestors list." + first_parents = child_parent_map.get(node) # immediate parents of node + if not first_parents: # top most node + return parents_list + + parents_list.extend(first_parents) + parents_list = list(dict.fromkeys(parents_list).keys()) # remove duplicates + + for nth_node in first_parents: + # recursively find parents + parents_list = _get_parents(nth_node, parents_list) + + return parents_list + + parents_list = _get_parents(leaf, parents_list) + return parents_list + + +def _get_leaf_boms(): + return 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, '')!='')""" + ) From d035aa2afb7fb124afa1a581d5a0c3f8c21d33ec Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 20:33:48 +0530 Subject: [PATCH 03/55] fix: Call `calculate_cost` for Draft BOM and typo in argument --- erpnext/manufacturing/doctype/bom/bom.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 5a6187b346f..5ea8d3c0546 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -375,7 +375,9 @@ class BOM(WebsiteGenerator): if self.docstatus == 1: self.flags.ignore_validate_update_after_submit = True - self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate) + + self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate) + if save: self.db_update() @@ -577,11 +579,11 @@ class BOM(WebsiteGenerator): bom_list.reverse() return bom_list - def calculate_cost(self, save_update=False, 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(save=save_update) - self.calculate_sm_cost(save=save_update) + self.calculate_rm_cost(save=save_updates) + self.calculate_sm_cost(save=save_updates) 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 From faa69c942b239000227d5b28564dfa4aae75f1be Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 21:24:31 +0530 Subject: [PATCH 04/55] perf: Use cached doc instead of `get_doc` - Doc is only used to iterate over items(which wont change) and change rate/amount of rows - These changes are inserted in db via `db_update`, so no harm - Tested locally: refetching cached doc after db update, reflects fresh data. --- .../manufacturing/doctype/bom_update_tool/bom_update_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 41922fb8245..c308c3f184c 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -48,7 +48,7 @@ def update_cost() -> None: """Updates Cost for all BOMs from bottom to top.""" bom_list = get_boms_in_bottom_up_order() for bom in bom_list: - bom_doc = frappe.get_doc("BOM", bom) + bom_doc = frappe.get_cached_doc("BOM", bom) bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) # bom_doc.update_exploded_items(save=True) #TODO: edit exploded items rate bom_doc.db_update() From 9a513fda74d19cfa9d23bdfcc4c9a23180cd28a4 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 21:48:24 +0530 Subject: [PATCH 05/55] fix: Get fresh RM rate in `calculate_rm_cost` --- erpnext/manufacturing/doctype/bom/bom.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 5ea8d3c0546..f84de82eaa8 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -631,6 +631,18 @@ class BOM(WebsiteGenerator): base_total_rm_cost = 0 for d in self.get("items"): + 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) From b827c3b3c963ed0998bd525a6bb7ded50205c0e6 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 20 May 2022 01:02:56 +0530 Subject: [PATCH 06/55] fix: `test_work_order_with_non_stock_item` - Use the right price list and currency to avoid rate conversion (1000/62.9), since rates are reset correctly now - Use RM rate based on Price List in BOM. Non stock item has no valuation --- .../doctype/bom_update_tool/bom_update_tool.py | 8 ++++---- .../production_plan/test_production_plan.py | 1 - .../doctype/work_order/test_work_order.py | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index c308c3f184c..87900924770 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -76,10 +76,10 @@ def create_bom_update_log( def get_boms_in_bottom_up_order(bom_no: Optional[str] = None) -> List: """ Eg: Main BOM - |- Sub BOM 1 - |- Leaf BOM 1 - |- Sub BOM 2 - |- Leaf BOM 2 + |- Sub BOM 1 + |- Leaf BOM 1 + |- Sub BOM 2 + |- Leaf BOM 2 Result: [Leaf BOM 1, Leaf BOM 2, Sub BOM 1, Sub BOM 2, Main BOM] """ leaf_boms = [] diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index e70f997a53c..dadb8e2781f 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -749,7 +749,6 @@ def make_bom(**args): for item in args.raw_materials: item_doc = frappe.get_doc("Item", item) - bom.append( "items", { diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 7131c335c8a..c88e91e4221 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -414,7 +414,7 @@ class TestWorkOrder(FrappeTestCase): "doctype": "Item Price", "item_code": "_Test FG Non Stock Item", "price_list_rate": 1000, - "price_list": "Standard Buying", + "price_list": "_Test Price List India", } ).insert(ignore_permissions=True) @@ -423,8 +423,17 @@ class TestWorkOrder(FrappeTestCase): item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100 ) - if not frappe.db.get_value("BOM", {"item": fg_item}): - make_bom(item=fg_item, rate=1000, raw_materials=["_Test FG Item", "_Test FG Non Stock Item"]) + if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}): + bom = make_bom( + item=fg_item, + rate=1000, + raw_materials=["_Test FG Item", "_Test FG Non Stock Item"], + do_not_save=True, + ) + bom.rm_cost_as_per = "Price List" # non stock item won't have valuation rate + bom.buying_price_list = "_Test Price List India" + bom.currency = "INR" + bom.save() wo = make_wo_order_test_record(production_item=fg_item) From 74d7d81d6e25698276bdff8d40550411bab91c09 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 23 May 2022 13:00:00 +0530 Subject: [PATCH 07/55] feat: Level-wise BOM cost updation - Process BOMs level wise and Pause after level is complete - Cron job will resume Paused jobs, which will again process the new level and pause at the end - This will go on until all BOMs are updated - Added Progress section with fields to track updated BOMs in Log - Cleanup: Add BOM Updation utils file to contain helper functions/sub-functions - Cleanup: BOM Update Log file will only contain functions that are in direct context of the Log Co-authored-by: Gavin D'souza --- erpnext/hooks.py | 5 +- .../bom_update_log/bom_update_log.json | 29 ++- .../doctype/bom_update_log/bom_update_log.py | 170 ++++++------- .../bom_update_log/bom_updation_utils.py | 223 ++++++++++++++++++ .../bom_update_log/test_bom_update_log.py | 6 +- .../bom_update_tool/bom_update_tool.py | 102 +------- 6 files changed, 335 insertions(+), 200 deletions(-) create mode 100644 erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py diff --git a/erpnext/hooks.py b/erpnext/hooks.py index d798137616a..20b84b5cb90 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -455,9 +455,12 @@ after_migrate = ["erpnext.setup.install.update_select_perm_after_install"] 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", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index 98c1acb71ce..3455b866573 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -13,6 +13,10 @@ "update_type", "status", "error_log", + "progress_section", + "current_boms", + "parent_boms", + "processed_boms", "amended_from" ], "fields": [ @@ -47,7 +51,7 @@ "fieldname": "status", "fieldtype": "Select", "label": "Status", - "options": "Queued\nIn Progress\nCompleted\nFailed" + "options": "Queued\nIn Progress\nPaused\nCompleted\nFailed" }, { "fieldname": "amended_from", @@ -63,13 +67,34 @@ "fieldtype": "Link", "label": "Error Log", "options": "Error Log" + }, + { + "fieldname": "progress_section", + "fieldtype": "Section Break", + "label": "Progress" + }, + { + "fieldname": "current_boms", + "fieldtype": "Text", + "label": "Current BOMs" + }, + { + "description": "Immediate parent BOMs", + "fieldname": "parent_boms", + "fieldtype": "Text", + "label": "Parent BOMs" + }, + { + "fieldname": "processed_boms", + "fieldtype": "Text", + "label": "Processed BOMs" } ], "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-03-31 12:51:44.885102", + "modified": "2022-05-23 14:42:14.725914", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index c3df96c99b1..639628ac383 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -1,14 +1,19 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from typing import Dict, List, Optional +import json +from typing import Dict, Optional import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr, flt -from typing_extensions import Literal +from frappe.utils import cstr -from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost +from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( + get_leaf_boms, + handle_exception, + replace_bom, + set_values_in_log, +) class BOMMissingError(frappe.ValidationError): @@ -50,116 +55,93 @@ class BOMUpdateLog(Document): if self.update_type == "Replace BOM": boms = {"current_bom": self.current_bom, "new_bom": self.new_bom} frappe.enqueue( - method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job", + method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_replace_bom_job", doc=self, boms=boms, timeout=40000, ) else: - frappe.enqueue( - method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job", - doc=self, - update_type="Update Cost", - timeout=40000, - ) + process_boms_cost_level_wise(self) -def replace_bom(boms: Dict) -> None: - """Replace current BOM with new BOM in parent BOMs.""" - current_bom = boms.get("current_bom") - new_bom = boms.get("new_bom") - - unit_cost = get_new_bom_unit_cost(new_bom) - update_new_bom_in_bom_items(unit_cost, current_bom, new_bom) - - frappe.cache().delete_key("bom_children") - parent_boms = get_parent_boms(new_bom) - - for bom in parent_boms: - bom_obj = frappe.get_doc("BOM", bom) - # this is only used for versioning and we do not want - # to make separate db calls by using load_doc_before_save - # which proves to be expensive while doing bulk replace - bom_obj._doc_before_save = bom_obj - bom_obj.update_exploded_items() - bom_obj.calculate_cost() - bom_obj.update_parent_cost() - bom_obj.db_update() - if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version: - bom_obj.save_version() - - -def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None: - bom_item = frappe.qb.DocType("BOM Item") - ( - frappe.qb.update(bom_item) - .set(bom_item.bom_no, new_bom) - .set(bom_item.rate, unit_cost) - .set(bom_item.amount, (bom_item.stock_qty * unit_cost)) - .where( - (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM") - ) - ).run() - - -def get_parent_boms(new_bom: str, bom_list: Optional[List] = None) -> List: - bom_list = bom_list or [] - bom_item = frappe.qb.DocType("BOM Item") - - parents = ( - frappe.qb.from_(bom_item) - .select(bom_item.parent) - .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")) - .run(as_dict=True) - ) - - for d in parents: - if new_bom == d.parent: - frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent)) - - bom_list.append(d.parent) - get_parent_boms(d.parent, bom_list) - - return list(set(bom_list)) - - -def get_new_bom_unit_cost(new_bom: str) -> float: - bom = frappe.qb.DocType("BOM") - new_bom_unitcost = ( - frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run() - ) - - return flt(new_bom_unitcost[0][0]) - - -def run_bom_job( +def run_replace_bom_job( doc: "BOMUpdateLog", boms: Optional[Dict[str, str]] = None, - update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM", ) -> None: try: doc.db_set("status", "In Progress") + if not frappe.flags.in_test: frappe.db.commit() frappe.db.auto_commit_on_many_writes = 1 - boms = frappe._dict(boms or {}) - - if update_type == "Replace BOM": - replace_bom(boms) - else: - update_cost() + replace_bom(boms) doc.db_set("status", "Completed") - except Exception: - frappe.db.rollback() - error_log = frappe.log_error(message=frappe.get_traceback(), title=_("BOM Update Tool Error")) - - doc.db_set("status", "Failed") - doc.db_set("error_log", error_log.name) - + handle_exception(doc) finally: frappe.db.auto_commit_on_many_writes = 0 frappe.db.commit() # nosemgrep + + +def process_boms_cost_level_wise(update_doc: "BOMUpdateLog") -> None: + "Queue jobs at the start of new BOM Level in 'Update Cost' Jobs." + + current_boms, parent_boms = {}, [] + values = {} + + if update_doc.status == "Queued": + # First level yet to process. On Submit. + current_boms = {bom: False for bom in get_leaf_boms()} + values = { + "current_boms": json.dumps(current_boms), + "parent_boms": "[]", + "processed_boms": json.dumps({}), + "status": "In Progress", + } + else: + # status is Paused, resume. via Cron Job. + current_boms, parent_boms = json.loads(update_doc.current_boms), json.loads( + update_doc.parent_boms + ) + if not current_boms: + # Process the next level BOMs. Stage parents as current BOMs. + current_boms = {bom: False for bom in parent_boms} + values = { + "current_boms": json.dumps(current_boms), + "parent_boms": "[]", + "status": "In Progress", + } + + set_values_in_log(update_doc.name, values, commit=True) + queue_bom_cost_jobs(current_boms, update_doc) + + +def queue_bom_cost_jobs(current_boms: Dict, update_doc: "BOMUpdateLog") -> None: + "Queue batches of 20k BOMs of the same level to process parallelly" + current_boms_list = [bom for bom in current_boms] + + while current_boms_list: + boms_to_process = current_boms_list[:20000] # slice out batch of 20k BOMs + + # update list to exclude 20K (queued) BOMs + current_boms_list = current_boms_list[20000:] if len(current_boms_list) > 20000 else [] + frappe.enqueue( + method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level", + doc=update_doc, + bom_list=boms_to_process, + timeout=40000, + ) + + +def resume_bom_cost_update_jobs(): + "Called every 10 minutes via Cron job." + paused_jobs = frappe.db.get_all("BOM Update Log", {"status": "Paused"}) + if not paused_jobs: + return + + for job in paused_jobs: + # resume from next level + process_boms_cost_level_wise(update_doc=frappe.get_doc("BOM Update Log", job.name)) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py new file mode 100644 index 00000000000..b5964cec9d4 --- /dev/null +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -0,0 +1,223 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List, Optional + +if TYPE_CHECKING: + from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog + +import frappe +from frappe import _ + + +def replace_bom(boms: Dict) -> None: + """Replace current BOM with new BOM in parent BOMs.""" + current_bom = boms.get("current_bom") + new_bom = boms.get("new_bom") + + unit_cost = get_bom_unit_cost(new_bom) + update_new_bom_in_bom_items(unit_cost, current_bom, new_bom) + + frappe.cache().delete_key("bom_children") + parent_boms = get_ancestor_boms(new_bom) + + for bom in parent_boms: + bom_obj = frappe.get_doc("BOM", bom) + # this is only used for versioning and we do not want + # to make separate db calls by using load_doc_before_save + # which proves to be expensive while doing bulk replace + bom_obj._doc_before_save = bom_obj + bom_obj.update_exploded_items() + bom_obj.calculate_cost() + bom_obj.update_parent_cost() + bom_obj.db_update() + if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version: + bom_obj.save_version() + + +def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str]) -> None: + "Updates Cost for BOMs within a given level. Runs via background jobs." + try: + status = frappe.db.get_value("BOM Update Log", doc.name, "status") + if status == "Failed": + return + + frappe.db.auto_commit_on_many_writes = 1 + # main updation logic + job_data = update_cost_in_boms(bom_list=bom_list, docname=doc.name) + + set_values_in_log( + doc.name, + values={ + "current_boms": json.dumps(job_data.get("current_boms")), + "processed_boms": json.dumps(job_data.get("processed_boms")), + }, + commit=True, + ) + + process_if_level_is_complete(doc.name, job_data["current_boms"], job_data["processed_boms"]) + except Exception: + handle_exception(doc) + finally: + frappe.db.auto_commit_on_many_writes = 0 + frappe.db.commit() # nosemgrep + + +def get_ancestor_boms(new_bom: str, bom_list: Optional[List] = None) -> List: + bom_list = bom_list or [] + bom_item = frappe.qb.DocType("BOM Item") + + parents = ( + frappe.qb.from_(bom_item) + .select(bom_item.parent) + .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")) + .run(as_dict=True) + ) + + for d in parents: + if new_bom == d.parent: + frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent)) + + bom_list.append(d.parent) + get_ancestor_boms(d.parent, bom_list) + + return list(set(bom_list)) + + +def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None: + bom_item = frappe.qb.DocType("BOM Item") + ( + frappe.qb.update(bom_item) + .set(bom_item.bom_no, new_bom) + .set(bom_item.rate, unit_cost) + .set(bom_item.amount, (bom_item.stock_qty * unit_cost)) + .where( + (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM") + ) + ).run() + + +def get_bom_unit_cost(new_bom: str) -> float: + bom = frappe.qb.DocType("BOM") + new_bom_unitcost = ( + frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run() + ) + + return frappe.utils.flt(new_bom_unitcost[0][0]) + + +def update_cost_in_boms(bom_list: List[str], docname: str) -> Dict: + "Updates cost in given BOMs. Returns current and total updated BOMs." + updated_boms = {} # current boms that have been updated + + for bom in bom_list: + bom_doc = frappe.get_cached_doc("BOM", bom) + bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) + # bom_doc.update_exploded_items(save=True) #TODO: edit exploded items rate + bom_doc.db_update() + updated_boms[bom] = True + + # Update processed BOMs in Log + log_data = frappe.db.get_values( + "BOM Update Log", docname, ["current_boms", "processed_boms"], as_dict=True + )[0] + + for field in ("current_boms", "processed_boms"): + log_data[field] = json.loads(log_data.get(field)) + log_data[field].update(updated_boms) + + return log_data + + +def process_if_level_is_complete(docname: str, current_boms: Dict, processed_boms: Dict) -> None: + "Prepare and set higher level BOMs in Log if current level is complete." + processing_complete = all(current_boms.get(bom) for bom in current_boms) + if not processing_complete: + return + + parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) + set_values_in_log( + docname, + values={ + "current_boms": json.dumps({}), + "parent_boms": json.dumps(parent_boms), + "status": "Completed" if not parent_boms else "Paused", + }, + commit=True, + ) + + +def get_next_higher_level_boms(child_boms: Dict, processed_boms: Dict): + "Generate immediate higher level dependants with no unresolved dependencies." + + def _all_children_are_processed(parent): + bom_doc = frappe.get_cached_doc("BOM", parent) + return all(processed_boms.get(row.bom_no) for row in bom_doc.items if row.bom_no) + + dependants_map = _generate_dependants_map() + dependants = set() + for bom in child_boms: + parents = dependants_map.get(bom) or [] + for parent in parents: + if _all_children_are_processed(parent): + dependants.add(parent) + + return list(dependants) + + +def get_leaf_boms(): + return 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, '')!='')""" + ) + + +def _generate_dependants_map(): + bom = frappe.qb.DocType("BOM") + bom_item = frappe.qb.DocType("BOM Item") + + bom_parents = ( + frappe.qb.from_(bom_item) + .join(bom) + .on(bom_item.parent == bom.name) + .select(bom_item.bom_no, bom_item.parent) + .where( + (bom_item.bom_no.isnotnull()) + & (bom_item.bom_no != "") + & (bom.docstatus == 1) + & (bom.is_active == 1) + & (bom_item.parenttype == "BOM") + ) + ).run(as_dict=True) + + child_parent_map = defaultdict(list) + for bom in bom_parents: + child_parent_map[bom.bom_no].append(bom.parent) + + return child_parent_map + + +def set_values_in_log(log_name: str, values: Dict, commit: bool = False) -> None: + "Update BOM Update Log record." + if not values: + return + + bom_update_log = frappe.qb.DocType("BOM Update Log") + query = frappe.qb.update(bom_update_log).where(bom_update_log.name == log_name) + + for key, value in values.items(): + query = query.set(key, value) + query.run() + + if commit: + frappe.db.commit() + + +def handle_exception(doc: "BOMUpdateLog"): + frappe.db.rollback() + error_log = doc.log_error("BOM Update Tool Error") + set_values_in_log(doc.name, {"status": "Failed", "error_log": error_log.name}) diff --git a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py index 47efea961b4..4f151334a2a 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py @@ -6,7 +6,7 @@ from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import ( BOMMissingError, - run_bom_job, + run_replace_bom_job, ) from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom @@ -71,7 +71,7 @@ class TestBOMUpdateLog(FrappeTestCase): # Explicitly commits log, new bom (setUp) and replacement impact. # Is run via background jobs IRL - run_bom_job( + run_replace_bom_job( doc=log, boms=self.boms, update_type="Replace BOM", @@ -88,7 +88,7 @@ class TestBOMUpdateLog(FrappeTestCase): log2 = enqueue_replace_bom( boms=self.boms, ) - run_bom_job( # Explicitly commits + run_replace_bom_job( # Explicitly commits doc=log2, boms=boms, update_type="Replace BOM", diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 87900924770..3b472375b4b 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -2,8 +2,7 @@ # For license information, please see license.txt import json -from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Dict, Optional, Union from typing_extensions import Literal @@ -41,17 +40,7 @@ def enqueue_update_cost() -> "BOMUpdateLog": def auto_update_latest_price_in_all_boms() -> None: """Called via hooks.py.""" if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"): - update_cost() - - -def update_cost() -> None: - """Updates Cost for all BOMs from bottom to top.""" - bom_list = get_boms_in_bottom_up_order() - for bom in bom_list: - bom_doc = frappe.get_cached_doc("BOM", bom) - bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) - # bom_doc.update_exploded_items(save=True) #TODO: edit exploded items rate - bom_doc.db_update() + create_bom_update_log(update_type="Update Cost") def create_bom_update_log( @@ -71,90 +60,3 @@ def create_bom_update_log( "update_type": update_type, } ).submit() - - -def get_boms_in_bottom_up_order(bom_no: Optional[str] = None) -> List: - """ - Eg: Main BOM - |- Sub BOM 1 - |- Leaf BOM 1 - |- Sub BOM 2 - |- Leaf BOM 2 - Result: [Leaf BOM 1, Leaf BOM 2, Sub BOM 1, Sub BOM 2, Main BOM] - """ - leaf_boms = [] - if bom_no: - leaf_boms.append(bom_no) - else: - leaf_boms = _get_leaf_boms() - - child_parent_map = _generate_child_parent_map() - bom_list = leaf_boms.copy() - - for leaf_bom in leaf_boms: - parent_list = _get_flat_parent_map(leaf_bom, child_parent_map) - - if not parent_list: - continue - - bom_list.extend(parent_list) - bom_list = list(dict.fromkeys(bom_list).keys()) # remove duplicates - - return bom_list - - -def _generate_child_parent_map(): - bom = frappe.qb.DocType("BOM") - bom_item = frappe.qb.DocType("BOM Item") - - bom_parents = ( - frappe.qb.from_(bom_item) - .join(bom) - .on(bom_item.parent == bom.name) - .select(bom_item.bom_no, bom_item.parent) - .where( - (bom_item.bom_no.isnotnull()) - & (bom_item.bom_no != "") - & (bom.docstatus == 1) - & (bom.is_active == 1) - & (bom_item.parenttype == "BOM") - ) - ).run(as_dict=True) - - child_parent_map = defaultdict(list) - for bom in bom_parents: - child_parent_map[bom.bom_no].append(bom.parent) - - return child_parent_map - - -def _get_flat_parent_map(leaf, child_parent_map): - "Get ancestors at all levels of a leaf BOM." - parents_list = [] - - def _get_parents(node, parents_list): - "Returns recursively updated ancestors list." - first_parents = child_parent_map.get(node) # immediate parents of node - if not first_parents: # top most node - return parents_list - - parents_list.extend(first_parents) - parents_list = list(dict.fromkeys(parents_list).keys()) # remove duplicates - - for nth_node in first_parents: - # recursively find parents - parents_list = _get_parents(nth_node, parents_list) - - return parents_list - - parents_list = _get_parents(leaf, parents_list) - return parents_list - - -def _get_leaf_boms(): - return 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, '')!='')""" - ) From 196a824c4f8ffc69248905d4cb5c07f9c3f8356e Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 24 May 2022 18:17:40 +0530 Subject: [PATCH 08/55] style: Update docstrings and fix/add type hints + Collapsible progress section in Log --- .../bom_update_log/bom_update_log.json | 3 +- .../bom_update_log/bom_updation_utils.py | 45 ++++++++++++++----- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index 3455b866573..db5f58d04f5 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -69,6 +69,7 @@ "options": "Error Log" }, { + "collapsible": 1, "fieldname": "progress_section", "fieldtype": "Section Break", "label": "Progress" @@ -94,7 +95,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-05-23 14:42:14.725914", + "modified": "2022-05-24 17:52:21.824710", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index b5964cec9d4..d246d3064f5 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -3,7 +3,7 @@ import json from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog @@ -13,7 +13,8 @@ from frappe import _ def replace_bom(boms: Dict) -> None: - """Replace current BOM with new BOM in parent BOMs.""" + "Replace current BOM with new BOM in parent BOMs." + current_bom = boms.get("current_bom") new_bom = boms.get("new_bom") @@ -39,6 +40,7 @@ def replace_bom(boms: Dict) -> None: def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str]) -> None: "Updates Cost for BOMs within a given level. Runs via background jobs." + try: status = frappe.db.get_value("BOM Update Log", doc.name, "status") if status == "Failed": @@ -66,6 +68,8 @@ def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str]) -> None: def get_ancestor_boms(new_bom: str, bom_list: Optional[List] = None) -> List: + "Recursively get all ancestors of BOM." + bom_list = bom_list or [] bom_item = frappe.qb.DocType("BOM Item") @@ -99,17 +103,18 @@ def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str ).run() -def get_bom_unit_cost(new_bom: str) -> float: +def get_bom_unit_cost(bom_name: str) -> float: bom = frappe.qb.DocType("BOM") new_bom_unitcost = ( - frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run() + frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == bom_name).run() ) return frappe.utils.flt(new_bom_unitcost[0][0]) -def update_cost_in_boms(bom_list: List[str], docname: str) -> Dict: +def update_cost_in_boms(bom_list: List[str], docname: str) -> Dict[str, Dict]: "Updates cost in given BOMs. Returns current and total updated BOMs." + updated_boms = {} # current boms that have been updated for bom in bom_list: @@ -131,8 +136,11 @@ def update_cost_in_boms(bom_list: List[str], docname: str) -> Dict: return log_data -def process_if_level_is_complete(docname: str, current_boms: Dict, processed_boms: Dict) -> None: - "Prepare and set higher level BOMs in Log if current level is complete." +def process_if_level_is_complete( + docname: str, current_boms: Dict[str, bool], processed_boms: Dict[str, bool] +) -> None: + "Prepare and set higher level BOMs/dependants in Log if current level is complete." + processing_complete = all(current_boms.get(bom) for bom in current_boms) if not processing_complete: return @@ -149,7 +157,9 @@ def process_if_level_is_complete(docname: str, current_boms: Dict, processed_bom ) -def get_next_higher_level_boms(child_boms: Dict, processed_boms: Dict): +def get_next_higher_level_boms( + child_boms: Dict[str, bool], processed_boms: Dict[str, bool] +) -> List[str]: "Generate immediate higher level dependants with no unresolved dependencies." def _all_children_are_processed(parent): @@ -167,7 +177,9 @@ def get_next_higher_level_boms(child_boms: Dict, processed_boms: Dict): return list(dependants) -def get_leaf_boms(): +def get_leaf_boms() -> List[str]: + "Get BOMs that have no dependencies." + return frappe.db.sql_list( """select name from `tabBOM` bom where docstatus=1 and is_active=1 @@ -176,7 +188,13 @@ def get_leaf_boms(): ) -def _generate_dependants_map(): +def _generate_dependants_map() -> defaultdict: + """ + Generate map such as: { BOM-1: [Dependant-BOM-1, Dependant-BOM-2, ..] }. + Here BOM-1 is the leaf/lower level node/dependency. + The list contains one level higher nodes/dependants that depend on BOM-1. + """ + bom = frappe.qb.DocType("BOM") bom_item = frappe.qb.DocType("BOM Item") @@ -201,8 +219,9 @@ def _generate_dependants_map(): return child_parent_map -def set_values_in_log(log_name: str, values: Dict, commit: bool = False) -> None: +def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = False) -> None: "Update BOM Update Log record." + if not values: return @@ -217,7 +236,9 @@ def set_values_in_log(log_name: str, values: Dict, commit: bool = False) -> None frappe.db.commit() -def handle_exception(doc: "BOMUpdateLog"): +def handle_exception(doc: "BOMUpdateLog") -> None: + "Rolls back and fails BOM Update Log." + frappe.db.rollback() error_log = doc.log_error("BOM Update Tool Error") set_values_in_log(doc.name, {"status": "Failed", "error_log": error_log.name}) From a3f2cf3917a16638f9f4c4f32d0416a902e4d9b0 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Mon, 23 May 2022 16:01:36 +0200 Subject: [PATCH 09/55] feat: Add german translations (cherry picked from commit 2388d8662323cfc6de081a03244f68b3c880681c) # Conflicts: # erpnext/translations/de.csv --- erpnext/translations/de.csv | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 545d0dde044..28fbd2fd828 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -783,6 +783,10 @@ Default Activity Cost exists for Activity Type - {0},Es gibt Standard-Aktivität Default BOM ({0}) must be active for this item or its template,Standardstückliste ({0}) muss für diesen Artikel oder dessen Vorlage aktiv sein, Default BOM for {0} not found,Standardstückliste für {0} nicht gefunden, Default BOM not found for Item {0} and Project {1},Standard-Stückliste nicht gefunden für Position {0} und Projekt {1}, +<<<<<<< HEAD +======= +Default In-Transit Warehouse,Standard-Durchgangslager, +>>>>>>> 2388d86623 (feat: Add german translations) Default Letter Head,Standardbriefkopf, Default Tax Template,Standardsteuervorlage, Default Unit of Measure for Item {0} cannot be changed directly because you have already made some transaction(s) with another UOM. You will need to create a new Item to use a different Default UOM.,"Die Standard-Maßeinheit für Artikel {0} kann nicht direkt geändert werden, weil Sie bereits einige Transaktionen mit einer anderen Maßeinheit durchgeführt haben. Sie müssen einen neuen Artikel erstellen, um eine andere Standard-Maßeinheit verwenden zukönnen.", @@ -7645,7 +7649,7 @@ Campaign Schedules,Kampagnenpläne, Buyer of Goods and Services.,Käufer von Waren und Dienstleistungen., CUST-.YYYY.-,CUST-.YYYY.-, Default Company Bank Account,Standard-Bankkonto des Unternehmens, -From Lead,Von Lead, +From Lead,Aus Lead, Account Manager,Buchhalter, Allow Sales Invoice Creation Without Sales Order,Ermöglichen Sie die Erstellung von Kundenrechnungen ohne Kundenauftrag, Allow Sales Invoice Creation Without Delivery Note,Ermöglichen Sie die Erstellung einer Verkaufsrechnung ohne Lieferschein, @@ -9843,3 +9847,24 @@ Row #{}: You must select {} serial numbers for item {}.,Zeile # {}: Sie müssen {} Available,{} Verfügbar, Report an Issue,Ein Problem melden, User Forum,Anwenderforum, +Get Customer Group Details,Einstellungen aus Kundengruppe übernehmen, +Is Rate Adjustment Entry (Debit Note),Ist Preisanpassung (Belastungsanzeige), +Fetch Timesheet,Zeiterfassung laden, +Company Tax ID,Eigene Steuernummer, +Quotation Number,Angebotsnummer, +Company Shipping Address,Eigene Lieferadresse, +Company Billing Address,Eigene Rechnungsadresse, +Billing Address Details,Vorschau Rechnungsadresse, +Supplier Contact,Lieferantenkontakt, +Order Status,Bestellstatus, +Invoice Portion (%),Rechnungsanteil (%), +Discount Settings,Rabatt-Einstellungen, +Payment Amount (Company Currency),Zahlungsbetrag (Unternehmenswährung), +Putaway Rule,Einlagerungsregel, +Apply Putaway Rule,Einlagerungsregel anwenden, +Default Discount Account,Standard-Rabattkonto, +Default Provisional Account,Standard Provisorisches Konto, +Leave Type Allocation,Zuordnung Abwesenheitsarten, +From Lead,Aus Lead, +From Opportunity,Aus Chance, +Publish in Website,Auf Webseite veröffentlichen, From a26da58718d1ef93500ce7f93b16da80fce5a375 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 25 May 2022 15:32:42 +0530 Subject: [PATCH 10/55] feat: Only update exploded items rate and amount - Generate RM-Rate map from Items table (will include subassembly items with rate) - Function to reset exploded item rate from above map - `db_update` exploded item rate only if rate is changed - Via Update Cost, only update exploded items rate, do not regenerate table again - Exploded Items are regenerated on Save and Replace BOM job - `calculate_exploded_cost` is run only via non doc events (Update Cost button, Update BOMs Cost Job) --- erpnext/manufacturing/doctype/bom/bom.py | 39 +++++++++++++++++-- .../bom_explosion_item.json | 4 +- .../bom_update_log/bom_updation_utils.py | 1 - 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index f84de82eaa8..9aff47cd598 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -5,7 +5,7 @@ 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 _ @@ -185,6 +185,7 @@ class BOM(WebsiteGenerator): 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.validate_scrap_items() @@ -381,8 +382,6 @@ class BOM(WebsiteGenerator): 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( @@ -584,6 +583,10 @@ class BOM(WebsiteGenerator): self.calculate_op_cost(update_hour_rate) 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 @@ -679,6 +682,36 @@ class BOM(WebsiteGenerator): 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) * row.rate + + if old_rate != row.rate: + # Only db_update if unchanged + 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.db.get_all( + "BOM Explosion Item", filters={"parent": item.bom_no}, fields=["item_code", "rate"] + ) + 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() diff --git a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json index f01d856e72a..9b1db63494b 100644 --- a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json +++ b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json @@ -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 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index d246d3064f5..1ec15f0d3ae 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -120,7 +120,6 @@ def update_cost_in_boms(bom_list: List[str], docname: str) -> Dict[str, Dict]: for bom in bom_list: bom_doc = frappe.get_cached_doc("BOM", bom) bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) - # bom_doc.update_exploded_items(save=True) #TODO: edit exploded items rate bom_doc.db_update() updated_boms[bom] = True From 12f0a9a183509a1fe44ddb4a73805dc52a59716e Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 May 2022 17:04:21 +0530 Subject: [PATCH 11/55] chore: Change BOM Progress field types to Long Text --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- .../doctype/bom_update_log/bom_update_log.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 9aff47cd598..e459f5f67f4 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -692,7 +692,7 @@ class BOM(WebsiteGenerator): row.amount = flt(row.stock_qty) * row.rate if old_rate != row.rate: - # Only db_update if unchanged + # Only db_update if changed row.db_update() def get_rm_rate_map(self) -> Dict[str, float]: diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index db5f58d04f5..bea3cf03733 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -76,18 +76,18 @@ }, { "fieldname": "current_boms", - "fieldtype": "Text", + "fieldtype": "Long Text", "label": "Current BOMs" }, { "description": "Immediate parent BOMs", "fieldname": "parent_boms", - "fieldtype": "Text", + "fieldtype": "Long Text", "label": "Parent BOMs" }, { "fieldname": "processed_boms", - "fieldtype": "Text", + "fieldtype": "Long Text", "label": "Processed BOMs" } ], @@ -95,7 +95,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-05-24 17:52:21.824710", + "modified": "2022-05-27 17:03:34.712010", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", From 767a7757095e369da68e017f666c4896fa3cc6a2 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 May 2022 20:33:14 +0530 Subject: [PATCH 12/55] perf: `get_next_higher_level_boms` - Separate getting dependants and checking if they are valid (loop within loop led to redundant processing that slowed down function) - Adding to above, the same dependant(parent) was repeatedly processed as many children shared it. Expensive. - Use a parent-child map similar to child-parent map to check if all children are resolved - `map.get()` reduced time: 10 mins -> 0.9s~1 second (as compared to `get_cached_doc` or query) - Total time: 17 seconds to process 6599 leaf boms and 4.2L parent boms - Previous Total time: >10 mins (I terminated it due to not wanting to waste time XD) --- .../bom_update_log/bom_updation_utils.py | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index 1ec15f0d3ae..790a79b3333 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -159,21 +159,29 @@ def process_if_level_is_complete( def get_next_higher_level_boms( child_boms: Dict[str, bool], processed_boms: Dict[str, bool] ) -> List[str]: - "Generate immediate higher level dependants with no unresolved dependencies." + "Generate immediate higher level dependants with no unresolved dependencies (children)." - def _all_children_are_processed(parent): - bom_doc = frappe.get_cached_doc("BOM", parent) - return all(processed_boms.get(row.bom_no) for row in bom_doc.items if row.bom_no) + def _all_children_are_processed(parent_bom): + child_boms = dependency_map.get(parent_bom) + return all(processed_boms.get(bom) for bom in child_boms) - dependants_map = _generate_dependants_map() - dependants = set() + dependants_map, dependency_map = _generate_dependence_map() + + dependants = [] for bom in child_boms: + # generate list of immediate dependants parents = dependants_map.get(bom) or [] - for parent in parents: - if _all_children_are_processed(parent): - dependants.add(parent) + dependants.extend(parents) - return list(dependants) + dependants = set(dependants) # remove duplicates + resolved_dependants = set() + + # consider only if children are all resolved + for parent_bom in dependants: + if _all_children_are_processed(parent_bom): + resolved_dependants.add(parent_bom) + + return list(resolved_dependants) def get_leaf_boms() -> List[str]: @@ -187,17 +195,19 @@ def get_leaf_boms() -> List[str]: ) -def _generate_dependants_map() -> defaultdict: +def _generate_dependence_map() -> defaultdict: """ - Generate map such as: { BOM-1: [Dependant-BOM-1, Dependant-BOM-2, ..] }. + Generate maps such as: { BOM-1: [Dependant-BOM-1, Dependant-BOM-2, ..] }. Here BOM-1 is the leaf/lower level node/dependency. The list contains one level higher nodes/dependants that depend on BOM-1. + + Generate and return the reverse as well. """ bom = frappe.qb.DocType("BOM") bom_item = frappe.qb.DocType("BOM Item") - bom_parents = ( + bom_items = ( frappe.qb.from_(bom_item) .join(bom) .on(bom_item.parent == bom.name) @@ -212,10 +222,12 @@ def _generate_dependants_map() -> defaultdict: ).run(as_dict=True) child_parent_map = defaultdict(list) - for bom in bom_parents: - child_parent_map[bom.bom_no].append(bom.parent) + parent_child_map = defaultdict(list) + for row in bom_items: + child_parent_map[row.bom_no].append(row.parent) + parent_child_map[row.parent].append(row.bom_no) - return child_parent_map + return child_parent_map, parent_child_map def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = False) -> None: From 6d65e2bab41e09a0603463e42b581ebc96abae53 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 May 2022 21:59:59 +0530 Subject: [PATCH 13/55] fix: Safe cast `row.rate` (in case of faulty exploded items, edge case but oh well) --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index e459f5f67f4..78c41604d34 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -689,7 +689,7 @@ class BOM(WebsiteGenerator): 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) * row.rate + row.amount = flt(row.stock_qty) * flt(row.rate) if old_rate != row.rate: # Only db_update if changed From bced6a07b4e1d9e3261703d6d31667b3b19609cf Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 May 2022 22:06:12 +0530 Subject: [PATCH 14/55] fix: (auto-merge) Use `frappe.log_error` instead of `doc.log_error` - The latter is only on develop --- .../manufacturing/doctype/bom_update_log/bom_updation_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index 790a79b3333..93a15deb154 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -251,5 +251,5 @@ def handle_exception(doc: "BOMUpdateLog") -> None: "Rolls back and fails BOM Update Log." frappe.db.rollback() - error_log = doc.log_error("BOM Update Tool Error") + error_log = frappe.log_error(title=_("BOM Update Tool Error")) set_values_in_log(doc.name, {"status": "Failed", "error_log": error_log.name}) From e6ad56cd68ee2f9a3b235cfbb7b7b34b3e27ca43 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 31 May 2022 15:53:34 +0530 Subject: [PATCH 15/55] chore: Limit Update Cost jobs & `db_update` only if changed values - If `Update Cost` job is ongoing, then block creation of new ones since all BOMs are updated - `db_update` in `calculate_rm_cost` only if changed values to reduce redundant row updates - Misc: Use variable for batch size --- erpnext/manufacturing/doctype/bom/bom.py | 4 +++- .../doctype/bom_update_log/bom_update_log.py | 22 +++++++++++++++++-- .../bom_update_tool/bom_update_tool.py | 8 ++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 78c41604d34..15efe0d7b3b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -634,6 +634,7 @@ class BOM(WebsiteGenerator): base_total_rm_cost = 0 for d in self.get("items"): + old_rate = d.rate d.rate = self.get_rm_rate( { "company": self.company, @@ -646,6 +647,7 @@ class BOM(WebsiteGenerator): "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) @@ -655,7 +657,7 @@ class BOM(WebsiteGenerator): total_rm_cost += d.amount base_total_rm_cost += d.base_amount - if save: + if save and (old_rate != d.rate): d.db_update() self.raw_material_cost = total_rm_cost diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index 639628ac383..f61f863c10a 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -26,6 +26,8 @@ class BOMUpdateLog(Document): self.validate_boms_are_specified() self.validate_same_bom() self.validate_bom_items() + else: + self.validate_bom_cost_update_in_progress() self.status = "Queued" @@ -48,6 +50,21 @@ class BOMUpdateLog(Document): if current_bom_item != new_bom_item: frappe.throw(_("The selected BOMs are not for the same item")) + def validate_bom_cost_update_in_progress(self): + "If another Cost Updation Log is still in progress, dont make new ones." + + wip_log = frappe.get_all( + "BOM Update Log", + {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress", "Paused"]]}, + limit_page_length=1, + ) + if wip_log: + log_link = frappe.utils.get_link_to_form("BOM Update Log", wip_log[0].name) + frappe.throw( + _("BOM Updation already in progress. Please wait until {0} is complete.").format(log_link), + title=_("Note"), + ) + def on_submit(self): if frappe.flags.in_test: return @@ -124,10 +141,11 @@ def queue_bom_cost_jobs(current_boms: Dict, update_doc: "BOMUpdateLog") -> None: current_boms_list = [bom for bom in current_boms] while current_boms_list: - boms_to_process = current_boms_list[:20000] # slice out batch of 20k BOMs + batch_size = 20_000 + boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs # update list to exclude 20K (queued) BOMs - current_boms_list = current_boms_list[20000:] if len(current_boms_list) > 20000 else [] + current_boms_list = current_boms_list[batch_size:] if len(current_boms_list) > batch_size else [] frappe.enqueue( method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level", doc=update_doc, diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 3b472375b4b..6b8da9c914f 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -40,7 +40,13 @@ def enqueue_update_cost() -> "BOMUpdateLog": def auto_update_latest_price_in_all_boms() -> None: """Called via hooks.py.""" if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"): - create_bom_update_log(update_type="Update Cost") + wip_log = frappe.get_all( + "BOM Update Log", + {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress", "Paused"]]}, + limit_page_length=1, + ) + if not wip_log: + create_bom_update_log(update_type="Update Cost") def create_bom_update_log( From 3b2a8bf837b62b68a2422ddd01335e2f7045c3d1 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 2 Jun 2022 13:35:30 +0530 Subject: [PATCH 16/55] feat: Track progress in Log Batch/Job wise - This was done due to stale reads while the background jobs tried updating status of the log - Added a table where all bom jobs within log will be tracked with what level they are processing - Cron job will check if table jobs are all processed every 5 mins - If yes, it will prepare parents and call `process_boms_cost_level_wise` to start next level - If pending jobs, do nothing - Current BOM Level is being tracked that helps adding rows to the table - Individual bom cost jobs (that are queued) will process and update boms > will update BOM Update Batch table row with list of updated BOMs --- .../doctype/bom_update_batch/__init__.py | 0 .../bom_update_batch/bom_update_batch.json | 45 ++++++++ .../bom_update_batch/bom_update_batch.py | 9 ++ .../bom_update_log/bom_update_log.json | 21 ++-- .../doctype/bom_update_log/bom_update_log.py | 106 +++++++++++++----- .../bom_update_log/bom_updation_utils.py | 55 +-------- 6 files changed, 154 insertions(+), 82 deletions(-) create mode 100644 erpnext/manufacturing/doctype/bom_update_batch/__init__.py create mode 100644 erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json create mode 100644 erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py diff --git a/erpnext/manufacturing/doctype/bom_update_batch/__init__.py b/erpnext/manufacturing/doctype/bom_update_batch/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json new file mode 100644 index 00000000000..9938454ce4e --- /dev/null +++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json @@ -0,0 +1,45 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2022-05-31 17:34:39.825537", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "level", + "batch_no", + "boms_updated" + ], + "fields": [ + { + "fieldname": "level", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Level" + }, + { + "fieldname": "batch_no", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Batch No." + }, + { + "fieldname": "boms_updated", + "fieldtype": "Long Text", + "in_list_view": 1, + "label": "BOMs Updated" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-05-31 23:36:13.628391", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "BOM Update Batch", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py new file mode 100644 index 00000000000..f952e435e67 --- /dev/null +++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BOMUpdateBatch(Document): + pass diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index bea3cf03733..b1c24ab9954 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -14,9 +14,10 @@ "status", "error_log", "progress_section", - "current_boms", + "current_level", "parent_boms", "processed_boms", + "bom_batches", "amended_from" ], "fields": [ @@ -70,15 +71,11 @@ }, { "collapsible": 1, + "depends_on": "eval: doc.update_type == \"Update Cost\"", "fieldname": "progress_section", "fieldtype": "Section Break", "label": "Progress" }, - { - "fieldname": "current_boms", - "fieldtype": "Long Text", - "label": "Current BOMs" - }, { "description": "Immediate parent BOMs", "fieldname": "parent_boms", @@ -89,13 +86,23 @@ "fieldname": "processed_boms", "fieldtype": "Long Text", "label": "Processed BOMs" + }, + { + "fieldname": "bom_batches", + "fieldtype": "Table", + "options": "BOM Update Batch" + }, + { + "fieldname": "current_level", + "fieldtype": "Int", + "label": "Current Level" } ], "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-05-27 17:03:34.712010", + "modified": "2022-05-31 20:20:06.370786", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index f61f863c10a..bfae76c2b2e 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -1,15 +1,16 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt import json -from typing import Dict, Optional +from typing import Any, Dict, List, Optional, Tuple import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr +from frappe.utils import cint, cstr from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( get_leaf_boms, + get_next_higher_level_boms, handle_exception, replace_bom, set_values_in_log, @@ -111,55 +112,110 @@ def process_boms_cost_level_wise(update_doc: "BOMUpdateLog") -> None: if update_doc.status == "Queued": # First level yet to process. On Submit. - current_boms = {bom: False for bom in get_leaf_boms()} + current_level = 0 + current_boms = get_leaf_boms() values = { - "current_boms": json.dumps(current_boms), "parent_boms": "[]", "processed_boms": json.dumps({}), "status": "In Progress", + "current_level": current_level, } else: - # status is Paused, resume. via Cron Job. - current_boms, parent_boms = json.loads(update_doc.current_boms), json.loads( - update_doc.parent_boms - ) - if not current_boms: - # Process the next level BOMs. Stage parents as current BOMs. - current_boms = {bom: False for bom in parent_boms} - values = { - "current_boms": json.dumps(current_boms), - "parent_boms": "[]", - "status": "In Progress", - } + # Resume next level. via Cron Job. + current_level = cint(update_doc.current_level) + 1 + parent_boms = json.loads(update_doc.parent_boms) + + # Process the next level BOMs. Stage parents as current BOMs. + current_boms = parent_boms.copy() + values = {"parent_boms": "[]", "current_level": current_level} set_values_in_log(update_doc.name, values, commit=True) - queue_bom_cost_jobs(current_boms, update_doc) + queue_bom_cost_jobs(current_boms, update_doc, current_level) -def queue_bom_cost_jobs(current_boms: Dict, update_doc: "BOMUpdateLog") -> None: +def queue_bom_cost_jobs( + current_boms_list: List, update_doc: "BOMUpdateLog", current_level: int +) -> None: "Queue batches of 20k BOMs of the same level to process parallelly" - current_boms_list = [bom for bom in current_boms] + batch_no = 0 while current_boms_list: + batch_no += 1 batch_size = 20_000 boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs # update list to exclude 20K (queued) BOMs current_boms_list = current_boms_list[batch_size:] if len(current_boms_list) > batch_size else [] + + batch_row = update_doc.append("bom_batches", {"level": current_level, "batch_no": batch_no}) + batch_row.db_insert() + frappe.enqueue( method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level", doc=update_doc, bom_list=boms_to_process, + batch_name=batch_row.name, timeout=40000, ) def resume_bom_cost_update_jobs(): - "Called every 10 minutes via Cron job." - paused_jobs = frappe.db.get_all("BOM Update Log", {"status": "Paused"}) - if not paused_jobs: + """ + 1. Checks for In Progress BOM Update Log. + 2. Checks if this job has completed the _current level_. + 3. If current level is complete, get parent BOMs and start next level. + 4. If no parents, mark as Complete. + 5. If current level is WIP, skip the Log. + + Called every 5 minutes via Cron job. + """ + + in_progress_logs = frappe.db.get_all( + "BOM Update Log", + {"update_type": "Update Cost", "status": "In Progress"}, + ["name", "processed_boms", "current_level"], + ) + if not in_progress_logs: return - for job in paused_jobs: - # resume from next level - process_boms_cost_level_wise(update_doc=frappe.get_doc("BOM Update Log", job.name)) + for log in in_progress_logs: + # check if all log batches of current level are processed + bom_batches = frappe.db.get_all( + "BOM Update Batch", {"parent": log.name, "level": log.current_level}, ["name", "boms_updated"] + ) + incomplete_level = any(not row.get("boms_updated") for row in bom_batches) + if not bom_batches or incomplete_level: + continue + + # Prep parent BOMs & updated processed BOMs for next level + current_boms, processed_boms = get_processed_current_boms(log, bom_batches) + parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) + + set_values_in_log( + log.name, + values={ + "processed_boms": json.dumps(processed_boms), + "parent_boms": json.dumps(parent_boms), + "status": "Completed" if not parent_boms else "In Progress", + }, + commit=True, + ) + + if parent_boms: # there is a next level to process + process_boms_cost_level_wise(update_doc=frappe.get_doc("BOM Update Log", log.name)) + + +def get_processed_current_boms( + log: Dict[str, Any], bom_batches: Dict[str, Any] +) -> Tuple[List[str], Dict[str, Any]]: + "Aggregate all BOMs from BOM Update Batch rows into 'processed_boms' field and into current boms list." + processed_boms = json.loads(log.processed_boms) if log.processed_boms else {} + current_boms = [] + + for row in bom_batches: + boms_updated = json.loads(row.boms_updated) + current_boms.extend(boms_updated) + boms_updated_dict = {bom: True for bom in boms_updated} + processed_boms.update(boms_updated_dict) + + return current_boms, processed_boms diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index 93a15deb154..6f36f2e985b 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -38,7 +38,7 @@ def replace_bom(boms: Dict) -> None: bom_obj.save_version() -def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str]) -> None: +def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str], batch_name: int) -> None: "Updates Cost for BOMs within a given level. Runs via background jobs." try: @@ -47,19 +47,9 @@ def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str]) -> None: return frappe.db.auto_commit_on_many_writes = 1 - # main updation logic - job_data = update_cost_in_boms(bom_list=bom_list, docname=doc.name) - set_values_in_log( - doc.name, - values={ - "current_boms": json.dumps(job_data.get("current_boms")), - "processed_boms": json.dumps(job_data.get("processed_boms")), - }, - commit=True, - ) - - process_if_level_is_complete(doc.name, job_data["current_boms"], job_data["processed_boms"]) + update_cost_in_boms(bom_list=bom_list) # main updation logic + frappe.db.set_value("BOM Update Batch", batch_name, "boms_updated", json.dumps(bom_list)) except Exception: handle_exception(doc) finally: @@ -112,48 +102,13 @@ def get_bom_unit_cost(bom_name: str) -> float: return frappe.utils.flt(new_bom_unitcost[0][0]) -def update_cost_in_boms(bom_list: List[str], docname: str) -> Dict[str, Dict]: +def update_cost_in_boms(bom_list: List[str]) -> None: "Updates cost in given BOMs. Returns current and total updated BOMs." - updated_boms = {} # current boms that have been updated - for bom in bom_list: bom_doc = frappe.get_cached_doc("BOM", bom) bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) bom_doc.db_update() - updated_boms[bom] = True - - # Update processed BOMs in Log - log_data = frappe.db.get_values( - "BOM Update Log", docname, ["current_boms", "processed_boms"], as_dict=True - )[0] - - for field in ("current_boms", "processed_boms"): - log_data[field] = json.loads(log_data.get(field)) - log_data[field].update(updated_boms) - - return log_data - - -def process_if_level_is_complete( - docname: str, current_boms: Dict[str, bool], processed_boms: Dict[str, bool] -) -> None: - "Prepare and set higher level BOMs/dependants in Log if current level is complete." - - processing_complete = all(current_boms.get(bom) for bom in current_boms) - if not processing_complete: - return - - parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) - set_values_in_log( - docname, - values={ - "current_boms": json.dumps({}), - "parent_boms": json.dumps(parent_boms), - "status": "Completed" if not parent_boms else "Paused", - }, - commit=True, - ) def get_next_higher_level_boms( @@ -244,7 +199,7 @@ def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = Fals query.run() if commit: - frappe.db.commit() + frappe.db.commit() # nosemgrep def handle_exception(doc: "BOMUpdateLog") -> None: From 7bd5558c31d8a7e957650b332b9235e50eab9e47 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 6 Jun 2022 17:01:51 +0530 Subject: [PATCH 17/55] chore: Miscellanous fixes/enhancements - `get_valuation_rate`: if no bins are found return 0, SLEs do not exist either - `get_valuation_rate`: Compute average valuation rate via query - `get_rm_rate_map`: set order_by as None to avoid creating sort index (modified) each time query runs (seen in process list) - BOM Update Batch: add status field and hide `boms_updated` so that users can see progress without loading all updated boms (too much data) - BOM Update Batch: set batch row status to completed after job runs - BOM Update Log: remove `parent_boms` field (just pass parent boms to processing function) & remove Paused state (not used) - Move job to long queue to avoid choking default queue - `update_cost_in_boms`: use `get_doc` as each BOM is accessed only once. Use `for_update` to lock BOM row - Commit after every 100 BOMs --- erpnext/manufacturing/doctype/bom/bom.py | 30 +++++++++------- .../bom_update_batch/bom_update_batch.json | 14 ++++++-- .../bom_update_log/bom_update_log.json | 12 ++----- .../doctype/bom_update_log/bom_update_log.py | 34 ++++++++++++------- .../bom_update_log/bom_updation_utils.py | 24 +++++++++---- .../bom_update_tool/bom_update_tool.py | 2 +- 6 files changed, 73 insertions(+), 43 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 15efe0d7b3b..352da1846b9 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -704,8 +704,11 @@ class BOM(WebsiteGenerator): for item in self.get("items"): if item.bom_no: # Get Item-Rate from Subassembly BOM - explosion_items = frappe.db.get_all( - "BOM Explosion Item", filters={"parent": item.bom_no}, fields=["item_code", "rate"] + 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) @@ -925,13 +928,17 @@ def get_bom_item_rate(args, bom_doc): def get_valuation_rate(args): - """Get weighted average of valuation rate from all warehouses""" + """ + 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 + """ - total_qty, total_value, valuation_rate = 0.0, 0.0, 0.0 - item_bins = frappe.db.sql( + valuation_rate = 0.0 + item_valuation = frappe.db.sql( """ select - bin.actual_qty, bin.stock_value + (sum(bin.stock_value) / sum(bin.actual_qty)) as valuation_rate from `tabBin` bin, `tabWarehouse` warehouse where @@ -940,14 +947,13 @@ def get_valuation_rate(args): and warehouse.company=%(company)s""", {"item": args["item_code"], "company": args["company"]}, as_dict=1, - ) + )[0] - for d in item_bins: - total_qty += flt(d.actual_qty) - total_value += flt(d.stock_value) + valuation_rate = item_valuation.get("valuation_rate") - if total_qty: - valuation_rate = total_value / total_qty + if valuation_rate is None: + # Explicit null value check. If null, Bins don't exist, neither does SLE + return valuation_rate if valuation_rate <= 0: last_valuation_rate = frappe.db.sql( diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json index 9938454ce4e..83b54d326cb 100644 --- a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json +++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json @@ -7,7 +7,8 @@ "field_order": [ "level", "batch_no", - "boms_updated" + "boms_updated", + "status" ], "fields": [ { @@ -25,14 +26,23 @@ { "fieldname": "boms_updated", "fieldtype": "Long Text", + "hidden": 1, "in_list_view": 1, "label": "BOMs Updated" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Pending\nCompleted", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-05-31 23:36:13.628391", + "modified": "2022-06-06 14:50:35.161062", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Batch", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index b1c24ab9954..c32e383b08a 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -15,7 +15,6 @@ "error_log", "progress_section", "current_level", - "parent_boms", "processed_boms", "bom_batches", "amended_from" @@ -52,7 +51,7 @@ "fieldname": "status", "fieldtype": "Select", "label": "Status", - "options": "Queued\nIn Progress\nPaused\nCompleted\nFailed" + "options": "Queued\nIn Progress\nCompleted\nFailed" }, { "fieldname": "amended_from", @@ -76,15 +75,10 @@ "fieldtype": "Section Break", "label": "Progress" }, - { - "description": "Immediate parent BOMs", - "fieldname": "parent_boms", - "fieldtype": "Long Text", - "label": "Parent BOMs" - }, { "fieldname": "processed_boms", "fieldtype": "Long Text", + "hidden": 1, "label": "Processed BOMs" }, { @@ -102,7 +96,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-05-31 20:20:06.370786", + "modified": "2022-06-06 15:15:23.883251", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index bfae76c2b2e..d714b9d5fd0 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -56,7 +56,7 @@ class BOMUpdateLog(Document): wip_log = frappe.get_all( "BOM Update Log", - {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress", "Paused"]]}, + {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]}, limit_page_length=1, ) if wip_log: @@ -104,10 +104,12 @@ def run_replace_bom_job( frappe.db.commit() # nosemgrep -def process_boms_cost_level_wise(update_doc: "BOMUpdateLog") -> None: +def process_boms_cost_level_wise( + update_doc: "BOMUpdateLog", parent_boms: List[str] = None +) -> None: "Queue jobs at the start of new BOM Level in 'Update Cost' Jobs." - current_boms, parent_boms = {}, [] + current_boms = {} values = {} if update_doc.status == "Queued": @@ -115,26 +117,27 @@ def process_boms_cost_level_wise(update_doc: "BOMUpdateLog") -> None: current_level = 0 current_boms = get_leaf_boms() values = { - "parent_boms": "[]", "processed_boms": json.dumps({}), "status": "In Progress", "current_level": current_level, } else: # Resume next level. via Cron Job. + if not parent_boms: + return + current_level = cint(update_doc.current_level) + 1 - parent_boms = json.loads(update_doc.parent_boms) # Process the next level BOMs. Stage parents as current BOMs. current_boms = parent_boms.copy() - values = {"parent_boms": "[]", "current_level": current_level} + values = {"current_level": current_level} set_values_in_log(update_doc.name, values, commit=True) queue_bom_cost_jobs(current_boms, update_doc, current_level) def queue_bom_cost_jobs( - current_boms_list: List, update_doc: "BOMUpdateLog", current_level: int + current_boms_list: List[str], update_doc: "BOMUpdateLog", current_level: int ) -> None: "Queue batches of 20k BOMs of the same level to process parallelly" batch_no = 0 @@ -147,7 +150,9 @@ def queue_bom_cost_jobs( # update list to exclude 20K (queued) BOMs current_boms_list = current_boms_list[batch_size:] if len(current_boms_list) > batch_size else [] - batch_row = update_doc.append("bom_batches", {"level": current_level, "batch_no": batch_no}) + batch_row = update_doc.append( + "bom_batches", {"level": current_level, "batch_no": batch_no, "status": "Pending"} + ) batch_row.db_insert() frappe.enqueue( @@ -155,7 +160,7 @@ def queue_bom_cost_jobs( doc=update_doc, bom_list=boms_to_process, batch_name=batch_row.name, - timeout=40000, + queue="long", ) @@ -181,9 +186,11 @@ def resume_bom_cost_update_jobs(): for log in in_progress_logs: # check if all log batches of current level are processed bom_batches = frappe.db.get_all( - "BOM Update Batch", {"parent": log.name, "level": log.current_level}, ["name", "boms_updated"] + "BOM Update Batch", + {"parent": log.name, "level": log.current_level}, + ["name", "boms_updated", "status"], ) - incomplete_level = any(not row.get("boms_updated") for row in bom_batches) + incomplete_level = any(row.get("status") == "Pending" for row in bom_batches) if not bom_batches or incomplete_level: continue @@ -195,14 +202,15 @@ def resume_bom_cost_update_jobs(): log.name, values={ "processed_boms": json.dumps(processed_boms), - "parent_boms": json.dumps(parent_boms), "status": "Completed" if not parent_boms else "In Progress", }, commit=True, ) if parent_boms: # there is a next level to process - process_boms_cost_level_wise(update_doc=frappe.get_doc("BOM Update Log", log.name)) + process_boms_cost_level_wise( + update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms + ) def get_processed_current_boms( diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index 6f36f2e985b..81e36f2df06 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -3,7 +3,7 @@ import json from collections import defaultdict -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union if TYPE_CHECKING: from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog @@ -38,7 +38,9 @@ def replace_bom(boms: Dict) -> None: bom_obj.save_version() -def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str], batch_name: int) -> None: +def update_cost_in_level( + doc: "BOMUpdateLog", bom_list: List[str], batch_name: Union[int, str] +) -> None: "Updates Cost for BOMs within a given level. Runs via background jobs." try: @@ -49,7 +51,14 @@ def update_cost_in_level(doc: "BOMUpdateLog", bom_list: List[str], batch_name: i frappe.db.auto_commit_on_many_writes = 1 update_cost_in_boms(bom_list=bom_list) # main updation logic - frappe.db.set_value("BOM Update Batch", batch_name, "boms_updated", json.dumps(bom_list)) + + bom_batch = frappe.qb.DocType("BOM Update Batch") + ( + frappe.qb.update(bom_batch) + .set(bom_batch.boms_updated, json.dumps(bom_list)) + .set(bom_batch.status, "Completed") + .where(bom_batch.name == batch_name) + ).run() except Exception: handle_exception(doc) finally: @@ -105,14 +114,17 @@ def get_bom_unit_cost(bom_name: str) -> float: def update_cost_in_boms(bom_list: List[str]) -> None: "Updates cost in given BOMs. Returns current and total updated BOMs." - for bom in bom_list: - bom_doc = frappe.get_cached_doc("BOM", bom) + for index, bom in enumerate(bom_list): + bom_doc = frappe.get_doc("BOM", bom, for_update=True) bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) bom_doc.db_update() + if index % 100 == 0: + frappe.db.commit() + def get_next_higher_level_boms( - child_boms: Dict[str, bool], processed_boms: Dict[str, bool] + child_boms: List[str], processed_boms: Dict[str, bool] ) -> List[str]: "Generate immediate higher level dependants with no unresolved dependencies (children)." diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 6b8da9c914f..974d0c3439b 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -42,7 +42,7 @@ def auto_update_latest_price_in_all_boms() -> None: if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"): wip_log = frappe.get_all( "BOM Update Log", - {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress", "Paused"]]}, + {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]}, limit_page_length=1, ) if not wip_log: From d3b06a682cc837a800e0b4d5a3ff12fa5d3f54c1 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 6 Jun 2022 17:12:43 +0530 Subject: [PATCH 18/55] chore: `get_valuation_rate` in bom.py must always return float & goto Item master if no bins --- erpnext/manufacturing/doctype/bom/bom.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 352da1846b9..db6201874a6 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -951,11 +951,8 @@ def get_valuation_rate(args): valuation_rate = item_valuation.get("valuation_rate") - if valuation_rate is None: - # Explicit null value check. If null, Bins don't exist, neither does SLE - return valuation_rate - - if valuation_rate <= 0: + if (valuation_rate is not None) and valuation_rate <= 0: + # Explicit null value check. If None, Bins don't exist, neither does SLE last_valuation_rate = frappe.db.sql( """select valuation_rate from `tabStock Ledger Entry` From c19dfbe98ab701d522e3ed0f65a3ec1314032d97 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 10 Mar 2022 12:22:37 +0530 Subject: [PATCH 19/55] fix: Add cost center in loan document (cherry picked from commit 5d66cc4c4a3a91af0d306f24c0b35f69a9b2966e) # Conflicts: # erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py # erpnext/patches.txt --- .../loan_management/doctype/loan/loan.json | 15 +++++++- erpnext/loan_management/doctype/loan/loan.py | 8 +++++ .../loan_interest_accrual.py | 35 ++++++++++++++++++- erpnext/patches.txt | 7 ++++ .../patches/v13_0/add_cost_center_in_loans.py | 16 +++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 erpnext/patches/v13_0/add_cost_center_in_loans.py diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json index dd723f38bdf..61bbf712610 100644 --- a/erpnext/loan_management/doctype/loan/loan.json +++ b/erpnext/loan_management/doctype/loan/loan.json @@ -32,6 +32,8 @@ "monthly_repayment_amount", "repayment_start_date", "is_term_loan", + "accounting_dimensions_section", + "cost_center", "account_info", "mode_of_payment", "disbursement_account", @@ -366,12 +368,23 @@ "options": "Account", "read_only": 1, "reqd": 1 + }, + { + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-01-25 16:29:16.325501", + "modified": "2022-03-10 11:50:31.957360", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index 7b7fc17142c..69e9ef8ebc3 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -26,6 +26,7 @@ class Loan(AccountsController): self.set_loan_amount() self.validate_loan_amount() self.set_missing_fields() + self.validate_cost_center() self.validate_accounts() self.check_sanctioned_amount_limit() self.validate_repay_from_salary() @@ -59,6 +60,13 @@ class Loan(AccountsController): ) ) + def validate_cost_center(self): + if not self.cost_center and self.rate_of_interest != 0: + self.cost_center = frappe.db.get_value('Company', self.company, 'cost_center') + + if not self.cost_center: + frappe.throw(_('Cost center is mandatory for loans having rate of interest greater than 0')) + def on_submit(self): self.link_loan_security_pledge() # Interest accrual for backdated term loans diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py index 0c4b051fba2..20eb637faac 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py @@ -6,7 +6,6 @@ import frappe from frappe import _ from frappe.utils import add_days, cint, date_diff, flt, get_datetime, getdate, nowdate -import erpnext from erpnext.accounts.general_ledger import make_gl_entries from erpnext.controllers.accounts_controller import AccountsController @@ -41,8 +40,11 @@ class LoanInterestAccrual(AccountsController): def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] + cost_center = frappe.db.get_value('Loan', self.loan, 'cost_center') + if self.interest_amount: gle_map.append( +<<<<<<< HEAD self.get_gl_dict( { "account": self.loan_account, @@ -78,6 +80,37 @@ class LoanInterestAccrual(AccountsController): "posting_date": self.posting_date, } ) +======= + self.get_gl_dict({ + "account": self.loan_account, + "party_type": self.applicant_type, + "party": self.applicant, + "against": self.interest_income_account, + "debit": self.interest_amount, + "debit_in_account_currency": self.interest_amount, + "against_voucher_type": "Loan", + "against_voucher": self.loan, + "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format( + self.last_accrual_date, self.posting_date, self.loan), + "cost_center": cost_center, + "posting_date": self.posting_date + }) + ) + + gle_map.append( + self.get_gl_dict({ + "account": self.interest_income_account, + "against": self.loan_account, + "credit": self.interest_amount, + "credit_in_account_currency": self.interest_amount, + "against_voucher_type": "Loan", + "against_voucher": self.loan, + "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format( + self.last_accrual_date, self.posting_date, self.loan), + "cost_center": cost_center, + "posting_date": self.posting_date + }) +>>>>>>> 5d66cc4c4a (fix: Add cost center in loan document) ) if gle_map: diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 63b146d99fe..a1e2104f081 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -352,6 +352,7 @@ erpnext.patches.v13_0.amazon_mws_deprecation_warning erpnext.patches.v13_0.datev_deprecation_warning erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.update_accounts_in_loan_docs +<<<<<<< HEAD erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022 erpnext.patches.v13_0.rename_non_profit_fields erpnext.patches.v13_0.enable_ksa_vat_docs #1 @@ -367,3 +368,9 @@ erpnext.patches.v13_0.create_accounting_dimensions_in_orders erpnext.patches.v13_0.set_per_billed_in_return_delivery_note erpnext.patches.v13_0.update_employee_advance_status erpnext.patches.v13_0.job_card_status_on_hold +======= +erpnext.patches.v14_0.update_batch_valuation_flag +erpnext.patches.v14_0.delete_non_profit_doctypes +erpnext.patches.v14_0.update_employee_advance_status +erpnext.patches.v13_0.add_cost_center_in_loans +>>>>>>> 5d66cc4c4a (fix: Add cost center in loan document) diff --git a/erpnext/patches/v13_0/add_cost_center_in_loans.py b/erpnext/patches/v13_0/add_cost_center_in_loans.py new file mode 100644 index 00000000000..25e1722a4ff --- /dev/null +++ b/erpnext/patches/v13_0/add_cost_center_in_loans.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + frappe.reload_doc('loan_management', 'doctype', 'loan') + loan = frappe.qb.DocType('Loan') + + for company in frappe.get_all('Company', pluck='name'): + default_cost_center = frappe.db.get_value('Company', company, 'cost_center') + frappe.qb.update( + loan + ).set( + loan.cost_center, default_cost_center + ).where( + loan.company == company + ).run() \ No newline at end of file From 6d99b5a95aed50b579e43062e4c017213322a57f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Jun 2022 15:43:37 +0530 Subject: [PATCH 20/55] fix: purchase invoice standalone return GLEs (backport #31209) (#31263) * test: create stock test mixin for assertion/utils (cherry picked from commit 293eb8d722c773864eef6ef45ce36a8bda25340e) # Conflicts: # erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py # erpnext/stock/tests/test_utils.py * fix: purchase invoice return GLe voucher_wise_stock_value contains tuples and the condition was looking for string, so it's never triggered. Caused by https://github.com/frappe/erpnext/pull/24200 (cherry picked from commit 7726271e2ac6776b29f795e6e54dd76aa6d581b8) * chore: conflicts Co-authored-by: Ankush Menat Co-authored-by: Ankush Menat --- .../purchase_invoice/purchase_invoice.py | 2 +- .../purchase_invoice/test_purchase_invoice.py | 77 ++++++++++++++++++- .../controllers/sales_and_purchase_return.py | 2 +- .../doctype/stock_entry/stock_entry_utils.py | 26 +++++++ .../test_stock_ledger_entry.py | 3 +- .../test_stock_reconciliation.py | 15 ++-- erpnext/stock/tests/test_utils.py | 53 +++++++++++++ 7 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 erpnext/stock/tests/test_utils.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 02de9e5fd37..3c1dc80970e 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1087,7 +1087,7 @@ class PurchaseInvoice(BuyingController): # Stock ledger value is not matching with the warehouse amount if ( self.update_stock - and voucher_wise_stock_value.get(item.name) + and voucher_wise_stock_value.get((item.name, item.warehouse)) and warehouse_debit_amount != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) ): diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 29aa67f7e2a..038508b14a8 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -26,12 +26,13 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( make_purchase_receipt, ) from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction +from erpnext.stock.tests.test_utils import StockTestMixin test_dependencies = ["Item", "Cost Center", "Payment Term", "Payment Terms Template"] test_ignore = ["Serial No"] -class TestPurchaseInvoice(unittest.TestCase): +class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): @classmethod def setUpClass(self): unlink_payment_on_cancel_of_invoice() @@ -659,6 +660,80 @@ class TestPurchaseInvoice(unittest.TestCase): self.assertEqual(expected_values[gle.account][0], gle.debit) self.assertEqual(expected_values[gle.account][1], gle.credit) + def test_standalone_return_using_pi(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item = self.make_item().name + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + + make_stock_entry(item_code=item, target=warehouse, qty=50, rate=120) + + return_pi = make_purchase_invoice( + is_return=1, + item=item, + qty=-10, + update_stock=1, + rate=100, + company=company, + warehouse=warehouse, + cost_center="Main - TCP1", + ) + + # assert that stock consumption is with actual rate + self.assertGLEs( + return_pi, + [{"credit": 1200, "debit": 0}], + gle_filters={"account": "Stock In Hand - TCP1"}, + ) + + # assert loss booked in COGS + self.assertGLEs( + return_pi, + [{"credit": 0, "debit": 200}], + gle_filters={"account": "Cost of Goods Sold - TCP1"}, + ) + + def test_return_with_lcv(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + create_landed_cost_voucher, + ) + + item = self.make_item().name + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + cost_center = "Main - TCP1" + + pi = make_purchase_invoice( + item=item, + company=company, + warehouse=warehouse, + cost_center=cost_center, + update_stock=1, + qty=10, + rate=100, + ) + + # Create landed cost voucher - will increase valuation of received item by 10 + create_landed_cost_voucher("Purchase Invoice", pi.name, pi.company, charges=100) + return_pi = make_return_doc(pi.doctype, pi.name) + return_pi.save().submit() + + # assert that stock consumption is with actual in rate + self.assertGLEs( + return_pi, + [{"credit": 1100, "debit": 0}], + gle_filters={"account": "Stock In Hand - TCP1"}, + ) + + # assert loss booked in COGS + self.assertGLEs( + return_pi, + [{"credit": 0, "debit": 100}], + gle_filters={"account": "Cost of Goods Sold - TCP1"}, + ) + def test_multi_currency_gle(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 0ad39949b6d..1e7dcfb2b6d 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -316,7 +316,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype): return data[0] -def make_return_doc(doctype, source_name, target_doc=None): +def make_return_doc(doctype: str, source_name: str, target_doc=None): from frappe.model.mapper import get_mapped_doc from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index 552023c0a6c..7badf475c57 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -2,12 +2,38 @@ # See license.txt +from typing import TYPE_CHECKING, Optional, overload + import frappe from frappe.utils import cint, flt from six import string_types import erpnext +if TYPE_CHECKING: + from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry + + +@overload +def make_stock_entry( + *, + item_code: str, + qty: float, + company: Optional[str] = None, + from_warehouse: Optional[str] = None, + to_warehouse: Optional[str] = None, + rate: Optional[float] = None, + serial_no: Optional[str] = None, + batch_no: Optional[str] = None, + posting_date: Optional[str] = None, + posting_time: Optional[str] = None, + purpose: Optional[str] = None, + do_not_save: bool = False, + do_not_submit: bool = False, + inspection_required: bool = False, +) -> "StockEntry": + ... + @frappe.whitelist() def make_stock_entry(**args): diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 8298313d4bd..9fa61098a0b 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -22,9 +22,10 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation, ) from erpnext.stock.stock_ledger import get_previous_sle +from erpnext.stock.tests.test_utils import StockTestMixin -class TestStockLedgerEntry(FrappeTestCase): +class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): def setUp(self): items = create_items() reset("Stock Entry") diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index b8347809fca..190ae9edaf9 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cstr, flt, nowdate, nowtime, random_string from erpnext.accounts.utils import get_stock_and_account_balance -from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( @@ -19,10 +19,11 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( ) from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after +from erpnext.stock.tests.test_utils import StockTestMixin from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method -class TestStockReconciliation(FrappeTestCase): +class TestStockReconciliation(FrappeTestCase, StockTestMixin): @classmethod def setUpClass(cls): create_batch_or_serial_no_items() @@ -40,7 +41,7 @@ class TestStockReconciliation(FrappeTestCase): self._test_reco_sle_gle("Moving Average") def _test_reco_sle_gle(self, valuation_method): - item_code = make_item(properties={"valuation_method": valuation_method}).name + item_code = self.make_item(properties={"valuation_method": valuation_method}).name se1, se2, se3 = insert_existing_sle(warehouse="Stores - TCP1", item_code=item_code) company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -391,7 +392,7 @@ class TestStockReconciliation(FrappeTestCase): SR4 | Reco | 0 | 6 (posting date: today-1) [backdated] PR3 | PR | 1 | 7 (posting date: today) # can't post future PR """ - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" frappe.flags.dont_execute_stock_reposts = True @@ -457,7 +458,7 @@ class TestStockReconciliation(FrappeTestCase): from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.stock_ledger import NegativeStockError - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" pr1 = make_purchase_receipt( @@ -505,7 +506,7 @@ class TestStockReconciliation(FrappeTestCase): from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.stock_ledger import NegativeStockError - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" sr = create_stock_reconciliation( @@ -548,7 +549,7 @@ class TestStockReconciliation(FrappeTestCase): # repost will make this test useless, qty should update in realtime without reposts frappe.flags.dont_execute_stock_reposts = True - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" sr = create_stock_reconciliation( diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py new file mode 100644 index 00000000000..17d129990dc --- /dev/null +++ b/erpnext/stock/tests/test_utils.py @@ -0,0 +1,53 @@ +import json + +import frappe + + +class StockTestMixin: + """Mixin to simplfy stock ledger tests, useful for all stock transactions.""" + + def make_item(self, item_code=None, properties=None, *args, **kwargs): + from erpnext.stock.doctype.item.test_item import make_item + + return make_item(item_code, properties, *args, **kwargs) + + def assertSLEs(self, doc, expected_sles, sle_filters=None): + """Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" + + filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} + if sle_filters: + filters.update(sle_filters) + sles = frappe.get_all( + "Stock Ledger Entry", + fields=["*"], + filters=filters, + order_by="timestamp(posting_date, posting_time), creation", + ) + + for exp_sle, act_sle in zip(expected_sles, sles): + for k, v in exp_sle.items(): + act_value = act_sle[k] + if k == "stock_queue": + act_value = json.loads(act_value) + if act_value and act_value[0][0] == 0: + # ignore empty fifo bins + continue + + self.assertEqual(v, act_value, msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}") + + def assertGLEs(self, doc, expected_gles, gle_filters=None, order_by=None): + filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} + + if gle_filters: + filters.update(gle_filters) + actual_gles = frappe.get_all( + "GL Entry", + fields=["*"], + filters=filters, + order_by=order_by or "posting_date, creation", + ) + + for exp_gle, act_gle in zip(expected_gles, actual_gles): + for k, exp_value in exp_gle.items(): + act_value = act_gle[k] + self.assertEqual(exp_value, act_value, msg=f"{k} doesn't match \n{exp_gle}\n{act_gle}") From 0e53edfd49dcf394f8b106f7b43c9f0924e99c88 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 7 Jun 2022 16:03:29 +0530 Subject: [PATCH 21/55] test: sales register report with conditions (#31266) --- .flake8 | 1 + .../report/sales_register/sales_register.py | 4 +- erpnext/accounts/test/test_reports.py | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 erpnext/accounts/test/test_reports.py diff --git a/.flake8 b/.flake8 index 4ff88403244..4b852abd7c6 100644 --- a/.flake8 +++ b/.flake8 @@ -31,6 +31,7 @@ ignore = E124, # closing bracket, irritating while writing QB code E131, # continuation line unaligned for hanging indent E123, # closing bracket does not match indentation of opening bracket's line + E101, # ensured by use of black max-line-length = 200 exclude=.github/helper/semgrep_rules diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index 777d96ced17..33bd3c74965 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -367,8 +367,8 @@ def get_conditions(filters): if not filters.get(field) or field in accounting_dimensions_list: return "" return f""" and exists(select name from `tab{table}` - where parent=`tabSales Invoice`.name - and ifnull(`tab{table}`.{field}, '') = %({field})s)""" + where parent=`tabSales Invoice`.name + and ifnull(`tab{table}`.{field}, '') = %({field})s)""" conditions += get_sales_invoice_item_field_condition("mode_of_payments", "Sales Invoice Payment") conditions += get_sales_invoice_item_field_condition("cost_center") diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py new file mode 100644 index 00000000000..609f74eadbd --- /dev/null +++ b/erpnext/accounts/test/test_reports.py @@ -0,0 +1,49 @@ +import unittest +from typing import List, Tuple + +from erpnext.tests.utils import ReportFilters, ReportName, execute_script_report + +DEFAULT_FILTERS = { + "company": "_Test Company", + "from_date": "2010-01-01", + "to_date": "2030-01-01", + "period_start_date": "2010-01-01", + "period_end_date": "2030-01-01", +} + + +REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ + ("General Ledger", {"group_by": "Group by Voucher (Consolidated)"}), + ("General Ledger", {"group_by": "Group by Voucher (Consolidated)", "include_dimensions": 1}), + ("Accounts Payable", {"range1": 30, "range2": 60, "range3": 90, "range4": 120}), + ("Accounts Receivable", {"range1": 30, "range2": 60, "range3": 90, "range4": 120}), + ("Consolidated Financial Statement", {"report": "Balance Sheet"}), + ("Consolidated Financial Statement", {"report": "Profit and Loss Statement"}), + ("Consolidated Financial Statement", {"report": "Cash Flow"}), + ("Gross Profit", {"group_by": "Invoice"}), + ("Gross Profit", {"group_by": "Item Code"}), + ("Gross Profit", {"group_by": "Item Group"}), + ("Gross Profit", {"group_by": "Customer"}), + ("Gross Profit", {"group_by": "Customer Group"}), + ("Item-wise Sales Register", {}), + ("Item-wise Purchase Register", {}), + ("Sales Register", {}), + ("Sales Register", {"item_group": "All Item Groups"}), + ("Purchase Register", {}), +] + +OPTIONAL_FILTERS = {} + + +class TestReports(unittest.TestCase): + def test_execute_all_accounts_reports(self): + """Test that all script report in stock modules are executable with supported filters""" + for report, filter in REPORT_FILTER_TEST_CASES: + with self.subTest(report=report): + execute_script_report( + report_name=report, + module="Accounts", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) From e69bff0caab77e97bf00e6fed3a2555fcba72fff Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Jun 2022 16:04:00 +0530 Subject: [PATCH 22/55] fix: Auto Insert Item Price If Missing when discount & blank UOM (backport #31168) (#31267) fix: Auto Insert Item Price If Missing when discount & blank UOM (#31168) * fix: Auto Insert Item Price If Missing when discount and blank UOM fixes wrong item price insert when discount is used and adds uom=stock_uom instead of blank as price is converted to stock uom * unit tests added for item with discount I have added test for auto_insert_price where discount is used. * unit test issue fixed fixed make_sales_order as some of the test that depended on it were failing due to passing of incorrect parameters. Co-authored-by: Ankush Menat (cherry picked from commit b3ccc4bfb953b90dc8301a6af953c1a2cd66d4b6) Co-authored-by: maharshivpatel <39730881+maharshivpatel@users.noreply.github.com> --- .../doctype/sales_order/test_sales_order.py | 24 ++++++++++++++++++- erpnext/stock/get_item_details.py | 8 +++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 8edc9394c10..292562beebb 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -777,6 +777,7 @@ class TestSalesOrder(FrappeTestCase): def test_auto_insert_price(self): make_item("_Test Item for Auto Price List", {"is_stock_item": 0}) + make_item("_Test Item for Auto Price List with Discount Percentage", {"is_stock_item": 0}) frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 1) item_price = frappe.db.get_value( @@ -798,6 +799,25 @@ class TestSalesOrder(FrappeTestCase): 100, ) + make_sales_order( + item_code="_Test Item for Auto Price List with Discount Percentage", + selling_price_list="_Test Price List", + price_list_rate=200, + discount_percentage=20, + ) + + self.assertEqual( + frappe.db.get_value( + "Item Price", + { + "price_list": "_Test Price List", + "item_code": "_Test Item for Auto Price List with Discount Percentage", + }, + "price_list_rate", + ), + 200, + ) + # do not update price list frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 0) @@ -1587,7 +1607,9 @@ def make_sales_order(**args): "warehouse": args.warehouse, "qty": args.qty or 10, "uom": args.uom or None, - "rate": args.rate or 100, + "price_list_rate": args.price_list_rate or None, + "discount_percentage": args.discount_percentage or None, + "rate": args.rate or (None if args.price_list_rate else 100), "against_blanket_order": args.against_blanket_order, }, ) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 384dd7d94f4..78e809a6fd2 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -343,6 +343,7 @@ def get_basic_details(args, item, overwrite_warehouse=True): "has_batch_no": item.has_batch_no, "batch_no": args.get("batch_no"), "uom": args.uom, + "stock_uom": item.stock_uom, "min_order_qty": flt(item.min_order_qty) if args.doctype == "Material Request" else "", "qty": flt(args.qty) or 1.0, "stock_qty": flt(args.qty) or 1.0, @@ -355,7 +356,7 @@ def get_basic_details(args, item, overwrite_warehouse=True): "net_rate": 0.0, "net_amount": 0.0, "discount_percentage": 0.0, - "discount_amount": 0.0, + "discount_amount": flt(args.discount_amount) or 0.0, "supplier": get_default_supplier(args, item_defaults, item_group_defaults, brand_defaults), "update_stock": args.get("update_stock") if args.get("doctype") in ["Sales Invoice", "Purchase Invoice"] @@ -813,7 +814,9 @@ def insert_item_price(args): ): if frappe.has_permission("Item Price", "write"): price_list_rate = ( - args.rate / args.get("conversion_factor") if args.get("conversion_factor") else args.rate + (args.rate + args.discount_amount) / args.get("conversion_factor") + if args.get("conversion_factor") + else (args.rate + args.discount_amount) ) item_price = frappe.db.get_value( @@ -839,6 +842,7 @@ def insert_item_price(args): "item_code": args.item_code, "currency": args.currency, "price_list_rate": price_list_rate, + "uom": args.stock_uom, } ) item_price.insert() From dfbfe403e97778ae425d611ad4942bbf724fbd96 Mon Sep 17 00:00:00 2001 From: Ganga Manoj Date: Wed, 8 Jun 2022 09:35:43 +0530 Subject: [PATCH 23/55] fix: Depreciate Asset before generating GL Entries on sale (#30759) --- .../doctype/sales_invoice/sales_invoice.py | 16 ++++++++------ erpnext/assets/doctype/asset/test_asset.py | 22 ++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index e39822e4036..954a8207780 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1113,24 +1113,24 @@ class SalesInvoice(SellingController): asset = self.get_asset(item) if self.is_return: + if asset.calculate_depreciation: + self.reverse_depreciation_entry_made_after_sale(asset) + self.reset_depreciation_schedule(asset) + fixed_asset_gl_entries = get_gl_entries_on_asset_regain( asset, item.base_net_amount, item.finance_book ) asset.db_set("disposal_date", None) - if asset.calculate_depreciation: - self.reverse_depreciation_entry_made_after_sale(asset) - self.reset_depreciation_schedule(asset) - else: + if asset.calculate_depreciation: + self.depreciate_asset(asset) + fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( asset, item.base_net_amount, item.finance_book ) asset.db_set("disposal_date", self.posting_date) - if asset.calculate_depreciation: - self.depreciate_asset(asset) - for gle in fixed_asset_gl_entries: gle["against"] = self.customer gl_entries.append(self.get_gl_dict(gle, item=item)) @@ -1198,6 +1198,7 @@ class SalesInvoice(SellingController): asset.save() make_depreciation_entry(asset.name, self.posting_date) + asset.load_from_db() def reset_depreciation_schedule(self, asset): asset.flags.ignore_validate_update_after_submit = True @@ -1207,6 +1208,7 @@ class SalesInvoice(SellingController): self.modify_depreciation_schedule_for_asset_repairs(asset) asset.save() + asset.load_from_db() def modify_depreciation_schedule_for_asset_repairs(self, asset): asset_repairs = frappe.get_all( diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 79455bb1b4e..25929a744af 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -193,31 +193,31 @@ class TestAsset(AssetSetup): def test_gle_made_by_asset_sale(self): asset = create_asset( calculate_depreciation=1, - available_for_use_date="2020-06-06", - purchase_date="2020-01-01", + available_for_use_date="2021-06-06", + purchase_date="2021-01-01", expected_value_after_useful_life=10000, total_number_of_depreciations=3, frequency_of_depreciation=10, - depreciation_start_date="2020-12-31", + depreciation_start_date="2021-12-31", submit=1, ) - post_depreciation_entries(date="2021-01-01") + post_depreciation_entries(date="2022-01-01") si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") si.customer = "_Test Customer" - si.due_date = nowdate() - si.get("items")[0].rate = 25000 - si.insert() + si.posting_date = getdate("2022-04-22") + si.due_date = getdate("2022-04-22") + si.get("items")[0].rate = 75000 si.submit() self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") expected_gle = ( - ("_Test Accumulated Depreciations - _TC", 20490.2, 0.0), + ("_Test Accumulated Depreciations - _TC", 36082.31, 0.0), ("_Test Fixed Asset - _TC", 0.0, 100000.0), - ("_Test Gain/Loss on Asset Disposal - _TC", 54509.8, 0.0), - ("Debtors - _TC", 25000.0, 0.0), + ("_Test Gain/Loss on Asset Disposal - _TC", 0.0, 11082.31), + ("Debtors - _TC", 75000.0, 0.0), ) gle = frappe.db.sql( @@ -229,7 +229,9 @@ class TestAsset(AssetSetup): self.assertEqual(gle, expected_gle) + si.load_from_db() si.cancel() + self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated") def test_expense_head(self): From c7accb9c3320f26692973eecc18eb2575fd84e29 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 7 Jun 2022 14:44:00 +0530 Subject: [PATCH 24/55] test: Util to update cost in all BOMs - Utility to update cost in all BOMs without cron jobs or background jobs (run immediately) - Re-use util wherever all bom costs are to be updated - Skip explicit commits if in test - Specify company in test records (dirty data sometimes, company wh mismatch) - Skip background jobs queueing if in test --- erpnext/manufacturing/doctype/bom/test_bom.py | 6 +- .../doctype/bom/test_records.json | 1 + .../doctype/bom_update_log/bom_update_log.py | 21 +++- .../bom_update_log/bom_updation_utils.py | 10 +- .../bom_update_log/test_bom_update_log.py | 97 ++++++++++++------- .../bom_update_tool/test_bom_update_tool.py | 14 +-- 6 files changed, 97 insertions(+), 52 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 455e3f9d9c3..57359abd767 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -11,7 +11,9 @@ from frappe.utils import cstr, flt 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_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, @@ -80,7 +82,7 @@ class TestBOM(FrappeTestCase): reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_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( diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json index 25730f9b9f4..507d319b515 100644 --- a/erpnext/manufacturing/doctype/bom/test_records.json +++ b/erpnext/manufacturing/doctype/bom/test_records.json @@ -32,6 +32,7 @@ "is_active": 1, "is_default": 1, "item": "_Test Item Home Desktop Manufactured", + "company": "_Test Company", "quantity": 1.0 }, { diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index d714b9d5fd0..71430bd57e4 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -1,7 +1,7 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt import json -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union import frappe from frappe import _ @@ -101,12 +101,14 @@ def run_replace_bom_job( handle_exception(doc) finally: frappe.db.auto_commit_on_many_writes = 0 - frappe.db.commit() # nosemgrep + + if not frappe.flags.in_test: + frappe.db.commit() # nosemgrep def process_boms_cost_level_wise( update_doc: "BOMUpdateLog", parent_boms: List[str] = None -) -> None: +) -> Union[None, Tuple]: "Queue jobs at the start of new BOM Level in 'Update Cost' Jobs." current_boms = {} @@ -133,6 +135,10 @@ def process_boms_cost_level_wise( values = {"current_level": current_level} set_values_in_log(update_doc.name, values, commit=True) + + if frappe.flags.in_test: + return current_boms, current_level + queue_bom_cost_jobs(current_boms, update_doc, current_level) @@ -155,6 +161,10 @@ def queue_bom_cost_jobs( ) batch_row.db_insert() + if frappe.flags.in_test: + # skip background jobs in test + return boms_to_process, batch_row.name + frappe.enqueue( method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level", doc=update_doc, @@ -216,7 +226,10 @@ def resume_bom_cost_update_jobs(): def get_processed_current_boms( log: Dict[str, Any], bom_batches: Dict[str, Any] ) -> Tuple[List[str], Dict[str, Any]]: - "Aggregate all BOMs from BOM Update Batch rows into 'processed_boms' field and into current boms list." + """ + Aggregate all BOMs from BOM Update Batch rows into 'processed_boms' field + and into current boms list. + """ processed_boms = json.loads(log.processed_boms) if log.processed_boms else {} current_boms = [] diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index 81e36f2df06..f5af01e6a6b 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -63,7 +63,9 @@ def update_cost_in_level( handle_exception(doc) finally: frappe.db.auto_commit_on_many_writes = 0 - frappe.db.commit() # nosemgrep + + if not frappe.flags.in_test: + frappe.db.commit() # nosemgrep def get_ancestor_boms(new_bom: str, bom_list: Optional[List] = None) -> List: @@ -119,8 +121,8 @@ def update_cost_in_boms(bom_list: List[str]) -> None: bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) bom_doc.db_update() - if index % 100 == 0: - frappe.db.commit() + if (index % 100 == 0) and not frappe.flags.in_test: + frappe.db.commit() # nosemgrep def get_next_higher_level_boms( @@ -210,7 +212,7 @@ def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = Fals query = query.set(key, value) query.run() - if commit: + if commit and not frappe.flags.in_test: frappe.db.commit() # nosemgrep diff --git a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py index 4f151334a2a..d770f6c56a5 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py @@ -1,14 +1,27 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import ( BOMMissingError, + get_processed_current_boms, + process_boms_cost_level_wise, + queue_bom_cost_jobs, run_replace_bom_job, ) -from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom +from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( + get_next_higher_level_boms, + set_values_in_log, + update_cost_in_level, +) +from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import ( + enqueue_replace_bom, + enqueue_update_cost, +) test_records = frappe.get_test_records("BOM") @@ -31,17 +44,12 @@ class TestBOMUpdateLog(FrappeTestCase): def tearDown(self): frappe.db.rollback() - if self._testMethodName == "test_bom_update_log_completion": - # clear logs and delete BOM created via setUp - frappe.db.delete("BOM Update Log") - self.new_bom_doc.cancel() - self.new_bom_doc.delete() - - # explicitly commit and restore to original state - frappe.db.commit() # nosemgrep - def test_bom_update_log_validate(self): - "Test if BOM presence is validated." + """ + 1) Test if BOM presence is validated. + 2) Test if same BOMs are validated. + 3) Test of non-existent BOM is validated. + """ with self.assertRaises(BOMMissingError): enqueue_replace_bom(boms={}) @@ -55,9 +63,7 @@ class TestBOMUpdateLog(FrappeTestCase): def test_bom_update_log_queueing(self): "Test if BOM Update Log is created and queued." - log = enqueue_replace_bom( - boms=self.boms, - ) + log = enqueue_replace_bom(boms=self.boms) self.assertEqual(log.docstatus, 1) self.assertEqual(log.status, "Queued") @@ -65,32 +71,51 @@ class TestBOMUpdateLog(FrappeTestCase): def test_bom_update_log_completion(self): "Test if BOM Update Log handles job completion correctly." - log = enqueue_replace_bom( - boms=self.boms, - ) + log = enqueue_replace_bom(boms=self.boms) - # Explicitly commits log, new bom (setUp) and replacement impact. - # Is run via background jobs IRL - run_replace_bom_job( - doc=log, - boms=self.boms, - update_type="Replace BOM", - ) + # Is run via background job IRL + run_replace_bom_job(doc=log, boms=self.boms) log.reload() self.assertEqual(log.status, "Completed") - # teardown (undo replace impact) due to commit - boms = frappe._dict( - current_bom=self.boms.new_bom, - new_bom=self.boms.current_bom, + +def update_cost_in_all_boms_in_test(): + """ + Utility to run 'Update Cost' job in tests immediately without Cron job. + Run job for all levels (manually) until fully complete. + """ + parent_boms = [] + log = enqueue_update_cost() # create BOM Update Log + + while log.status != "Completed": + level_boms, current_level = process_boms_cost_level_wise(log, parent_boms) + log.reload() + + boms, batch = queue_bom_cost_jobs( + level_boms, log, current_level + ) # adds rows in log for tracking + log.reload() + + update_cost_in_level(log, boms, batch) # business logic + log.reload() + + # current level done, get next level boms + bom_batches = frappe.db.get_all( + "BOM Update Batch", + {"parent": log.name, "level": log.current_level}, + ["name", "boms_updated", "status"], ) - log2 = enqueue_replace_bom( - boms=self.boms, + current_boms, processed_boms = get_processed_current_boms(log, bom_batches) + parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) + + set_values_in_log( + log.name, + values={ + "processed_boms": json.dumps(processed_boms), + "status": "Completed" if not parent_boms else "In Progress", + }, ) - run_replace_bom_job( # Explicitly commits - doc=log2, - boms=boms, - update_type="Replace BOM", - ) - self.assertEqual(log2.status, "Completed") + log.reload() + + return log diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py index fae72a0f6f7..d1882e56e95 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py @@ -1,11 +1,13 @@ -# 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 frappe from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import replace_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.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.stock.doctype.item.test_item import create_item @@ -25,8 +27,8 @@ class TestBOMUpdateTool(FrappeTestCase): boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name) replace_bom(boms) - self.assertFalse(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", current_bom)) - self.assertTrue(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", bom_doc.name)) + self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1})) + self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1})) # reverse, as it affects other testcases boms.current_bom = bom_doc.name @@ -52,13 +54,13 @@ class TestBOMUpdateTool(FrappeTestCase): self.assertEqual(doc.total_cost, 200) frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 200) - update_cost() + update_cost_in_all_boms_in_test() doc.load_from_db() self.assertEqual(doc.total_cost, 300) frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 100) - update_cost() + update_cost_in_all_boms_in_test() doc.load_from_db() self.assertEqual(doc.total_cost, 200) From b529a610fb4aba1c5b76609ddbaf90fb607c7dae Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 7 Jun 2022 17:44:39 +0530 Subject: [PATCH 25/55] test: Fix `test_update_bom_cost_in_all_boms` - Use base_rate for assertions as rate is subject to change due to conversion factor (USD) --- erpnext/manufacturing/doctype/bom/test_bom.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 57359abd767..a470244dd74 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -71,26 +71,32 @@ class TestBOM(FrappeTestCase): 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 + rm_rate = bom_rates[0].get("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_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]) From 289d65a4ed7125db55f84638f51daa5657d664cd Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 8 Jun 2022 14:01:04 +0530 Subject: [PATCH 26/55] chore: `get_valuation_rate` sider fixes - Use qb instead of db.sql - Don't use `args` as argument for function - Cleaner variable names --- erpnext/manufacturing/doctype/bom/bom.py | 48 ++++++++++--------- erpnext/manufacturing/doctype/bom/test_bom.py | 1 - 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index db6201874a6..7969e8a9849 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -927,44 +927,46 @@ def get_bom_item_rate(args, bom_doc): return flt(rate) -def get_valuation_rate(args): +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 - item_valuation = frappe.db.sql( - """ - select - (sum(bin.stock_value) / sum(bin.actual_qty)) as valuation_rate - 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, - )[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 - 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"], - ) + 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_valuation_rate[0][0]) if last_valuation_rate else 0 + 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) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index a470244dd74..47634d903d0 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -82,7 +82,6 @@ class TestBOM(FrappeTestCase): as_dict=True, ) rm_base_rate = bom_rates[0].get("base_rate") if bom_rates else 0 - rm_rate = bom_rates[0].get("rate") if bom_rates else 0 # Reset item valuation rate reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_base_rate + 10) From 8a4c9d1238198d8951440534710a1b2a9ff32e84 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 8 Jun 2022 14:12:59 +0530 Subject: [PATCH 27/55] fix(asset): failing test case (#31277) --- erpnext/assets/doctype/asset/test_asset.py | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 25929a744af..226a38a5849 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -193,30 +193,30 @@ class TestAsset(AssetSetup): def test_gle_made_by_asset_sale(self): asset = create_asset( calculate_depreciation=1, - available_for_use_date="2021-06-06", - purchase_date="2021-01-01", + available_for_use_date="2020-06-06", + purchase_date="2020-01-01", expected_value_after_useful_life=10000, total_number_of_depreciations=3, frequency_of_depreciation=10, - depreciation_start_date="2021-12-31", + depreciation_start_date="2020-12-31", submit=1, ) - - post_depreciation_entries(date="2022-01-01") + post_depreciation_entries(date="2021-01-01") si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") si.customer = "_Test Customer" - si.posting_date = getdate("2022-04-22") - si.due_date = getdate("2022-04-22") + si.set_posting_time = 1 + si.posting_date = "2021-10-31" + si.due_date = "2021-10-31" si.get("items")[0].rate = 75000 si.submit() self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") expected_gle = ( - ("_Test Accumulated Depreciations - _TC", 36082.31, 0.0), + ("_Test Accumulated Depreciations - _TC", 50490.2, 0.0), ("_Test Fixed Asset - _TC", 0.0, 100000.0), - ("_Test Gain/Loss on Asset Disposal - _TC", 0.0, 11082.31), + ("_Test Gain/Loss on Asset Disposal - _TC", 0.0, 25490.2), ("Debtors - _TC", 75000.0, 0.0), ) @@ -227,7 +227,10 @@ class TestAsset(AssetSetup): si.name, ) - self.assertEqual(gle, expected_gle) + for i, gle_entry in enumerate(gle): + self.assertEqual(gle_entry[0], expected_gle[i][0]) + self.assertEqual(flt(gle_entry[1], 1), flt(expected_gle[i][1], 1)) + self.assertEqual(flt(gle_entry[2], 1), flt(expected_gle[i][2], 1)) si.load_from_db() si.cancel() From 633a4521e496446dc3ae87752a412d94dddd563c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 8 Jun 2022 18:12:02 +0530 Subject: [PATCH 28/55] fix: Use `frappe.as_unicode` to decode output of redis module list (backport #31282) (#31283) fix: Use `frappe.as_unicode` to decode output of redis module list (#31282) - As of redis 7, a list is added to the result of fetching the module list - This list cannot be "decoded",so use `frappe.as_unicode` that handles bytes as well as other types (cherry picked from commit 2832731601920b07c7083a20c49868e866640add) Co-authored-by: Marica --- erpnext/e_commerce/redisearch_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py index f2dd796f2c5..97da02688ef 100644 --- a/erpnext/e_commerce/redisearch_utils.py +++ b/erpnext/e_commerce/redisearch_utils.py @@ -38,7 +38,7 @@ def is_search_module_loaded(): out = cache.execute_command("MODULE LIST") parsed_output = " ".join( - (" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out) + (" ".join([frappe.as_unicode(s) for s in o if not isinstance(s, int)]) for o in out) ) return "search" in parsed_output except Exception: From 5a08850aa1addfc42f0604f1fe288289f5ff16e7 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 9 Jun 2022 16:22:00 +0530 Subject: [PATCH 29/55] chore: Less hacky tests, versioning (replace bom) and clearing log data (update cost) - Remove `auto_commit_on_many_writes` in `update_cost_in_level()` as commits happen every N BOMs - Auto commit every 50 BOMs - test: Remove hacky `frappe.flags.in_test` returns - test: Enqueue `now` if in tests (for update cost and replace bom) - Replace BOM: Copy bom object to `_doc_before_save` so that version.py finds a difference between the two - Replace BOM: Add reference to version - Update Cost: Unset `processed_boms` if Log is completed (useless after completion) - test: `update_cost_in_all_boms_in_test` works close to actual prod implementation (only call Cron job manually) - Test: use `enqueue_replace_bom` so that test works closest to production behaviour Co-authored-by: Ankush Menat --- .../doctype/bom_update_log/bom_update_log.py | 18 ++---- .../bom_update_log/bom_updation_utils.py | 19 ++++--- .../bom_update_log/test_bom_update_log.py | 56 +------------------ .../bom_update_tool/test_bom_update_tool.py | 12 ++-- 4 files changed, 23 insertions(+), 82 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index 71430bd57e4..9c9c24044aa 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -67,9 +67,6 @@ class BOMUpdateLog(Document): ) def on_submit(self): - if frappe.flags.in_test: - return - if self.update_type == "Replace BOM": boms = {"current_bom": self.current_bom, "new_bom": self.new_bom} frappe.enqueue( @@ -77,6 +74,7 @@ class BOMUpdateLog(Document): doc=self, boms=boms, timeout=40000, + now=frappe.flags.in_test, ) else: process_boms_cost_level_wise(self) @@ -94,7 +92,7 @@ def run_replace_bom_job( frappe.db.auto_commit_on_many_writes = 1 boms = frappe._dict(boms or {}) - replace_bom(boms) + replace_bom(boms, doc.name) doc.db_set("status", "Completed") except Exception: @@ -135,10 +133,6 @@ def process_boms_cost_level_wise( values = {"current_level": current_level} set_values_in_log(update_doc.name, values, commit=True) - - if frappe.flags.in_test: - return current_boms, current_level - queue_bom_cost_jobs(current_boms, update_doc, current_level) @@ -161,16 +155,13 @@ def queue_bom_cost_jobs( ) batch_row.db_insert() - if frappe.flags.in_test: - # skip background jobs in test - return boms_to_process, batch_row.name - frappe.enqueue( method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level", doc=update_doc, bom_list=boms_to_process, batch_name=batch_row.name, queue="long", + now=frappe.flags.in_test, ) @@ -208,10 +199,11 @@ def resume_bom_cost_update_jobs(): current_boms, processed_boms = get_processed_current_boms(log, bom_batches) parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) + # Unset processed BOMs if log is complete, it is used for next level BOMs set_values_in_log( log.name, values={ - "processed_boms": json.dumps(processed_boms), + "processed_boms": json.dumps([] if not parent_boms else processed_boms), "status": "Completed" if not parent_boms else "In Progress", }, commit=True, diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index f5af01e6a6b..3d52cd811bb 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -1,6 +1,7 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import copy import json from collections import defaultdict from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -12,7 +13,7 @@ import frappe from frappe import _ -def replace_bom(boms: Dict) -> None: +def replace_bom(boms: Dict, log_name: str) -> None: "Replace current BOM with new BOM in parent BOMs." current_bom = boms.get("current_bom") @@ -29,13 +30,17 @@ def replace_bom(boms: Dict) -> None: # this is only used for versioning and we do not want # to make separate db calls by using load_doc_before_save # which proves to be expensive while doing bulk replace - bom_obj._doc_before_save = bom_obj + bom_obj._doc_before_save = copy.deepcopy(bom_obj) bom_obj.update_exploded_items() bom_obj.calculate_cost() bom_obj.update_parent_cost() bom_obj.db_update() - if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version: - bom_obj.save_version() + bom_obj.flags.updater_reference = { + "doctype": "BOM Update Log", + "docname": log_name, + "label": _("via BOM Update Tool"), + } + bom_obj.save_version() def update_cost_in_level( @@ -48,8 +53,6 @@ def update_cost_in_level( if status == "Failed": return - frappe.db.auto_commit_on_many_writes = 1 - update_cost_in_boms(bom_list=bom_list) # main updation logic bom_batch = frappe.qb.DocType("BOM Update Batch") @@ -62,8 +65,6 @@ def update_cost_in_level( except Exception: handle_exception(doc) finally: - frappe.db.auto_commit_on_many_writes = 0 - if not frappe.flags.in_test: frappe.db.commit() # nosemgrep @@ -121,7 +122,7 @@ def update_cost_in_boms(bom_list: List[str]) -> None: bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) bom_doc.db_update() - if (index % 100 == 0) and not frappe.flags.in_test: + if (index % 50 == 0) and not frappe.flags.in_test: frappe.db.commit() # nosemgrep diff --git a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py index d770f6c56a5..b38fc8976b2 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py @@ -1,22 +1,12 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -import json - import frappe from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import ( BOMMissingError, - get_processed_current_boms, - process_boms_cost_level_wise, - queue_bom_cost_jobs, - run_replace_bom_job, -) -from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( - get_next_higher_level_boms, - set_values_in_log, - update_cost_in_level, + resume_bom_cost_update_jobs, ) from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import ( enqueue_replace_bom, @@ -60,62 +50,22 @@ class TestBOMUpdateLog(FrappeTestCase): with self.assertRaises(frappe.ValidationError): enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom="Dummy BOM")) - def test_bom_update_log_queueing(self): - "Test if BOM Update Log is created and queued." - - log = enqueue_replace_bom(boms=self.boms) - - self.assertEqual(log.docstatus, 1) - self.assertEqual(log.status, "Queued") - def test_bom_update_log_completion(self): "Test if BOM Update Log handles job completion correctly." log = enqueue_replace_bom(boms=self.boms) - - # Is run via background job IRL - run_replace_bom_job(doc=log, boms=self.boms) log.reload() - self.assertEqual(log.status, "Completed") def update_cost_in_all_boms_in_test(): """ - Utility to run 'Update Cost' job in tests immediately without Cron job. - Run job for all levels (manually) until fully complete. + Utility to run 'Update Cost' job in tests without Cron job until fully complete. """ - parent_boms = [] log = enqueue_update_cost() # create BOM Update Log while log.status != "Completed": - level_boms, current_level = process_boms_cost_level_wise(log, parent_boms) - log.reload() - - boms, batch = queue_bom_cost_jobs( - level_boms, log, current_level - ) # adds rows in log for tracking - log.reload() - - update_cost_in_level(log, boms, batch) # business logic - log.reload() - - # current level done, get next level boms - bom_batches = frappe.db.get_all( - "BOM Update Batch", - {"parent": log.name, "level": log.current_level}, - ["name", "boms_updated", "status"], - ) - current_boms, processed_boms = get_processed_current_boms(log, bom_batches) - parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) - - set_values_in_log( - log.name, - values={ - "processed_boms": json.dumps(processed_boms), - "status": "Completed" if not parent_boms else "In Progress", - }, - ) + resume_bom_cost_update_jobs() # run cron job until complete log.reload() return log diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py index d1882e56e95..5dd557f8ab1 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py @@ -4,10 +4,10 @@ import frappe from frappe.tests.utils import FrappeTestCase -from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import replace_bom from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import ( update_cost_in_all_boms_in_test, ) +from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.stock.doctype.item.test_item import create_item @@ -17,6 +17,9 @@ test_records = frappe.get_test_records("BOM") class TestBOMUpdateTool(FrappeTestCase): "Test major functions run via BOM Update Tool." + def tearDown(self): + frappe.db.rollback() + def test_replace_bom(self): current_bom = "BOM-_Test Item Home Desktop Manufactured-001" @@ -25,16 +28,11 @@ class TestBOMUpdateTool(FrappeTestCase): bom_doc.insert() boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name) - replace_bom(boms) + enqueue_replace_bom(boms=boms) self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1})) self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1})) - # reverse, as it affects other testcases - boms.current_bom = bom_doc.name - boms.new_bom = current_bom - replace_bom(boms) - def test_bom_cost(self): for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]: item_doc = create_item(item, valuation_rate=100) From 9f6b32af1259d4485396ad6245c1ebbbfb0514a2 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 17:22:50 +0530 Subject: [PATCH 30/55] fix(ux): hide new version btn on unsaved BOM (backport #31297) (#31298) fix(ux): hide new version btn on unsaved BOM (#31297) (cherry picked from commit d9a52139523cf095d3cc60cf61483a8d56468595) Co-authored-by: Ankush Menat --- erpnext/manufacturing/doctype/bom/bom.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index ef7a09c3aa7..ae0b26222ee 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -81,7 +81,7 @@ frappe.ui.form.on("BOM", { } ) - if (!frm.doc.__islocal && frm.doc.docstatus<2) { + if (!frm.is_new() && frm.doc.docstatus<2) { frm.add_custom_button(__("Update Cost"), function() { frm.events.update_cost(frm, true); }); @@ -93,10 +93,12 @@ 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.is_new() && !frm.doc.docstatus == 0) { + 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) { frm.add_custom_button(__("Work Order"), function() { From 81e32e28550dbba180f6f3b2125a2186d448d12b Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 9 Jun 2022 15:14:44 +0530 Subject: [PATCH 31/55] fix: misaligned columns in print format of AR/AP report (cherry picked from commit bbaa14af1615cedc88b9e5f4d19289c6be6510fe) --- .../report/accounts_receivable/accounts_receivable.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html index f4fd06ba037..f2bf9424f72 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html @@ -42,7 +42,7 @@ {% if(filters.show_future_payments) { %} {% var balance_row = data.slice(-1).pop(); - var start = filters.based_on_payment_terms ? 13 : 11; + var start = report.columns.findIndex((elem) => (elem.fieldname == 'age')); var range1 = report.columns[start].label; var range2 = report.columns[start+1].label; var range3 = report.columns[start+2].label; From 9347cbbc9f7826116faf22db7bf9f3bf32e6e3c2 Mon Sep 17 00:00:00 2001 From: meaziz Date: Thu, 9 Jun 2022 14:13:31 +0200 Subject: [PATCH 32/55] chore: Asset Arabic translation Fix (#31221) Update ar.csv Fix Translation arabic translation that caused an error when submitting an asset if user language was arabic --- erpnext/translations/ar.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/translations/ar.csv b/erpnext/translations/ar.csv index 91a9da9f163..e62f61a4f55 100644 --- a/erpnext/translations/ar.csv +++ b/erpnext/translations/ar.csv @@ -4297,7 +4297,7 @@ Fetch Serial Numbers based on FIFO,إحضار الأرقام المسلسلة ب "To allow different rates, disable the {0} checkbox in {1}.",للسماح بمعدلات مختلفة ، قم بتعطيل مربع الاختيار {0} في {1}., Current Odometer Value should be greater than Last Odometer Value {0},يجب أن تكون قيمة عداد المسافات الحالية أكبر من قيمة آخر عداد المسافات {0}, No additional expenses has been added,لم يتم إضافة مصاريف إضافية, -Asset{} {assets_link} created for {},الأصل {} {asset_link} الذي تم إنشاؤه لـ {}, +Asset{} {assets_link} created for {},الأصل {} {assets_link} الذي تم إنشاؤه لـ {}, Row {}: Asset Naming Series is mandatory for the auto creation for item {},الصف {}: سلسلة تسمية الأصول إلزامية للإنشاء التلقائي للعنصر {}, Assets not created for {0}. You will have to create asset manually.,لم يتم إنشاء الأصول لـ {0}. سيكون عليك إنشاء الأصل يدويًا., {0} {1} has accounting entries in currency {2} for company {3}. Please select a receivable or payable account with currency {2}.,{0} يحتوي {1} على إدخالات محاسبية بالعملة {2} للشركة {3}. الرجاء تحديد حساب مستحق أو دائن بالعملة {2}., From 77e4755c1f8bda642c94c0779c13d863a2883c0d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 19:16:48 +0530 Subject: [PATCH 33/55] fix: update ru translate (backport #31200) (#31304) * fix: update ru translate (#31200) * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv fix logic * Update ru.csv * Update ru.csv * Update ru.csv (cherry picked from commit 2675751d6c2ce188b1df8be5f930869a97ebd520) # Conflicts: # erpnext/translations/ru.csv * fix: Merge conflicts Co-authored-by: Vladislav Co-authored-by: Marica --- erpnext/translations/ru.csv | 153 +++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 73 deletions(-) diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 968637065db..8c614c02e88 100644 --- a/erpnext/translations/ru.csv +++ b/erpnext/translations/ru.csv @@ -288,7 +288,7 @@ Asset {0} must be submitted,Актив {0} должен быть проведе Assets,Активы, Assign,Назначить, Assign Salary Structure,Назначить структуру заработной платы, -Assign To,Назначить в, +Assign To,Назначить для, Assign to Employees,Назначить сотрудникам, Assigning Structures...,Назначение структур ..., Associate,Помощник, @@ -421,7 +421,7 @@ Buildings,Здания, Bundle items at time of sale.,Собирать продукты в момент продажи., Business Development Manager,Менеджер по развитию бизнеса, Buy,Купить, -Buying,Покупки, +Buying,Закупки, Buying Amount,Сумма покупки, Buying Price List,Ценовой список покупок, Buying Rate,Частота покупки, @@ -490,7 +490,7 @@ Capital Equipments,Капитальные оборудование, Capital Stock,Капитал, Capital Work in Progress,Капитальная работа в процессе, Cart,Корзина, -Cart is Empty,Корзина Пусто, +Cart is Empty,Корзина пуста, Case No(s) already in use. Try from Case No {0},Случай Нет (ы) уже используется. Попробуйте из дела № {0}, Cash,Наличные, Cash Flow Statement,О движении денежных средств, @@ -578,7 +578,7 @@ Compensatory Off,Компенсационные Выкл, Compensatory leave request days not in valid holidays,Дни запроса на получение компенсационных отчислений не действительны, Complaint,Жалоба, Completion Date,Дата завершения, -Computer,компьютер, +Computer,Компьютер, Condition,Условия, Configure,Конфигурировать, Configure {0},Настроить {0}, @@ -642,8 +642,7 @@ Course,Курс, Course Code: ,Код курса:, Course Enrollment {0} does not exists,Зачисление на курс {0} не существует, Course Schedule,Расписание курса, -Course: ,Курс:, -Cr,Cr, +Course: ,Курс: , Create,Создать, Create BOM,Создать спецификацию, Create Delivery Trip,Создать маршрут доставки, @@ -795,7 +794,6 @@ Defense,Оборона, Define Project type.,Установите тип проекта., Define budget for a financial year.,Определить бюджет на финансовый год., Define various loan types,Определение различных видов кредита, -Del,Del, Delay in payment (Days),Задержка в оплате (дни), Delete all the Transactions for this Company,Удалить все транзакции этой компании, Deletion is not permitted for country {0},Для страны не разрешено удаление {0}, @@ -1287,12 +1285,12 @@ Installing presets,Установка пресетов, Institute Abbreviation,институт Аббревиатура, Institute Name,Название института, Instructor,Инструктор, -Insufficient Stock,Недостаточный Stock, -Insurance Start date should be less than Insurance End date,"Дата страхование начала должна быть меньше, чем дата страхование End", +Insufficient Stock,Недостаточный запас, +Insurance Start date should be less than Insurance End date,"Дата начала страхования должна быть раньше, чем дата окончания", Integrated Tax,Интегрированный налог, Inter-State Supplies,Межгосударственные поставки, -Interest Amount,Проценты Сумма, -Interests,интересы, +Interest Amount,Сумма процентов, +Interests,Интересы, Intern,Стажер, Internet Publishing,Интернет издания, Intra-State Supplies,Внутригосударственные поставки, @@ -1397,7 +1395,7 @@ Job Card,Карточка работы, Job Description,Описание работы, Job Offer,Предложение работы, Job card {0} created,Карта работы {0} создана, -Jobs,Работы, +Jobs,Вакансии, Join,Присоединиться, Journal Entries {0} are un-linked,Записи в журнале {0} не-связаны, Journal Entry,Запись в дневнике, @@ -1925,7 +1923,7 @@ Pending Amount,В ожидании Сумма, Pending Leaves,Ожидающие листья, Pending Qty,В ожидании кол-во, Pending Quantity,Количество в ожидании, -Pending Review,В ожидании отзыв, +Pending Review,В ожидании отзыва, Pending activities for today,В ожидании деятельность на сегодняшний день, Pension Funds,Пенсионные фонды, Percentage Allocation should be equal to 100%,Процент Распределение должно быть равно 100%, @@ -1949,7 +1947,7 @@ Planned Qty,Планируемое кол-во, Planning,Планирование, Plants and Machineries,Растения и Механизмов, Please Set Supplier Group in Buying Settings.,Установите группу поставщиков в разделе «Настройки покупок»., -Please add a Temporary Opening account in Chart of Accounts,"Пожалуйста, добавьте временный вступительный счет в План счетов", +Please add a Temporary Opening account in Chart of Accounts,"Пожалуйста, добавьте временный вступительный счет в план счетов", Please add the account to root level Company - ,"Пожалуйста, добавьте счет на корневой уровень компании -", Please add the remaining benefits {0} to any of the existing component,Добавьте оставшиеся преимущества {0} к любому из существующих компонентов, Please check Multi Currency option to allow accounts with other currency,"Пожалуйста, проверьте мультивалютный вариант, позволяющий счета другой валюте", @@ -2146,7 +2144,7 @@ Preview Salary Slip,Просмотр Зарплата скольжению, Previous Financial Year is not closed,Предыдущий финансовый год не закрыт, Price,Цена, Price List,Прайс-лист, -Price List Currency not selected,Прайс-лист Обмен не выбран, +Price List Currency not selected,Валюта прайс-листа не выбрана, Price List Rate,Прайс-лист Оценить, Price List master.,Мастер Прайс-лист., Price List must be applicable for Buying or Selling,Прайс-лист должен быть применим для покупки или продажи, @@ -2347,7 +2345,7 @@ Remaining,Осталось, Remaining Balance,Остаток средств, Remarks,Примечания, Reminder to update GSTIN Sent,Напоминание об обновлении отправленного GSTIN, -Remove item if charges is not applicable to that item,"Удалить продукт, если сборы не применимы к этому продукту", +Remove item if charges is not applicable to that item,"Удалить объект, если к нему не применяются сборы", Removed items with no change in quantity or value.,Удалены пункты без изменения в количестве или стоимости., Reopen,Возобновить, Reorder Level,Уровень переупорядочения, @@ -2509,7 +2507,7 @@ Salary Slip of employee {0} already created for this period,Зарплата С Salary Slip of employee {0} already created for time sheet {1},Зарплата Скольжение работника {0} уже создан для табеля {1}, Salary Slip submitted for period from {0} to {1},"Зарплатная ведомость отправлена за период с {0} по {1}", Salary Structure Assignment for Employee already exists,Присвоение структуры зарплаты сотруднику уже существует, -Salary Structure Missing,Структура заработной платы Отсутствующий, +Salary Structure Missing,Структура заработной платы отсутствует, Salary Structure must be submitted before submission of Tax Ememption Declaration,Структура заработной платы должна быть представлена до подачи декларации об освобождении от налогов, Salary Structure not found for employee {0} and date {1},Структура зарплаты не найдена для сотрудника {0} и даты {1}, Salary Structure should have flexible benefit component(s) to dispense benefit amount,Структура заработной платы должна иметь гибкий компонент (ы) выгоды для распределения суммы пособия, @@ -2701,10 +2699,10 @@ Setup default values for POS Invoices,Настройка значений по Setup mode of POS (Online / Offline),Режим настройки POS (Online / Offline), Setup your Institute in ERPNext,Установите свой институт в ERPNext, Share Balance,Баланс акций, -Share Ledger,Поделиться записями, +Share Ledger,Записи по акциям, Share Management,Управление долями, Share Transfer,Передача акций, -Share Type,Share Тип, +Share Type,Тип акций, Shareholder,Акционер, Ship To State,Корабль в штат, Shipments,Поставки, @@ -2796,8 +2794,8 @@ Stock Entry {0} is not submitted,Складской акт {0} не провед Stock Expenses,Расходы по Запасам, Stock In Hand,Запасы на руках, Stock Items,Позиции на складе, -Stock Ledger,Книга учета Запасов, -Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Записи складской книги и записи GL запасов отправляются для выбранных покупок, +Stock Ledger,Книга учета запасов, +Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Записи книги учета запасов и записи GL повторно публикуются для выбранных квитанций о покупках, Stock Levels,Уровень запасов, Stock Liabilities,Обязательства по запасам, Stock Options,Опционы, @@ -2829,9 +2827,9 @@ Student Email ID,Идентификация студента по электро Student Group,Учебная группа, Student Group Strength,Сила студенческой группы, Student Group is already updated.,Студенческая группа уже обновлена., -Student Group: ,Студенческая группа:, +Student Group: ,Студенческая группа: , Student ID,Студенческий билет, -Student ID: ,Студенческий билет:, +Student ID: ,Студенческий билет: , Student LMS Activity,Студенческая LMS Активность, Student Mobile No.,Мобильный номер студента, Student Name,Имя ученика, @@ -2864,9 +2862,9 @@ Successfully created payment entries,Успешно созданные плат Successfully deleted all transactions related to this company!,"Успешно удален все сделки, связанные с этой компанией!", Sum of Scores of Assessment Criteria needs to be {0}.,Сумма десятков критериев оценки должно быть {0}., Sum of points for all goals should be 100. It is {0},Сумма баллов за все цели должны быть 100. Это {0}, -Summary,Резюме, -Summary for this month and pending activities,Резюме для этого месяца и в ожидании деятельности, -Summary for this week and pending activities,Резюме на этой неделе и в ожидании деятельности, +Summary,Сводка, +Summary for this month and pending activities,Сводка за этот месяц и предстоящие мероприятия, +Summary for this week and pending activities,Сводка за эту неделю и предстоящие мероприятия, Sunday,Воскресенье, Suplier,Поставщик, Supplier,Поставщик, @@ -2879,8 +2877,8 @@ Supplier Invoice No exists in Purchase Invoice {0},Номер счета пос Supplier Name,наименование поставщика, Supplier Part No,Деталь поставщика №, Supplier Quotation,Предложение поставщика, -Supplier Scorecard,Поставщик Scorecard, -Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Поставщик Склад обязательным для субподрядчиком ТОВАРНЫЙ ЧЕК, +Supplier Scorecard,Оценочная карта поставщика, +Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Наличие склада поставщика обязательно для субподрядной квитанции о покупке, Supplier database.,База данных поставщиков., Supplier {0} not found in {1},Поставщик {0} не найден в {1}, Supplier(s),Поставщик (и), @@ -3199,7 +3197,7 @@ Used Leaves,Используемые листы, User,Пользователь, User ID,ID пользователя, User ID not set for Employee {0},ID пользователя не установлен для сотрудника {0}, -User Remark,Примечание Пользователь, +User Remark,Примечание пользователя, User has not applied rule on the invoice {0},Пользователь не применил правило к счету {0}, User {0} already exists,Пользователь {0} уже существует, User {0} created,Пользователь {0} создан, @@ -3243,7 +3241,7 @@ View Fees Records,Посмотреть рекорды, View Form,Посмотреть форму, View Lab Tests,Просмотр лабораторных тестов, View Leads,Посмотреть лиды, -View Ledger,Посмотреть Леджер, +View Ledger,Посмотреть записи, View Now,Просмотр сейчас, View a list of all the help videos,Просмотреть список всех справочных видео, View in Cart,Смотрите в корзину, @@ -3313,8 +3311,8 @@ Work Order {0} must be submitted,Порядок работы {0} должен б Work Orders Created: {0},Созданы рабочие задания: {0}, Work Summary for {0},Резюме работы для {0}, Work-in-Progress Warehouse is required before Submit,Работа-в-Прогресс Склад требуется перед Отправить, -Workflow, Рабочий процесс, -Working,Работающий, +Workflow,Рабочий процесс, +Working,В работе, Working Hours,Часы работы, Workstation,Рабочая станция, Workstation is closed on the following dates as per Holiday List: {0},Рабочая станция закрыта в следующие даты согласно списка праздников: {0}, @@ -3869,13 +3867,17 @@ Non stock items,Нет на складе, Not Allowed,Не разрешено, Not allowed to create accounting dimension for {0},Не разрешено создавать учетное измерение для {0}, Not permitted. Please disable the Lab Test Template,"Не разрешено Пожалуйста, отключите шаблон лабораторного теста", -Note,Заметки, +Note,Заметка, Notes: ,Заметки: , -On Converting Opportunity,О возможности конвертации, -On Purchase Order Submission,При подаче заказа на поставку, -On Sales Order Submission,На подаче заказа клиента, -On Task Completion,По завершении задачи, +On Converting Opportunity,Конвертацию возможности, +On Purchase Order Submission,Офомление заказа на закупку, +On Sales Order Submission,Оформление заказа на продажу, +On Task Completion,Завершении задачи, On {0} Creation,На {0} создании, +On Item Creation,Создание продукта, +On Lead Creation,Создание лида, +On Supplier Creation,Создание поставщика, +On Customer Creation,Создание клиента, Only .csv and .xlsx files are supported currently,В настоящее время поддерживаются только файлы .csv и .xlsx, Only expired allocation can be cancelled,Только истекшее распределение может быть отменено, Only users with the {0} role can create backdated leave applications,Только пользователи с ролью {0} могут создавать оставленные приложения с задним сроком действия, @@ -4217,7 +4219,7 @@ Mode Of Payment,Способ оплаты, No students Found,Студенты не найдены, Not in Stock,Нет в наличии, Please select a Customer,Выберите клиента, -Printed On,Отпечатано на, +Printed On,Напечатано на, Received From,Получено от, Sales Person,Продавец, To date cannot be before From date,На сегодняшний день не может быть раньше От даты, @@ -4945,14 +4947,14 @@ Max Qty,Макс Кол-во, Min Amt,Мин Amt, Max Amt,Макс Амт, Period Settings,Настройки периода, -Margin,Разница, +Margin,Маржа, Margin Type,Тип маржа, Margin Rate or Amount,Маржинальная ставка или сумма, Price Discount Scheme,Схема скидок, Rate or Discount,Стоимость или скидка, Discount Percentage,Скидка в процентах, Discount Amount,Сумма скидки, -For Price List,Для Прейскурантом, +For Price List,Для прайс-листа, Product Discount Scheme,Схема скидок на товары, Same Item,Тот же пункт, Free Item,Бесплатный товар, @@ -5385,18 +5387,18 @@ Insurance Start Date,Дата начала страхования, Insurance End Date,Дата окончания страхования, Comprehensive Insurance,Комплексное страхование, Maintenance Required,Требуется техническое обслуживание, -Check if Asset requires Preventive Maintenance or Calibration,"Проверьте, требуется ли Asset профилактическое обслуживание или калибровка", +Check if Asset requires Preventive Maintenance or Calibration,"Проверьте, требует ли актив профилактического обслуживания или калибровки", Booked Fixed Asset,Забронированные основные средства, Purchase Receipt Amount,Сумма покупки, Default Finance Book,Финансовая книга по умолчанию, Quality Manager,Менеджер по качеству, -Asset Category Name,Asset Категория Название, +Asset Category Name,Название категории активов, Depreciation Options,Варианты амортизации, Enable Capital Work in Progress Accounting,Включить капитальную работу в процессе учета, Finance Book Detail,Финансовая книга, Asset Category Account,Категория активов Счет, Fixed Asset Account,Счет учета основных средств, -Accumulated Depreciation Account,Начисленной амортизации Счет, +Accumulated Depreciation Account,Счет накопленной амортизации, Depreciation Expense Account,Износ счет расходов, Capital Work In Progress Account,Счет капитальной работы, Asset Finance Book,Финансовая книга по активам, @@ -5441,7 +5443,7 @@ Failure Date,Дата отказа, Assign To Name,Назначить имя, Repair Status,Статус ремонта, Error Description,Описание ошибки, -Downtime,время простоя, +Downtime,Время простоя, Repair Cost,Стоимость ремонта, Manufacturing Manager,Менеджер производства, Current Asset Value,Текущая стоимость актива, @@ -6073,7 +6075,7 @@ Shopify Tax/Shipping Title,Изменить название налога / до ERPNext Account,Учетная запись ERPNext, Shopify Webhook Detail,Узнайте подробности веб-камеры, Webhook ID,Идентификатор Webhook, -Tally Migration,Tally Migration, +Tally Migration,Tally миграция, Master Data,Основные данные, "Data exported from Tally that consists of the Chart of Accounts, Customers, Suppliers, Addresses, Items and UOMs","Данные, экспортированные из Tally, которые состоят из плана счетов, клиентов, поставщиков, адресов, позиций и единиц измерения", Is Master Data Processed,Обработка основных данных, @@ -6082,7 +6084,7 @@ Tally Creditors Account,Счет Tally Creditors, Creditors Account set in Tally,Счет кредиторов установлен в Tally, Tally Debtors Account,Счет Tally должников, Debtors Account set in Tally,Счет дебитора установлен в Tally, -Tally Company,Талли Компания, +Tally Company,Tally Компания, Company Name as per Imported Tally Data,Название компании согласно импортированным данным подсчета, Default UOM,Единица измерения по умолчанию, UOM in case unspecified in imported data,"Единицы измерения, если они не указаны в импортированных данных", @@ -6108,7 +6110,7 @@ Freight and Forwarding Account,Фрахт и пересылка, Creation User,Создание пользователя, "The user that will be used to create Customers, Items and Sales Orders. This user should have the relevant permissions.","Пользователь, который будет использоваться для создания клиентов, товаров и заказов на продажу. Этот пользователь должен иметь соответствующие разрешения.", "This warehouse will be used to create Sales Orders. The fallback warehouse is ""Stores"".",Этот склад будет использоваться для создания заказов на продажу. Резервный склад "Магазины"., -"The fallback series is ""SO-WOO-"".",Аварийная серия "SO-WOO-"., +"The fallback series is ""SO-WOO-"".","Аварийная серия ""SO-WOO-"".", This company will be used to create Sales Orders.,Эта компания будет использоваться для создания заказов на продажу., Delivery After (Days),Доставка после (дней), This is the default offset (days) for the Delivery Date in Sales Orders. The fallback offset is 7 days from the order placement date.,Это смещение по умолчанию (дни) для даты поставки в заказах на продажу. Смещение отступления составляет 7 дней с даты размещения заказа., @@ -6441,7 +6443,7 @@ Job Applicant,Соискатель работы, Applicant Name,Имя заявителя, Appointment Date,Назначенная дата, Appointment Letter Template,Шаблон письма о назначении, -Body,Тело, +Body,Содержимое, Closing Notes,Заметки, Appointment Letter content,Письмо о назначении, Appraisal,Оценка, @@ -6455,7 +6457,7 @@ Appraisal Goal,Оценка Гол, Key Responsibility Area,Ключ Ответственность Площадь, Weightage (%),Weightage (%), Score (0-5),Оценка (0-5), -Score Earned,Оценка Заработано, +Score Earned,Оценка получена, Appraisal Template Title,Название шаблона оценки, Appraisal Template Goal,Цель шаблона оценки, KRA,КРА, @@ -6747,7 +6749,7 @@ Applicant Email Address,Адрес электронной почты заяви Awaiting Response,В ожидании ответа, Job Offer Terms,Условия работы, Select Terms and Conditions,Выберите Сроки и условия, -Printing Details,Печатать Подробности, +Printing Details,Подробности печати, Job Offer Term,Срок действия предложения, Offer Term,Условие предложения, Value / Description,Значение / Описание, @@ -7520,7 +7522,7 @@ Expected Time (in hours),Ожидаемое время (в часах), Is Milestone,Является этапом, Task Description,Описание задания, Dependencies,Зависимости, -Dependent Tasks,Зависимые задачи, +Dependent Tasks,Зависит от задач, Depends on Tasks,Зависит от задач, Actual Start Date (via Time Sheet),Фактическая дата начала (по табелю учета рабочего времени), Actual Time (in hours),Фактическое время (в часах), @@ -7645,7 +7647,7 @@ Campaign Schedules,Расписание кампаний, Buyer of Goods and Services.,Покупатель товаров и услуг., CUST-.YYYY.-,КЛИЕНТ-.YYYY.-, Default Company Bank Account,Стандартный банковский счет компании, -From Lead,Из Лида, +From Lead,Из лида, Account Manager,Менеджер по работе с клиентами, Allow Sales Invoice Creation Without Sales Order,Разрешить создание счета без заказа на продажу, Allow Sales Invoice Creation Without Delivery Note,Разрешить создание счета без накладной, @@ -7818,14 +7820,14 @@ Phone No,Номер телефона, Company Description,Описание Компании, Registration Details,Регистрационные данные, Company registration numbers for your reference. Tax numbers etc.,Регистрационные номера компании для вашей справки. Налоговые числа и т.д., -Delete Company Transactions,Удалить Сделки Компания, +Delete Company Transactions,Удалить транзакции компании, Currency Exchange,Курс обмена валюты, Specify Exchange Rate to convert one currency into another,Укажите Курс конвертировать одну валюту в другую, From Currency,Из валюты, To Currency,В валюту, For Buying,Для покупки, For Selling,Для продажи, -Customer Group Name,Группа Имя клиента, +Customer Group Name,Название группы клиентов, Parent Customer Group,Родительская группа клиента, Only leaf nodes are allowed in transaction,Только листовые узлы допускаются в сделке, Mention if non-standard receivable account applicable,Упоминание если нестандартная задолженность счет применимо, @@ -7893,7 +7895,7 @@ This is the number of the last created transaction with this prefix,Это чи Update Series Number,Обновить Идентификаторы по Номеру, Quotation Lost Reason,Причина Отказа от Предложения, A third party distributor / dealer / commission agent / affiliate / reseller who sells the companies products for a commission.,"Сторонний дистрибьютер, дилер, агент, филиал или реселлер, который продаёт продукты компании за комиссионное вознаграждение.", -Sales Partner Name,Имя Партнера по продажам, +Sales Partner Name,Имя партнера по продажам, Partner Type,Тип партнера, Address & Contacts,Адрес и контакты, Address Desc,Адрес по убыванию, @@ -7914,7 +7916,7 @@ Sales Person Targets,Цели продавца, Set targets Item Group-wise for this Sales Person.,Задайте цели Продуктовых Групп для Продавца, Supplier Group Name,Название группы поставщиков, Parent Supplier Group,Родительская группа поставщиков, -Target Detail,Цель Подробности, +Target Detail,Подробности цели, Target Qty,Целевое количество, Target Amount,Целевая сумма, Target Distribution,Распределение цели, @@ -7973,13 +7975,13 @@ Is Return,Является Вернуться, Issue Credit Note,Кредитная кредитная карта, Return Against Delivery Note,Вернуться На накладной, Customer's Purchase Order No,Клиентам Заказ Нет, -Billing Address Name,Адрес для выставления счета Имя, +Billing Address Name,Название адреса для выставления счета, Required only for sample item.,Требуется только для образца пункта., "If you have created a standard template in Sales Taxes and Charges Template, select one and click on the button below.","Если вы создали стандартный шаблон в шаблонах Налоги с налогами и сбором платежей, выберите его и нажмите кнопку ниже.", In Words will be visible once you save the Delivery Note.,По словам будет виден только вы сохраните накладной., In Words (Export) will be visible once you save the Delivery Note.,В Слов (Экспорт) будут видны только вы сохраните накладной., -Transporter Info,Transporter информация, -Driver Name,Имя драйвера, +Transporter Info,Информация для транспортировки, +Driver Name,Имя водителя, Track this Delivery Note against any Project,Подписка на Delivery Note против любого проекта, Inter Company Reference,Справочник Интер, Print Without Amount,Распечатать без суммы, @@ -8079,7 +8081,7 @@ Delivered by Supplier (Drop Ship),Доставка поставщиком, Supplier Items,Продукты поставщика, Foreign Trade Details,Сведения о внешней торговле, Country of Origin,Страна происхождения, -Sales Details,Продажи Подробности, +Sales Details,Детали продажи, Default Sales Unit of Measure,Единица измерения продаж по умолчанию, Is Sales Item,Продаваемый продукт, Max Discount (%),Макс Скидка (%), @@ -8116,8 +8118,8 @@ Synced With Hub,Синхронизированные со ступицей, Item Alternative,Пункт Альтернатива, Alternative Item Code,Альтернативный код товара, Two-way,Двусторонний, -Alternative Item Name,Альтернативное название товара, -Attribute Name,Имя атрибута, +Alternative Item Name,Альтернативное название продукта, +Attribute Name,Название атрибута, Numeric Values,Числовые значения, From Range,От хребта, Increment,Приращение, @@ -8236,8 +8238,8 @@ Transporter Details,Детали транспорта, Vehicle Number,Номер транспортного средства, Vehicle Date,Дата транспортного средства, Received and Accepted,Получил и принял, -Accepted Quantity,Принято Количество, -Rejected Quantity,Отклонен Количество, +Accepted Quantity,Количество принятых, +Rejected Quantity,Количество отклоненных, Accepted Qty as per Stock UOM,Принятое количество в соответствии с единицами измерения запаса, Sample Quantity,Количество образцов, Rate and Amount,Ставку и сумму, @@ -8285,7 +8287,7 @@ Out of AMC,Из КУА, Warranty Period (Days),Гарантийный срок (дней), Serial No Details,Серийный номер подробнее, MAT-STE-.YYYY.-,MAT-STE-.YYYY.-, -Stock Entry Type,Тип входа, +Stock Entry Type,Тип складской записи, Stock Entry (Outward GIT),Вход в акции (внешний GIT), Material Consumption for Manufacture,Потребление материала для производства, Repack,Перепаковать, @@ -8447,7 +8449,7 @@ No of Sent SMS,Кол-во отправленных СМС, Sent To,Отправить, Absent Student Report,Отчет о пропуске занятия, Assessment Plan Status,Статус плана оценки, -Asset Depreciation Ledger,Износ Леджер активов, +Asset Depreciation Ledger,Книга амортизации основных средств, Asset Depreciations and Balances,Активов Амортизация и противовесов, Available Stock for Packing Items,Доступные Запасы для Комплектации Продуктов, Bank Clearance Summary,Банк уплата по счетам итого, @@ -8559,7 +8561,7 @@ Sales Order Trends,Динамика по сделкам, Sales Partner Commission Summary,Сводка комиссий партнеров по продажам, Sales Partner Target Variance based on Item Group,Целевое отклонение партнера по продажам на основе группы товаров, Sales Partner Transaction Summary,Сводка по сделкам с партнерами по продажам, -Sales Partners Commission,Комиссионные Партнеров по продажам, +Sales Partners Commission,Комиссия партнеров по продажам, Invoiced Amount (Exclusive Tax),Сумма счета (без учета налога), Average Commission Rate,Средний Уровень Комиссии, Sales Payment Summary,Сводка по продажам, @@ -8579,7 +8581,7 @@ Student Fee Collection,Student Fee Collection, Student Monthly Attendance Sheet,Ежемесячная посещаемость студентов, Subcontracted Item To Be Received,"Субподрядный предмет, подлежащий получению", Subcontracted Raw Materials To Be Transferred,Субподрядное сырье для передачи, -Supplier Ledger Summary,Список поставщиков, +Supplier Ledger Summary,Сводка книги поставщиков, Supplier-Wise Sales Analytics,Аналитика продаж в разрезе поставщиков, Support Hour Distribution,Распределение поддержки, TDS Computation Summary,Резюме вычислений TDS, @@ -9242,7 +9244,7 @@ Tasks Completed,Задачи выполнены, Tasks Overdue,Просроченные задачи, Completion,Завершение, Provident Fund Deductions,Отчисления в резервный фонд, -Purchase Order Analysis,Анализ заказа на закупку, +Purchase Order Analysis,Анализ заказов на закупку, From and To Dates are required.,Укажите даты от и до., To Date cannot be before From Date.,Дата не может быть раньше даты начала., Qty to Bill,Кол-во к счету, @@ -9267,7 +9269,7 @@ Sales Order Analysis,Анализ заказов на продажу, Amount Delivered,Сумма доставки, Delay (in Days),Задержка (в днях), Group by Sales Order,Группировать по заказу на продажу, - Sales Value,Объем продаж, + Sales Value, Объем продаж, Stock Qty vs Serial No Count,Кол-во на складе по сравнению с серийным номером, Serial No Count,Серийный номер, Work Order Summary,Сводка заказа на работу, @@ -9320,9 +9322,9 @@ Error creating membership entry for {0},Ошибка создания запис A customer is already linked to this Member,Клиент уже привязан к этому участнику, End Date must not be lesser than Start Date,Дата окончания не должна быть меньше даты начала., Employee {0} already has Active Shift {1}: {2},Сотрудник {0} уже имеет активную смену {1}: {2}, - from {0},от {0}, - to {0},в {0}, -Please select Employee first.,"Пожалуйста, сначала выберите Сотрудник.", + from {0}, от {0}, + to {0}, в {0}, +Please select Employee first.,"Пожалуйста, сначала выберите сотрудника.", Please set {0} for the Employee or for Department: {1},Установите {0} для сотрудника или отдела: {1}, To Date should be greater than From Date,"Дата до должна быть больше, чем Дата", Employee Onboarding: {0} is already for Job Applicant: {1},Прием на работу сотрудника: {0} уже для соискателя: {1}, @@ -9838,3 +9840,8 @@ Enable European Access,Включить европейский доступ, Creating Purchase Order ...,Создание заказа на поставку ..., "Select a Supplier from the Default Suppliers of the items below. On selection, a Purchase Order will be made against items belonging to the selected Supplier only.","Выберите поставщика из списка поставщиков по умолчанию для позиций ниже. При выборе Заказ на поставку будет сделан в отношении товаров, принадлежащих только выбранному Поставщику.", Row #{}: You must select {} serial numbers for item {}.,Строка № {}: необходимо выбрать {} серийных номеров для позиции {}., +Items & Pricing,Продукты и цены, +Overdue,Просрочено, +Completed,Завершенно, +Total Tasks,Всего задач, +Build,Конструктор, From fb9b302ecf8ca94be9b4561aa87c1e85b0cc74ee Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 19:20:53 +0530 Subject: [PATCH 34/55] fix: Reset represents company on disabling internal customer and supplier (backport #31302) (#31306) fix: Reset represents company on disabling internal customer and supplier (#31302) (cherry picked from commit c13e5ad741de68a51ae478727c35dea5fb6f2390) Co-authored-by: Deepesh Garg --- erpnext/buying/doctype/supplier/supplier.py | 3 +++ erpnext/selling/doctype/customer/customer.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 6ddc2809c7f..6fdeaaa4c1e 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -84,6 +84,9 @@ class Supplier(TransactionBase): self.save() def validate_internal_supplier(self): + if not self.is_internal_supplier: + self.represents_company = "" + internal_supplier = frappe.db.get_value( "Supplier", { diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 8889a5f939a..35e0b0de407 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -141,6 +141,9 @@ class Customer(TransactionBase): ) def validate_internal_customer(self): + if not self.is_internal_customer: + self.represents_company = "" + internal_customer = frappe.db.get_value( "Customer", { From 00371f4a224e58b9f48456ea41b17f51ab7f9cac Mon Sep 17 00:00:00 2001 From: Sun Howwrongbum Date: Wed, 1 Jun 2022 20:20:16 +0530 Subject: [PATCH 35/55] fix: Trial Balance failing to ignore Finance Book (cherry picked from commit 48bde2de2a2280d788f7688f9cb523d76042ffcf) --- .../accounts/report/trial_balance/trial_balance.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index e5a4ed2f347..af447df52a8 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -160,14 +160,10 @@ def get_rootwise_opening_balances(filters, report_type): if filters.project: additional_conditions += " and project = %(project)s" - if filters.finance_book: - fb_conditions = " AND finance_book = %(finance_book)s" - if filters.include_default_book_entries: - fb_conditions = ( - " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" - ) - - additional_conditions += fb_conditions + if filters.get("include_default_book_entries"): + additional_conditions += "AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" + else: + additional_conditions += "AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)" accounting_dimensions = get_accounting_dimensions(as_list=False) From 44642dba39fbe5e6e44a8687462fdbc91aa26eeb Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 9 Jun 2022 18:58:04 +0530 Subject: [PATCH 36/55] chore: Linting Issues (cherry picked from commit b9dbb36d0e55eb4f12e067032f5e7e93875304e3) --- erpnext/accounts/report/trial_balance/trial_balance.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index af447df52a8..26572130d26 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -161,7 +161,9 @@ def get_rootwise_opening_balances(filters, report_type): additional_conditions += " and project = %(project)s" if filters.get("include_default_book_entries"): - additional_conditions += "AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" + additional_conditions += ( + "AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" + ) else: additional_conditions += "AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)" From 894f945be7e09ac312c9b39dc29d9ae119a5d2f3 Mon Sep 17 00:00:00 2001 From: Sun Howwrongbum Date: Thu, 9 Jun 2022 19:28:59 +0530 Subject: [PATCH 37/55] fix: typo in sql condition (cherry picked from commit ee2949aa3fa221877d7a16a02e1e3164894f8219) --- erpnext/accounts/report/trial_balance/trial_balance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 26572130d26..6bd08ad837a 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -162,10 +162,10 @@ def get_rootwise_opening_balances(filters, report_type): if filters.get("include_default_book_entries"): additional_conditions += ( - "AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" + " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" ) else: - additional_conditions += "AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)" + additional_conditions += " AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)" accounting_dimensions = get_accounting_dimensions(as_list=False) From e5d2c59929ec233da97a3a1a5530a7397ed88f7f Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 9 Jun 2022 11:50:37 +0530 Subject: [PATCH 38/55] fix(India): Incorrect taxable in GSTR-3B report (cherry picked from commit 20f568c159424a728d8cf87b087efea069732579) --- .../doctype/gstr_3b_report/gstr_3b_report.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py index 579540993e2..7daf22839d2 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -245,11 +245,10 @@ class GSTR3BReport(Document): ) for d in item_details: - if d.item_code not in self.invoice_items.get(d.parent, {}): - self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) - self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) or d.get( - "base_net_amount", 0 - ) + self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) + self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) or d.get( + "base_net_amount", 0 + ) if d.is_nil_exempt and d.item_code not in self.is_nil_exempt: self.is_nil_exempt.append(d.item_code) @@ -336,7 +335,7 @@ class GSTR3BReport(Document): def set_outward_taxable_supplies(self): inter_state_supply_details = {} - + invoice_list = {} for inv, items_based_on_rate in self.items_based_on_tax_rate.items(): gst_category = self.invoice_detail_map.get(inv, {}).get("gst_category") place_of_supply = ( @@ -344,6 +343,8 @@ class GSTR3BReport(Document): ) export_type = self.invoice_detail_map.get(inv, {}).get("export_type") + invoice_list.setdefault(inv, 0.0) + for rate, items in items_based_on_rate.items(): for item_code, taxable_value in self.invoice_items.get(inv).items(): if item_code in items: @@ -362,7 +363,6 @@ class GSTR3BReport(Document): else: self.report_dict["sup_details"]["osup_det"]["iamt"] += taxable_value * rate / 100 self.report_dict["sup_details"]["osup_det"]["txval"] += taxable_value - if ( gst_category in ["Unregistered", "Registered Composition", "UIN Holders"] and self.gst_details.get("gst_state") != place_of_supply.split("-")[1] @@ -375,10 +375,12 @@ class GSTR3BReport(Document): inter_state_supply_details[(gst_category, place_of_supply)]["iamt"] += ( taxable_value * rate / 100 ) + invoice_list[inv] += taxable_value if self.invoice_cess.get(inv): self.report_dict["sup_details"]["osup_det"]["csamt"] += flt(self.invoice_cess.get(inv), 2) + print({k: v for k, v in sorted(invoice_list.items(), key=lambda item: item[1])}) self.set_inter_state_supply(inter_state_supply_details) def set_supplies_liable_to_reverse_charge(self): From 176a6722e504331efb0106dd8b11d0494fb6c0b1 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 9 Jun 2022 11:52:46 +0530 Subject: [PATCH 39/55] chore: cleanup (cherry picked from commit 50aafdbe99ff3f35b6816863768d0144c1e17e7b) --- erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py index 7daf22839d2..be062fcff85 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -335,7 +335,6 @@ class GSTR3BReport(Document): def set_outward_taxable_supplies(self): inter_state_supply_details = {} - invoice_list = {} for inv, items_based_on_rate in self.items_based_on_tax_rate.items(): gst_category = self.invoice_detail_map.get(inv, {}).get("gst_category") place_of_supply = ( @@ -343,8 +342,6 @@ class GSTR3BReport(Document): ) export_type = self.invoice_detail_map.get(inv, {}).get("export_type") - invoice_list.setdefault(inv, 0.0) - for rate, items in items_based_on_rate.items(): for item_code, taxable_value in self.invoice_items.get(inv).items(): if item_code in items: @@ -375,12 +372,10 @@ class GSTR3BReport(Document): inter_state_supply_details[(gst_category, place_of_supply)]["iamt"] += ( taxable_value * rate / 100 ) - invoice_list[inv] += taxable_value if self.invoice_cess.get(inv): self.report_dict["sup_details"]["osup_det"]["csamt"] += flt(self.invoice_cess.get(inv), 2) - print({k: v for k, v in sorted(invoice_list.items(), key=lambda item: item[1])}) self.set_inter_state_supply(inter_state_supply_details) def set_supplies_liable_to_reverse_charge(self): From 95f8784ea9b3ac1243cfbcf948e9f418261d0cbb Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Jun 2022 10:59:46 +0530 Subject: [PATCH 40/55] chore: Resolve conflicts --- erpnext/translations/de.csv | 3 --- 1 file changed, 3 deletions(-) diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 28fbd2fd828..5ab6ff90cbb 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -783,10 +783,7 @@ Default Activity Cost exists for Activity Type - {0},Es gibt Standard-Aktivität Default BOM ({0}) must be active for this item or its template,Standardstückliste ({0}) muss für diesen Artikel oder dessen Vorlage aktiv sein, Default BOM for {0} not found,Standardstückliste für {0} nicht gefunden, Default BOM not found for Item {0} and Project {1},Standard-Stückliste nicht gefunden für Position {0} und Projekt {1}, -<<<<<<< HEAD -======= Default In-Transit Warehouse,Standard-Durchgangslager, ->>>>>>> 2388d86623 (feat: Add german translations) Default Letter Head,Standardbriefkopf, Default Tax Template,Standardsteuervorlage, Default Unit of Measure for Item {0} cannot be changed directly because you have already made some transaction(s) with another UOM. You will need to create a new Item to use a different Default UOM.,"Die Standard-Maßeinheit für Artikel {0} kann nicht direkt geändert werden, weil Sie bereits einige Transaktionen mit einer anderen Maßeinheit durchgeführt haben. Sie müssen einen neuen Artikel erstellen, um eine andere Standard-Maßeinheit verwenden zukönnen.", From 391ed9c5670ab7639c1c3b6c885f038e4d123055 Mon Sep 17 00:00:00 2001 From: RJPvT <48353029+RJPvT@users.noreply.github.com> Date: Wed, 8 Jun 2022 10:55:15 +0200 Subject: [PATCH 41/55] fix: locale Currency and Float setting in update_employee In fieldtypes locale settings (example NL) . and , changes whereby the field is inproperly filled (cherry picked from commit 17887cde7122ff2332f92394cfa2c8d1e196339a) --- erpnext/hr/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 387c2ca5e5a..9707b81089b 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -191,6 +191,8 @@ def update_employee_work_history(employee, details, date=None, cancel=False): new_data = getdate(new_data) elif fieldtype == "Datetime" and new_data: new_data = get_datetime(new_data) + elif fieldtype in ["Currency", "Float"] and new_data: + new_data = flt(new_data) setattr(employee, item.fieldname, new_data) if item.fieldname in ["department", "designation", "branch"]: internal_work_history[item.fieldname] = item.new From 4c9422fb1b9e79c455824ea6e1ffd702407a4d38 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Jun 2022 12:39:12 +0530 Subject: [PATCH 42/55] chore: resolve conflicts --- erpnext/loan_management/doctype/loan/loan.py | 8 ++-- .../loan_interest_accrual.py | 38 ++----------------- erpnext/patches.txt | 6 --- 3 files changed, 7 insertions(+), 45 deletions(-) diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index 69e9ef8ebc3..ff940545a1b 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -61,11 +61,11 @@ class Loan(AccountsController): ) def validate_cost_center(self): - if not self.cost_center and self.rate_of_interest != 0: - self.cost_center = frappe.db.get_value('Company', self.company, 'cost_center') + if not self.cost_center and self.rate_of_interest != 0.0: + self.cost_center = frappe.db.get_value("Company", self.company, "cost_center") - if not self.cost_center: - frappe.throw(_('Cost center is mandatory for loans having rate of interest greater than 0')) + if not self.cost_center: + frappe.throw(_("Cost center is mandatory for loans having rate of interest greater than 0")) def on_submit(self): self.link_loan_security_pledge() diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py index 20eb637faac..3a4c6513e45 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py @@ -40,11 +40,10 @@ class LoanInterestAccrual(AccountsController): def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] - cost_center = frappe.db.get_value('Loan', self.loan, 'cost_center') + cost_center = frappe.db.get_value("Loan", self.loan, "cost_center") if self.interest_amount: gle_map.append( -<<<<<<< HEAD self.get_gl_dict( { "account": self.loan_account, @@ -58,7 +57,7 @@ class LoanInterestAccrual(AccountsController): "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format( self.last_accrual_date, self.posting_date, self.loan ), - "cost_center": erpnext.get_default_cost_center(self.company), + "cost_center": cost_center, "posting_date": self.posting_date, } ) @@ -76,41 +75,10 @@ class LoanInterestAccrual(AccountsController): "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format( self.last_accrual_date, self.posting_date, self.loan ), - "cost_center": erpnext.get_default_cost_center(self.company), + "cost_center": cost_center, "posting_date": self.posting_date, } ) -======= - self.get_gl_dict({ - "account": self.loan_account, - "party_type": self.applicant_type, - "party": self.applicant, - "against": self.interest_income_account, - "debit": self.interest_amount, - "debit_in_account_currency": self.interest_amount, - "against_voucher_type": "Loan", - "against_voucher": self.loan, - "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format( - self.last_accrual_date, self.posting_date, self.loan), - "cost_center": cost_center, - "posting_date": self.posting_date - }) - ) - - gle_map.append( - self.get_gl_dict({ - "account": self.interest_income_account, - "against": self.loan_account, - "credit": self.interest_amount, - "credit_in_account_currency": self.interest_amount, - "against_voucher_type": "Loan", - "against_voucher": self.loan, - "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format( - self.last_accrual_date, self.posting_date, self.loan), - "cost_center": cost_center, - "posting_date": self.posting_date - }) ->>>>>>> 5d66cc4c4a (fix: Add cost center in loan document) ) if gle_map: diff --git a/erpnext/patches.txt b/erpnext/patches.txt index a1e2104f081..f639dc7d380 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -352,7 +352,6 @@ erpnext.patches.v13_0.amazon_mws_deprecation_warning erpnext.patches.v13_0.datev_deprecation_warning erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.update_accounts_in_loan_docs -<<<<<<< HEAD erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022 erpnext.patches.v13_0.rename_non_profit_fields erpnext.patches.v13_0.enable_ksa_vat_docs #1 @@ -368,9 +367,4 @@ erpnext.patches.v13_0.create_accounting_dimensions_in_orders erpnext.patches.v13_0.set_per_billed_in_return_delivery_note erpnext.patches.v13_0.update_employee_advance_status erpnext.patches.v13_0.job_card_status_on_hold -======= -erpnext.patches.v14_0.update_batch_valuation_flag -erpnext.patches.v14_0.delete_non_profit_doctypes -erpnext.patches.v14_0.update_employee_advance_status erpnext.patches.v13_0.add_cost_center_in_loans ->>>>>>> 5d66cc4c4a (fix: Add cost center in loan document) From 1aaca097b3b5d08569ae2845a09c45bf7125e7fb Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Jun 2022 12:51:16 +0530 Subject: [PATCH 43/55] chore: Linting Issues --- .../patches/v13_0/add_cost_center_in_loans.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/erpnext/patches/v13_0/add_cost_center_in_loans.py b/erpnext/patches/v13_0/add_cost_center_in_loans.py index 25e1722a4ff..e293cf2874e 100644 --- a/erpnext/patches/v13_0/add_cost_center_in_loans.py +++ b/erpnext/patches/v13_0/add_cost_center_in_loans.py @@ -2,15 +2,11 @@ import frappe def execute(): - frappe.reload_doc('loan_management', 'doctype', 'loan') - loan = frappe.qb.DocType('Loan') + frappe.reload_doc("loan_management", "doctype", "loan") + loan = frappe.qb.DocType("Loan") - for company in frappe.get_all('Company', pluck='name'): - default_cost_center = frappe.db.get_value('Company', company, 'cost_center') - frappe.qb.update( - loan - ).set( - loan.cost_center, default_cost_center - ).where( + for company in frappe.get_all("Company", pluck="name"): + default_cost_center = frappe.db.get_value("Company", company, "cost_center") + frappe.qb.update(loan).set(loan.cost_center, default_cost_center).where( loan.company == company - ).run() \ No newline at end of file + ).run() From 5ebbe81543c24dc65a57d1e700b3dcbee07b3f68 Mon Sep 17 00:00:00 2001 From: hendrik Date: Fri, 10 Jun 2022 16:22:53 +0700 Subject: [PATCH 44/55] fix: update Period Closing Voucher per Company Validate period closing voucher company-wise (cherry picked from commit 74b274f555801356b1ef05577eb4cb2cfb79b8d3) --- .../doctype/period_closing_voucher/period_closing_voucher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index 53b1c64c460..5a86376199c 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -54,8 +54,8 @@ class PeriodClosingVoucher(AccountsController): pce = frappe.db.sql( """select name from `tabPeriod Closing Voucher` - where posting_date > %s and fiscal_year = %s and docstatus = 1""", - (self.posting_date, self.fiscal_year), + where posting_date > %s and fiscal_year = %s and docstatus = 1 and company = %s""", + (self.posting_date, self.fiscal_year, self.company), ) if pce and pce[0][0]: frappe.throw( From 50a4c2e9dcab04f61ac373834970ea2c793809b1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 10 Jun 2022 18:30:50 +0530 Subject: [PATCH 45/55] refactor: remove add_fetch (backport #31315) (#31319) * refactor: remove add_fetch (#31315) - Sales Team already had fetch from set up - Set up fetch from on sales partner in sales transaction Reason for removal: the JS code applies arbitrarily to any field called "sales_person" (cherry picked from commit 1646fbe478fefaa173c2c1e009d1a5d0dcb13326) # Conflicts: # erpnext/selling/sales_common.js * chore: conflicts Co-authored-by: Ankush Menat --- erpnext/accounts/doctype/sales_invoice/sales_invoice.json | 4 +++- erpnext/selling/doctype/sales_order/sales_order.json | 4 +++- erpnext/selling/sales_common.js | 2 -- erpnext/stock/doctype/delivery_note/delivery_note.json | 4 +++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 80b95db8868..327545aa54e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -1790,6 +1790,8 @@ "width": "50%" }, { + "fetch_from": "sales_partner.commission_rate", + "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "hide_days": 1, @@ -2038,7 +2040,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2022-03-08 16:08:53.517903", + "modified": "2022-06-10 03:52:51.409913", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 6bcc8f05ac3..fc00fa6ea68 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1359,6 +1359,8 @@ "width": "50%" }, { + "fetch_from": "sales_partner.commission_rate", + "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "hide_days": 1, @@ -1547,7 +1549,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-04-26 14:38:18.350207", + "modified": "2022-06-10 03:52:22.212953", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index 05d93a533af..cab67c98d60 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -12,8 +12,6 @@ frappe.provide("erpnext.selling"); erpnext.selling.SellingController = erpnext.TransactionController.extend({ setup: function() { this._super(); - this.frm.add_fetch("sales_partner", "commission_rate", "commission_rate"); - this.frm.add_fetch("sales_person", "commission_rate", "commission_rate"); }, onload: function() { diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index e3222bc8850..f9e934921d8 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1192,6 +1192,8 @@ "width": "50%" }, { + "fetch_from": "sales_partner.commission_rate", + "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "label": "Commission Rate (%)", @@ -1334,7 +1336,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2022-04-26 14:48:08.781837", + "modified": "2022-06-10 03:52:04.197415", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", From 78473b8d994d028a2b685d950ce66608c6cd3c90 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Jun 2022 18:43:46 +0530 Subject: [PATCH 46/55] fix(UX): use doc.status for Job Card status (#31320) - Use doc.status directly for indicator - single source of truth - Update status to cancelled when doc is cancelled (cherry picked from commit 39ec0aca9550e64bccdbdad96613f25b97026c53) --- .../doctype/job_card/job_card.py | 13 +++++----- .../doctype/job_card/job_card_list.js | 23 +++++++++-------- .../doctype/job_card/test_job_card.py | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index dadaaf9aa96..c24d07d2194 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -626,14 +626,15 @@ class JobCard(Document): self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] - if self.for_quantity <= self.transferred_qty: - self.status = "Material Transferred" + if self.docstatus < 2: + if self.for_quantity <= self.transferred_qty: + self.status = "Material Transferred" - if self.time_logs: - self.status = "Work In Progress" + if self.time_logs: + self.status = "Work In Progress" - if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items): - self.status = "Completed" + if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items): + self.status = "Completed" if update_status: self.db_set("status", self.status) diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js index 7f60bdc6d92..5d883bf9fa7 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_list.js +++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js @@ -1,16 +1,17 @@ frappe.listview_settings['Job Card'] = { has_indicator_for_draft: true, + get_indicator: function(doc) { - if (doc.status === "Work In Progress") { - return [__("Work In Progress"), "orange", "status,=,Work In Progress"]; - } else if (doc.status === "Completed") { - return [__("Completed"), "green", "status,=,Completed"]; - } else if (doc.docstatus == 2) { - return [__("Cancelled"), "red", "status,=,Cancelled"]; - } else if (doc.status === "Material Transferred") { - return [__('Material Transferred'), "blue", "status,=,Material Transferred"]; - } else { - return [__("Open"), "red", "status,=,Open"]; - } + const status_colors = { + "Work In Progress": "orange", + "Completed": "green", + "Cancelled": "red", + "Material Transferred": "blue", + "Open": "red", + }; + const status = doc.status || "Open"; + const color = status_colors[status] || "blue"; + + return [__(status), color, `status,=,${status}`]; } }; diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index b5371af2ccb..71aa5ef0951 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -341,6 +341,31 @@ class TestJobCard(FrappeTestCase): cost_after_cancel = self.work_order.total_operating_cost self.assertEqual(cost_after_cancel, original_cost) + def test_job_card_statuses(self): + def assertStatus(status): + jc.set_status() + self.assertEqual(jc.status, status) + + jc = frappe.new_doc("Job Card") + jc.for_quantity = 2 + jc.transferred_qty = 1 + jc.total_completed_qty = 0 + jc.time_logs = [] + assertStatus("Open") + + jc.transferred_qty = jc.for_quantity + assertStatus("Material Transferred") + + jc.append("time_logs", {}) + assertStatus("Work In Progress") + + jc.docstatus = 1 + jc.total_completed_qty = jc.for_quantity + assertStatus("Completed") + + jc.docstatus = 2 + assertStatus("Cancelled") + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" From 5d0f27145164b17ca99ccf1de12b7326b6367e14 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 12:18:46 +0530 Subject: [PATCH 47/55] fix: update fr translation (backport #31232) (#31334) * fix: update fr translation (#31232) * update fr translation * fix:update fr translation * fix:update fr translation * fix:update fr translation * fix:update fr translation * fix:update fr translation * fix:update fr translation * fix:update fr translation * Update fr.csv update typo * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * update fr translation * fix: Use elision instead of HTML code equivalent * fix: Use elision instead of HTML code equivalent (pt 2) * fix: Use elision/single quote instead of HTML code equivalent (pt 3) Co-authored-by: Marica (cherry picked from commit 83367bfe5e286244461caa6d2dcddc5e851f2e3c) * fix: Accidental '=' instead of comma Co-authored-by: HENRY Florian Co-authored-by: Marica --- erpnext/translations/fr.csv | 48 +++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index 70c150a2cba..800b5a2e184 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -1352,11 +1352,11 @@ Item Description,Description de l'Article, Item Group,Groupe d'Article, Item Group Tree,Arborescence de Groupe d'Article, Item Group not mentioned in item master for item {0},Le Groupe d'Articles n'est pas mentionné dans la fiche de l'article pour l'article {0}, -Item Name,Nom de l'article, +Item Name,Nom de l'article, Item Price added for {0} in Price List {1},Prix de l'Article ajouté pour {0} dans la Liste de Prix {1}, -"Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Le prix de l'article apparaît plusieurs fois en fonction de la liste de prix, du fournisseur / client, de la devise, de l'article, de l'unité de mesure, de la quantité et des dates.", +"Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Le prix de l'article apparaît plusieurs fois en fonction de la liste de prix, du fournisseur / client, de la devise, de l'article, de l'unité de mesure, de la quantité et des dates.", Item Price updated for {0} in Price List {1},Prix de l'Article mis à jour pour {0} dans la Liste des Prix {1}, -Item Row {0}: {1} {2} does not exist in above '{1}' table,Ligne d'objet {0}: {1} {2} n'existe pas dans la table '{1}' ci-dessus, +Item Row {0}: {1} {2} does not exist in above '{1}' table,Ligne d'objet {0}: {1} {2} n'existe pas dans la table '{1}' ci-dessus, Item Tax Row {0} must have account of type Tax or Income or Expense or Chargeable,La Ligne de Taxe d'Article {0} doit indiquer un compte de type Taxes ou Produit ou Charge ou Facturable, Item Template,Modèle d'article, Item Variant Settings,Paramètres de Variante d'Article, @@ -3661,7 +3661,7 @@ Chart,Graphique, Choose a corresponding payment,Choisissez un paiement correspondant, Click on the link below to verify your email and confirm the appointment,Cliquez sur le lien ci-dessous pour vérifier votre email et confirmer le rendez-vous, Close,Fermer, -Communication,la communication, +Communication,Communication, Compact Item Print,Impression de l'Article Compacté, Company,Société, Company of asset {0} and purchase document {1} doesn't matches.,La société de l'actif {0} et le document d'achat {1} ne correspondent pas., @@ -3969,7 +3969,7 @@ Quantity to Manufacture can not be zero for the operation {0},La quantité à fa Quarterly,Trimestriel, Queued,File d'Attente, Quick Entry,Écriture Rapide, -Quiz {0} does not exist,Le questionnaire {0} n'existe pas, +Quiz {0} does not exist,Le questionnaire {0} n'existe pas, Quotation Amount,Montant du devis, Rate or Discount is required for the price discount.,Le taux ou la remise est requis pour la remise de prix., Reason,Raison, @@ -4071,7 +4071,7 @@ Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and Stores - {0},Magasins - {0}, Student with email {0} does not exist,Étudiant avec le courrier électronique {0} n'existe pas, Submit Review,Poster un commentaire, -Submitted,Soumis, +Submitted,Valider, Supplier Addresses And Contacts,Adresses et contacts des fournisseurs, Synchronize this account,Synchroniser ce compte, Tag,Étiquette, @@ -9872,8 +9872,42 @@ Show Barcode Field in Stock Transactions,Afficher le champ Code Barre dans les t Convert Item Description to Clean HTML in Transactions,Convertir les descriptions d'articles en HTML valide lors des transactions Have Default Naming Series for Batch ID?,Nom de série par défaut pour les Lots ou Séries "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units","Le pourcentage de quantité que vous pourrez réceptionner en plus de la quantité commandée. Par exemple, vous avez commandé 100 unités, votre pourcentage de dépassement est de 10%, vous pourrez réceptionner 110 unités" -Unit Of Measure (UOM),Unité de mesure (UDM), Allowed Items,Articles autorisés Party Specific Item,Restriction d'article disponible Restrict Items Based On,Type de critére de restriction Based On Value,critére de restriction +Unit of Measure (UOM),Unité de mesure (UDM), +Unit Of Measure (UOM),Unité de mesure (UDM), +CRM Settings,Paramètres CRM +Do Not Explode,Ne pas décomposer +Quick Access, Accés rapides +{} Available,{} Disponible.s +{} Pending,{} En attente.s +{} To Bill,{} à facturer +{} To Receive,{} A recevoir +{} Active,{} Actif.ve(s) +{} Open,{} Ouvert.e(s) +Incorrect Data Report,Rapport de données incohérentes +Incorrect Serial No Valuation,Valorisation inccorecte par Num. Série / Lots +Incorrect Balance Qty After Transaction,Equilibre des quantités aprés une transaction +Interview Type,Type d'entretien +Interview Round,Cycle d'entretien +Interview,Entretien +Interview Feedback,Retour d'entretien +Journal Energy Point,Historique des points d'énergies +Billing Address Details,Adresse de facturation (détails) +Supplier Address Details,Adresse Fournisseur (détails) +Retail,Commerce +Users,Utilisateurs +Permission Manager,Gestion des permissions +Fetch Timesheet,Récuprer les temps saisis +Get Supplier Group Details,Appliquer les informations depuis le Groupe de fournisseur +Quality Inspection(s),Inspection(s) Qualité +Set Advances and Allocate (FIFO),Affecter les encours au réglement +Apply Putaway Rule,Appliquer la régle de routage d'entrepot +Delete Transactions,Supprimer les transactions +Default Payment Discount Account,Compte par défaut des paiements de remise +Unrealized Profit / Loss Account,Compte de perte +Enable Provisional Accounting For Non Stock Items,Activer la provision pour les articles non stockés +Publish in Website,Publier sur le Site Web +List View,Vue en liste From 79b20622c9ebb9121a2d2633916fd02d8b7906db Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 13 Jun 2022 17:59:03 +0530 Subject: [PATCH 48/55] fix: Supplied Qty not updated on Stock Entry cancel - Loop over PO supplied items and update them as data from SE will exclude a row if supplied qty becomes 0 on cancel - Use DB API insteaf of raw SQL (cherry picked from commit fa1d9d548e1d1d3af7041c495ba3dc8829cbf7aa) --- .../stock/doctype/stock_entry/stock_entry.py | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 52011afefd1..92f90a1c7d6 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1979,23 +1979,30 @@ class StockEntry(StockController): ): # Get PO Supplied Items Details - item_wh = frappe._dict( - frappe.db.sql( - """ - select rm_item_code, reserve_warehouse - from `tabPurchase Order` po, `tabPurchase Order Item Supplied` poitemsup - where po.name = poitemsup.parent - and po.name = %s""", - self.purchase_order, - ) + po_supplied_items = frappe.db.get_all( + "Purchase Order Item Supplied", + filters={"parent": self.purchase_order}, + fields=["name", "rm_item_code", "reserve_warehouse"], ) + # Get Items Supplied in Stock Entries against PO supplied_items = get_supplied_items(self.purchase_order) - for name, item in supplied_items.items(): - frappe.db.set_value("Purchase Order Item Supplied", name, item) - # Update reserved sub contracted quantity in bin based on Supplied Item Details and + for row in po_supplied_items: + key, item = row.name, {} + if not supplied_items.get(key): + # no stock transferred against PO Supplied Items row + item = {"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0} + else: + item = supplied_items.get(key) + + frappe.db.set_value("Purchase Order Item Supplied", row.name, item) + + # RM Item-Reserve Warehouse Dict + item_wh = {x.get("rm_item_code"): x.get("reserve_warehouse") for x in po_supplied_items} + for d in self.get("items"): + # Update reserved sub contracted quantity in bin based on Supplied Item Details and item_code = d.get("original_item") or d.get("item_code") reserve_warehouse = item_wh.get(item_code) if not (reserve_warehouse and item_code): From 86a0ba5c9fe1834c921fe14eff31cfe45872051e Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 13 Jun 2022 18:31:35 +0530 Subject: [PATCH 49/55] test: PO Supplied Qty reset on cancel/submit (cherry picked from commit b8f468cb4f33a3983428b7c4614168a0e4405608) --- erpnext/tests/test_subcontracting.py | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/erpnext/tests/test_subcontracting.py b/erpnext/tests/test_subcontracting.py index 07291e851b5..8a553c8efb7 100644 --- a/erpnext/tests/test_subcontracting.py +++ b/erpnext/tests/test_subcontracting.py @@ -879,6 +879,55 @@ class TestSubcontracting(unittest.TestCase): for key, value in get_supplied_items(pr1).items(): self.assertEqual(value.qty, 2) + def test_po_supplied_qty(self): + """ + Check if 'Supplied Qty' in PO's Supplied Items table is reset on submit/cancel. + """ + set_backflush_based_on("Material Transferred for Subcontract") + items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Item SA1", + "qty": 5, + "rate": 100, + }, + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Item SA5", + "qty": 6, + "rate": 100, + }, + ] + + rm_items = [ + {"item_code": "Subcontracted SRM Item 1", "qty": 5, "main_item_code": "Subcontracted Item SA1"}, + {"item_code": "Subcontracted SRM Item 2", "qty": 5, "main_item_code": "Subcontracted Item SA1"}, + {"item_code": "Subcontracted SRM Item 3", "qty": 5, "main_item_code": "Subcontracted Item SA1"}, + {"item_code": "Subcontracted SRM Item 5", "qty": 6, "main_item_code": "Subcontracted Item SA5"}, + {"item_code": "Subcontracted SRM Item 4", "qty": 6, "main_item_code": "Subcontracted Item SA5"}, + ] + + itemwise_details = make_stock_in_entry(rm_items=rm_items) + po = create_purchase_order( + rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC" + ) + + for d in rm_items: + d["po_detail"] = po.items[0].name if d.get("qty") == 5 else po.items[1].name + + se = make_stock_transfer_entry( + po_no=po.name, rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details) + ) + + po.reload() + for row in po.get("supplied_items"): + self.assertIn(row.supplied_qty, [5.0, 6.0]) + + se.cancel() + po.reload() + for row in po.get("supplied_items"): + self.assertEqual(row.supplied_qty, 0.0) + def add_second_row_in_pr(pr): item_dict = {} From 6064ca6fedd62cb5ef82fa785074116b14888905 Mon Sep 17 00:00:00 2001 From: Marica Date: Mon, 13 Jun 2022 21:06:04 +0530 Subject: [PATCH 50/55] test: Pass "yes" instead of 1 for `is_subcontracted` in `create_purchase_order` --- erpnext/tests/test_subcontracting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/tests/test_subcontracting.py b/erpnext/tests/test_subcontracting.py index 8a553c8efb7..6c365f2c8e2 100644 --- a/erpnext/tests/test_subcontracting.py +++ b/erpnext/tests/test_subcontracting.py @@ -909,7 +909,7 @@ class TestSubcontracting(unittest.TestCase): itemwise_details = make_stock_in_entry(rm_items=rm_items) po = create_purchase_order( - rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC" + rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC" ) for d in rm_items: From d3759394756cc2679b5002a296690b5689c2e081 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Jun 2022 19:23:17 +0530 Subject: [PATCH 51/55] fix: Company address filter in quotation (cherry picked from commit 2fc04f661ac0dc3df1f6d1cc2bd37920f7c98917) --- erpnext/selling/doctype/quotation/quotation.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 474cf56fc1b..34062fd1a88 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -20,6 +20,20 @@ frappe.ui.form.on('Quotation', { frm.set_df_property('packed_items', 'cannot_add_rows', true); frm.set_df_property('packed_items', 'cannot_delete_rows', true); + + frm.set_query('company_address', function(doc) { + if(!doc.company) { + frappe.throw(__('Please set Company')); + } + + return { + query: 'frappe.contacts.doctype.address.address.address_query', + filters: { + link_doctype: 'Company', + link_name: doc.company + } + }; + }); }, refresh: function(frm) { From a1ba8475d03202e601227f2db1e4d0f2eefa8ecc Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Jun 2022 22:05:58 +0530 Subject: [PATCH 52/55] fix(India): Sales taxes and charges template fetching in quotation (cherry picked from commit 243625898ee884b641c5fcbb437ce35e77eb22d9) --- erpnext/accounts/party.py | 2 +- erpnext/regional/india/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 177624ca032..f07fc64737c 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -212,7 +212,7 @@ def set_address_details( else: party_details.update(get_company_address(company)) - if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order"]: + if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order", "Quotation"]: if party_details.company_address: party_details.update( get_fetch_values(doctype, "company_address", party_details.company_address) diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 88c899734ed..eb9d6743e13 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -288,7 +288,7 @@ def get_regional_address_details(party_details, doctype, company): return party_details if ( - doctype in ("Sales Invoice", "Delivery Note", "Sales Order") + doctype in ("Sales Invoice", "Delivery Note", "Sales Order", "Quotation") and party_details.company_gstin and party_details.company_gstin[:2] != party_details.place_of_supply[:2] ) or ( From 37ba5503987b55e41a174437bca9c99232d256b1 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 11 Jun 2022 21:55:59 +0530 Subject: [PATCH 53/55] fix: Partially Ordered status for quotation (cherry picked from commit 118c786e63173413864dfd0f08a1748eef377059) # Conflicts: # erpnext/selling/doctype/quotation/quotation.json --- erpnext/controllers/status_updater.py | 3 +- .../selling/doctype/quotation/quotation.js | 2 +- .../selling/doctype/quotation/quotation.json | 6 +++- .../selling/doctype/quotation/quotation.py | 28 +++++++++++++++++-- .../doctype/quotation/quotation_list.js | 2 ++ 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 3c0a10e0860..517e080c972 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -35,7 +35,8 @@ status_map = { ["Draft", None], ["Open", "eval:self.docstatus==1"], ["Lost", "eval:self.status=='Lost'"], - ["Ordered", "has_sales_order"], + ["Partially Ordered", "is_partially_ordered"], + ["Ordered", "is_fully_ordered"], ["Cancelled", "eval:self.docstatus==2"], ], "Sales Order": [ diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 34062fd1a88..3bb9774044a 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -83,7 +83,7 @@ erpnext.selling.QuotationController = erpnext.selling.SellingController.extend({ } } - if(doc.docstatus == 1 && doc.status!=='Lost') { + if(doc.docstatus == 1 && !(['Lost', 'Ordered']).includes(doc.status)) { if(!doc.valid_till || frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) { cur_frm.add_custom_button(__('Sales Order'), cur_frm.cscript['Make Sales Order'], __('Create')); diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index dcc0c784639..c819dafd106 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -1084,7 +1084,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "Draft\nOpen\nReplied\nOrdered\nLost\nCancelled\nExpired", + "options": "Draft\nOpen\nReplied\nPartially Ordered\nOrdered\nLost\nCancelled\nExpired", "print_hide": 1, "read_only": 1, "reqd": 1, @@ -1174,7 +1174,11 @@ "idx": 82, "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2022-03-23 16:49:36.297403", +======= + "modified": "2022-06-11 20:35:32.635804", +>>>>>>> 118c786e63 (fix: Partially Ordered status for quotation) "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 5759b504cee..ceb922c0e53 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -59,8 +59,32 @@ class Quotation(SellingController): title=_("Unpublished Item"), ) - def has_sales_order(self): - return frappe.db.get_value("Sales Order Item", {"prevdoc_docname": self.name, "docstatus": 1}) + def get_ordered_status(self): + ordered_items = frappe._dict( + frappe.db.get_all( + "Sales Order Item", + {"prevdoc_docname": self.name, "docstatus": 1}, + ["item_code", "sum(qty)"], + group_by="item_code", + as_list=1, + ) + ) + + status = "Open" + if ordered_items: + status = "Ordered" + + for item in self.get("items"): + if item.qty > ordered_items.get(item.item_code, 0.0): + status = "Partially Ordered" + + return status + + def is_fully_ordered(self): + return self.get_ordered_status() == "Ordered" + + def is_partially_ordered(self): + return self.get_ordered_status() == "Partially Ordered" def update_lead(self): if self.quotation_to == "Lead" and self.party_name: diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js index b631685bd19..93da1bd122f 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -17,6 +17,8 @@ frappe.listview_settings['Quotation'] = { get_indicator: function(doc) { if(doc.status==="Open") { return [__("Open"), "orange", "status,=,Open"]; + } else if(doc.status==="Partially Ordered") { + return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"]; } else if(doc.status==="Ordered") { return [__("Ordered"), "green", "status,=,Ordered"]; } else if(doc.status==="Lost") { From 823cf88c3c66fe66dbf29f63c9fe6a329d9fe0c7 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 14 Jun 2022 10:50:38 +0530 Subject: [PATCH 54/55] chore: linting issues (cherry picked from commit fb3da124e5ca97ab0194a1da21f40f8e4db30f90) --- erpnext/selling/doctype/quotation/quotation_list.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js index 93da1bd122f..990f449a209 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -17,7 +17,7 @@ frappe.listview_settings['Quotation'] = { get_indicator: function(doc) { if(doc.status==="Open") { return [__("Open"), "orange", "status,=,Open"]; - } else if(doc.status==="Partially Ordered") { + } else if (doc.status==="Partially Ordered") { return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"]; } else if(doc.status==="Ordered") { return [__("Ordered"), "green", "status,=,Ordered"]; From da1a948a2842eabea89e2cada04548d6260a47ea Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 14 Jun 2022 11:36:43 +0530 Subject: [PATCH 55/55] chore: resolve conflicts --- erpnext/selling/doctype/quotation/quotation.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index c819dafd106..eeaa4fda585 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -1174,11 +1174,7 @@ "idx": 82, "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2022-03-23 16:49:36.297403", -======= "modified": "2022-06-11 20:35:32.635804", ->>>>>>> 118c786e63 (fix: Partially Ordered status for quotation) "modified_by": "Administrator", "module": "Selling", "name": "Quotation", @@ -1274,4 +1270,4 @@ "sort_order": "DESC", "timeline_field": "party_name", "title_field": "title" -} \ No newline at end of file +}