From 3c35c9b6aee0f7e199aa78837f3d11f225b5a2ae Mon Sep 17 00:00:00 2001 From: hrzzz Date: Sat, 14 May 2022 10:45:26 -0300 Subject: [PATCH 001/133] fix: correction of the calculation to the average value when there is a discount on the document and not on the items --- .../doctype/authorization_control/authorization_control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/authorization_control/authorization_control.py b/erpnext/setup/doctype/authorization_control/authorization_control.py index 309658d260..cfe3d62b8c 100644 --- a/erpnext/setup/doctype/authorization_control/authorization_control.py +++ b/erpnext/setup/doctype/authorization_control/authorization_control.py @@ -135,8 +135,8 @@ class AuthorizationControl(TransactionBase): price_list_rate, base_rate = 0, 0 for d in doc_obj.get("items"): if d.base_rate: - price_list_rate += flt(d.base_price_list_rate) or flt(d.base_rate) - base_rate += flt(d.base_rate) + price_list_rate += (flt(d.base_price_list_rate) or flt(d.base_rate)) * flt(d.qty) + base_rate += flt(d.base_rate) * flt(d.qty) if doc_obj.get("discount_amount"): base_rate -= flt(doc_obj.discount_amount) From b6e46eea80da79d57bfcebdfbe5831f68b7e60e3 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 18 May 2022 13:00:00 +0530 Subject: [PATCH 002/133] 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 220ce1dbd8..a828869c36 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 _ @@ -1130,35 +1130,77 @@ def get_children(parent=None, is_root=False, **filters): return bom_items -def get_boms_in_bottom_up_order(bom_no=None): - def _get_parent(bom_no): - return frappe.db.sql_list( - """ - select distinct bom_item.parent from `tabBOM Item` bom_item - where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM' - and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1) - """, - bom_no, - ) +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 5932e9d78a70fc53f9f1c57ed201815f80960384 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 20:22:13 +0530 Subject: [PATCH 003/133] 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 a828869c36..047bcc5239 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 _ @@ -383,35 +383,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() @@ -613,11 +587,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 @@ -659,7 +633,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 @@ -674,11 +648,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 @@ -693,6 +669,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 @@ -1130,81 +1108,6 @@ def get_children(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 b0e7da1201..5b073b7539 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, Literal, Optional, Union +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union if TYPE_CHECKING: from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog @@ -10,8 +11,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 @@ -47,7 +46,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( @@ -67,3 +69,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 9dc30830887c3b6e303df6bb92ba1148799afea6 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 20:33:48 +0530 Subject: [PATCH 004/133] 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 047bcc5239..399eb5a087 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -385,7 +385,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() @@ -587,11 +589,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 9a7e9d902d2b2960034cf2d7ad0d084dee01c1b4 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 21:24:31 +0530 Subject: [PATCH 005/133] 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 5b073b7539..e765725340 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -46,7 +46,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 dd99c00eb64dc16b0f54a0cbf8e2c3b7f0f7e5fe Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 19 May 2022 21:48:24 +0530 Subject: [PATCH 006/133] 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 399eb5a087..560019a86d 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -641,6 +641,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 90d4dc0cd6428d2befa0805c9903446e668c9ba8 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 20 May 2022 01:02:56 +0530 Subject: [PATCH 007/133] 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 --- .../production_plan/test_production_plan.py | 1 - .../doctype/work_order/test_work_order.py | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 891a497878..e88049d810 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -798,7 +798,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 2aba48231b..27e7e24a82 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -417,7 +417,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) @@ -426,8 +426,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 ab2d95a74d8beda1d751f7d795f37058826fff18 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 23 May 2022 13:00:00 +0530 Subject: [PATCH 008/133] 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 | 169 ++++++------- .../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(+), 199 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 813ac17ca0..05f06b3bda 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -392,9 +392,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 98c1acb71c..3455b86657 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 c0770fac90..639628ac38 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -1,13 +1,19 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from typing import Dict, List, Literal, 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 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): @@ -49,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 = doc.log_error("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 0000000000..b5964cec9d --- /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 47efea961b..4f151334a2 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 e765725340..4a2e03fb18 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, Literal, Optional, Union +from typing import TYPE_CHECKING, Dict, Literal, Optional, Union if TYPE_CHECKING: from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog @@ -39,17 +38,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( @@ -69,90 +58,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 9f5f18e94da4254255a32d792abc94407ca5fde0 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 24 May 2022 18:17:40 +0530 Subject: [PATCH 009/133] 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 3455b86657..db5f58d04f 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 b5964cec9d..d246d3064f 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 eabd8290d40db9745a046c422ed17e024a685ae2 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 25 May 2022 15:32:42 +0530 Subject: [PATCH 010/133] 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 560019a86d..6d53cfe707 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.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) @@ -391,8 +392,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( @@ -594,6 +593,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 @@ -689,6 +692,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 f01d856e72..9b1db63494 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 d246d3064f..1ec15f0d3a 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 59499462650965b483a2b0f9f377d59b98f756e6 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 May 2022 17:04:21 +0530 Subject: [PATCH 011/133] 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 6d53cfe707..15048ec990 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -702,7 +702,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 db5f58d04f..bea3cf0373 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 2de2491e1737a9935183ca8f1db953f77f6c1185 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 May 2022 20:33:14 +0530 Subject: [PATCH 012/133] 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 1ec15f0d3a..790a79b333 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 978ba5238fae64fe0e348091d42c3210b999df02 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 May 2022 21:59:59 +0530 Subject: [PATCH 013/133] 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 15048ec990..7055efac28 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -699,7 +699,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 4c74637c916e8acd3a1985264fdf17ffe1788d35 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 30 May 2022 19:05:57 +0530 Subject: [PATCH 014/133] refactor: remove naming expression for payment ledger --- .../doctype/payment_ledger_entry/payment_ledger_entry.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json index d96107678f..39e90420c7 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json +++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json @@ -1,7 +1,6 @@ { "actions": [], "allow_rename": 1, - "autoname": "format:PLE-{YY}-{MM}-{######}", "creation": "2022-05-09 19:35:03.334361", "doctype": "DocType", "editable_grid": 1, @@ -138,11 +137,10 @@ "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2022-05-19 18:04:44.609115", + "modified": "2022-05-30 19:04:55.532171", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Ledger Entry", - "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { From a62bc9b6c920b0d39a16b348a65962fa6a0d9992 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 31 May 2022 15:53:34 +0530 Subject: [PATCH 015/133] 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 7055efac28..d4e0d4b814 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -644,6 +644,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, @@ -656,6 +657,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) @@ -665,7 +667,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 639628ac38..f61f863c10 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 4a2e03fb18..758d8ed0ef 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -38,7 +38,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 c4af63ad981649a2bcbc82c0fe79dd14e9cf2f47 Mon Sep 17 00:00:00 2001 From: hrzzz Date: Tue, 31 May 2022 10:46:56 -0300 Subject: [PATCH 016/133] feat: two new groupby mode: Monthly, Payment Term --- .../report/gross_profit/gross_profit.js | 2 +- .../report/gross_profit/gross_profit.py | 103 ++++++++++++++++-- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js index 158ff4d343..3d37b5898c 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.js +++ b/erpnext/accounts/report/gross_profit/gross_profit.js @@ -35,7 +35,7 @@ frappe.query_reports["Gross Profit"] = { "fieldname":"group_by", "label": __("Group By"), "fieldtype": "Select", - "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject", + "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject\nMonthly\nPayment Term", "default": "Invoice" }, ], diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 9668992e02..526ea9d6e2 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -4,7 +4,7 @@ import frappe from frappe import _, scrub -from frappe.utils import cint, flt +from frappe.utils import cint, flt, formatdate from erpnext.controllers.queries import get_match_cond from erpnext.stock.utils import get_incoming_rate @@ -124,6 +124,23 @@ def execute(filters=None): "gross_profit", "gross_profit_percent", ], + "monthly": [ + "monthly", + "qty", + "base_rate", + "buying_rate", + "base_amount", + "buying_amount", + "gross_profit", + "gross_profit_percent", + ], + "payment_term": [ + "payment_term", + "base_amount", + "buying_amount", + "gross_profit", + "gross_profit_percent", + ], } ) @@ -317,6 +334,19 @@ def get_columns(group_wise_columns, filters): "options": "territory", "width": 100, }, + "monthly": { + "label": _("Monthly"), + "fieldname": "monthly", + "fieldtype": "Data", + "width": 100, + }, + "payment_term": { + "label": _("Payment Term"), + "fieldname": "payment_term", + "fieldtype": "Link", + "options": "Payment Term", + "width": 170, + }, } ) @@ -390,6 +420,9 @@ class GrossProfitGenerator(object): buying_amount = 0 for row in reversed(self.si_list): + if self.filters.get("group_by") == "Monthly": + row.monthly = formatdate(row.posting_date, "MMM YYYY") + if self.skip_row(row): continue @@ -445,17 +478,7 @@ class GrossProfitGenerator(object): def get_average_rate_based_on_group_by(self): for key in list(self.grouped): - if self.filters.get("group_by") != "Invoice": - for i, row in enumerate(self.grouped[key]): - if i == 0: - new_row = row - else: - new_row.qty += flt(row.qty) - new_row.buying_amount += flt(row.buying_amount, self.currency_precision) - new_row.base_amount += flt(row.base_amount, self.currency_precision) - new_row = self.set_average_rate(new_row) - self.grouped_data.append(new_row) - else: + if self.filters.get("group_by") == "Invoice": for i, row in enumerate(self.grouped[key]): if row.indent == 1.0: if ( @@ -469,6 +492,44 @@ class GrossProfitGenerator(object): if flt(row.qty) or row.base_amount: row = self.set_average_rate(row) self.grouped_data.append(row) + elif self.filters.get("group_by") == "Payment Term": + for i, row in enumerate(self.grouped[key]): + invoice_portion = 0 + + if row.is_return: + invoice_portion = 100 + elif row.invoice_portion: + invoice_portion = row.invoice_portion + else: + invoice_portion = row.payment_amount * 100 / row.base_net_amount + + if i == 0: + new_row = row + self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion) + else: + new_row.qty += flt(row.qty) + self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True) + + new_row = self.set_average_rate(new_row) + self.grouped_data.append(new_row) + else: + for i, row in enumerate(self.grouped[key]): + if i == 0: + new_row = row + else: + new_row.qty += flt(row.qty) + new_row.buying_amount += flt(row.buying_amount, self.currency_precision) + new_row.base_amount += flt(row.base_amount, self.currency_precision) + new_row = self.set_average_rate(new_row) + self.grouped_data.append(new_row) + + def set_average_based_on_payment_term_portion(self, new_row, row, invoice_portion, aggr=False): + cols = ["base_amount", "buying_amount", "gross_profit"] + for col in cols: + if aggr: + new_row[col] += row[col] * invoice_portion / 100 + else: + new_row[col] = row[col] * invoice_portion / 100 def is_not_invoice_row(self, row): return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get( @@ -622,6 +683,20 @@ class GrossProfitGenerator(object): sales_person_cols = "" sales_team_table = "" + if self.filters.group_by == "Payment Term": + payment_term_cols = """,if(`tabSales Invoice`.is_return = 1, + '{0}', + coalesce(schedule.payment_term, '{1}')) as payment_term, + schedule.invoice_portion, + schedule.payment_amount """.format( + _("Sales Return"), _("No Terms") + ) + payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and + `tabSales Invoice`.is_return = 0 """ + else: + payment_term_cols = "" + payment_term_table = "" + if self.filters.get("sales_invoice"): conditions += " and `tabSales Invoice`.name = %(sales_invoice)s" @@ -644,10 +719,12 @@ class GrossProfitGenerator(object): `tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return, `tabSales Invoice Item`.cost_center {sales_person_cols} + {payment_term_cols} from `tabSales Invoice` inner join `tabSales Invoice Item` on `tabSales Invoice Item`.parent = `tabSales Invoice`.name {sales_team_table} + {payment_term_table} where `tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond} order by @@ -655,6 +732,8 @@ class GrossProfitGenerator(object): conditions=conditions, sales_person_cols=sales_person_cols, sales_team_table=sales_team_table, + payment_term_cols=payment_term_cols, + payment_term_table=payment_term_table, match_cond=get_match_cond("Sales Invoice"), ), self.filters, From 48bde2de2a2280d788f7688f9cb523d76042ffcf Mon Sep 17 00:00:00 2001 From: Sun Howwrongbum Date: Wed, 1 Jun 2022 20:20:16 +0530 Subject: [PATCH 017/133] fix: Trial Balance failing to ignore Finance Book --- .../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 e5a4ed2f34..af447df52a 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 62857e3e080b3888f40a09112be63238974dd175 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 2 Jun 2022 13:35:30 +0530 Subject: [PATCH 018/133] 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 0000000000..e69de29bb2 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 0000000000..9938454ce4 --- /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 0000000000..f952e435e6 --- /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 bea3cf0373..b1c24ab995 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 f61f863c10..bfae76c2b2 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 790a79b333..2d6429b050 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 333c62de72b1e47a15285b463ec6715cbf46bc40 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 6 Jun 2022 01:19:45 +0530 Subject: [PATCH 019/133] fix: transferred batches are not fetched while making Manufacture stock entry --- .../stock/doctype/stock_entry/stock_entry.py | 223 ++++++++++-------- 1 file changed, 120 insertions(+), 103 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index f1df54dd6a..293c2e54f5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1656,118 +1656,58 @@ class StockEntry(StockController): ) def get_transfered_raw_materials(self): - transferred_materials = frappe.db.sql( - """ - select - item_name, original_item, item_code, sum(qty) as qty, sed.t_warehouse as warehouse, - description, stock_uom, expense_account, cost_center - from `tabStock Entry` se,`tabStock Entry Detail` sed - where - se.name = sed.parent and se.docstatus=1 and se.purpose='Material Transfer for Manufacture' - and se.work_order= %s and ifnull(sed.t_warehouse, '') != '' - group by sed.item_code, sed.t_warehouse - """, + available_materials = get_available_materials(self.work_order) + + wo_data = frappe.db.get_value( + "Work Order", self.work_order, + ["qty", "produced_qty", "material_transferred_for_manufacturing as trans_qty"], as_dict=1, ) - materials_already_backflushed = frappe.db.sql( - """ - select - item_code, sed.s_warehouse as warehouse, sum(qty) as qty - from - `tabStock Entry` se, `tabStock Entry Detail` sed - where - se.name = sed.parent and se.docstatus=1 - and (se.purpose='Manufacture' or se.purpose='Material Consumption for Manufacture') - and se.work_order= %s and ifnull(sed.s_warehouse, '') != '' - group by sed.item_code, sed.s_warehouse - """, - self.work_order, - as_dict=1, - ) - - backflushed_materials = {} - for d in materials_already_backflushed: - backflushed_materials.setdefault(d.item_code, []).append({d.warehouse: d.qty}) - - po_qty = frappe.db.sql( - """select qty, produced_qty, material_transferred_for_manufacturing from - `tabWork Order` where name=%s""", - self.work_order, - as_dict=1, - )[0] - - manufacturing_qty = flt(po_qty.qty) or 1 - produced_qty = flt(po_qty.produced_qty) - trans_qty = flt(po_qty.material_transferred_for_manufacturing) or 1 - - for item in transferred_materials: - qty = item.qty - item_code = item.original_item or item.item_code - req_items = frappe.get_all( - "Work Order Item", - filters={"parent": self.work_order, "item_code": item_code}, - fields=["required_qty", "consumed_qty"], + for key, row in available_materials.items(): + qty = (flt(row.qty) * flt(self.fg_completed_qty)) / ( + flt(wo_data.trans_qty) - flt(wo_data.produced_qty) ) - req_qty = flt(req_items[0].required_qty) if req_items else flt(4) - req_qty_each = flt(req_qty / manufacturing_qty) - consumed_qty = flt(req_items[0].consumed_qty) if req_items else 0 - - if trans_qty and manufacturing_qty > (produced_qty + flt(self.fg_completed_qty)): - if qty >= req_qty: - qty = (req_qty / trans_qty) * flt(self.fg_completed_qty) - else: - qty = qty - consumed_qty - - if self.purpose == "Manufacture": - # If Material Consumption is booked, must pull only remaining components to finish product - if consumed_qty != 0: - remaining_qty = consumed_qty - (produced_qty * req_qty_each) - exhaust_qty = req_qty_each * produced_qty - if remaining_qty > exhaust_qty: - if (remaining_qty / (req_qty_each * flt(self.fg_completed_qty))) >= 1: - qty = 0 - else: - qty = (req_qty_each * flt(self.fg_completed_qty)) - remaining_qty - else: - if self.flags.backflush_based_on == "Material Transferred for Manufacture": - qty = (item.qty / trans_qty) * flt(self.fg_completed_qty) - else: - qty = req_qty_each * flt(self.fg_completed_qty) - - elif backflushed_materials.get(item.item_code): - precision = frappe.get_precision("Stock Entry Detail", "qty") - for d in backflushed_materials.get(item.item_code): - if d.get(item.warehouse) > 0: - if qty > req_qty: - qty = ( - (flt(qty, precision) - flt(d.get(item.warehouse), precision)) - / (flt(trans_qty, precision) - flt(produced_qty, precision)) - ) * flt(self.fg_completed_qty) - - d[item.warehouse] -= qty - + item = row.item_details if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")): qty = frappe.utils.ceil(qty) - if qty > 0: - self.add_to_stock_entry_detail( - { - item.item_code: { - "from_warehouse": item.warehouse, - "to_warehouse": "", - "qty": qty, - "item_name": item.item_name, - "description": item.description, - "stock_uom": item.stock_uom, - "expense_account": item.expense_account, - "cost_center": item.buying_cost_center, - "original_item": item.original_item, - } - } - ) + if row.batch_details: + for batch_no, batch_qty in row.batch_details.items(): + if qty <= 0: + continue + + if batch_qty > qty: + batch_qty = qty + + item.batch_no = batch_no + self.update_item_in_stock_entry_detail(row, item, batch_qty) + + row.batch_details[batch_no] -= batch_qty + qty -= batch_qty + else: + self.update_item_in_stock_entry_detail(row, item, qty) + + def update_item_in_stock_entry_detail(self, row, item, qty): + ste_item_details = { + "from_warehouse": item.warehouse, + "to_warehouse": "", + "qty": qty, + "item_name": item.item_name, + "batch_no": item.batch_no, + "description": item.description, + "stock_uom": item.stock_uom, + "expense_account": item.expense_account, + "cost_center": item.buying_cost_center, + "original_item": item.original_item, + } + + if item.serial_no: + ste_item_details["serial_no"] = "\n".join(item.serial_no[0 : cint(qty)]) + + self.add_to_stock_entry_detail({item.item_code: ste_item_details}) def get_pending_raw_materials(self, backflush_based_on=None): """ @@ -2521,3 +2461,80 @@ def get_supplied_items(purchase_order): ) return supplied_item_details + + +def get_available_materials(work_order) -> dict: + data = get_stock_entry_data(work_order) + + available_materials = {} + for row in data: + key = (row.item_code, row.warehouse) + if row.purpose != "Material Transfer for Manufacture": + key = (row.item_code, row.s_warehouse) + + if key not in available_materials: + available_materials.setdefault( + key, + frappe._dict( + {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []} + ), + ) + + item_data = available_materials[key] + + if row.purpose == "Material Transfer for Manufacture": + item_data.qty += row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] += row.qty + + if row.serial_no: + item_data.serial_nos.extend(get_serial_nos(row.serial_no)) + else: + # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture' + + item_data.qty -= row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] -= row.qty + + if row.serial_no: + for serial_no in get_serial_nos(row.serial_no): + item_data.serial_nos.remove(serial_no) + + return available_materials + + +def get_stock_entry_data(work_order): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .from_(stock_entry_detail) + .select( + stock_entry_detail.item_name, + stock_entry_detail.original_item, + stock_entry_detail.item_code, + stock_entry_detail.qty, + (stock_entry_detail.t_warehouse).as_("warehouse"), + (stock_entry_detail.s_warehouse).as_("s_warehouse"), + stock_entry_detail.description, + stock_entry_detail.stock_uom, + stock_entry_detail.expense_account, + stock_entry_detail.cost_center, + stock_entry_detail.batch_no, + stock_entry_detail.serial_no, + stock_entry.purpose, + ) + .where( + (stock_entry.name == stock_entry_detail.parent) + & (stock_entry.work_order == work_order) + & (stock_entry.docstatus == 1) + & (stock_entry_detail.s_warehouse.isnotnull()) + & ( + stock_entry.purpose.isin( + ["Manufacture", "Material Consumption for Manufacture", "Material Transfer for Manufacture"] + ) + ) + ) + .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) + ).run(as_dict=1, debug=1) From 3513d54c0a04887422caf932071754fa71e1f6bc Mon Sep 17 00:00:00 2001 From: vishdha Date: Fri, 3 Jun 2022 14:23:53 +0530 Subject: [PATCH 020/133] fix: Print/PDF for financial statement reports displays either wrong date range or wrong fiscal year --- .../consolidated_financial_statement.js | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js index d3e836afd1..a5c5a67613 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js @@ -50,7 +50,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "fieldtype": "Link", "options": "Fiscal Year", "default": frappe.defaults.get_user_default("fiscal_year"), - "reqd": 1 + "reqd": 1, + on_change: () => { + frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) { + let start_fy = frappe.model.get_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year')); + frappe.query_report.set_filter_value({ + period_start_date: start_fy.year_start_date + }); + }); + } }, { "fieldname":"to_fiscal_year", @@ -58,7 +66,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "fieldtype": "Link", "options": "Fiscal Year", "default": frappe.defaults.get_user_default("fiscal_year"), - "reqd": 1 + "reqd": 1, + on_change: () => { + frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) { + let to_fy = frappe.model.get_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year')); + frappe.query_report.set_filter_value({ + period_end_date: to_fy.year_end_date + }); + }); + } }, { "fieldname":"finance_book", From 53774e0f520defeb47d41d2fe3078ab26962f5a6 Mon Sep 17 00:00:00 2001 From: vishdha Date: Mon, 6 Jun 2022 13:07:39 +0530 Subject: [PATCH 021/133] chore: minor change in fetching start and end date --- .../consolidated_financial_statement.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js index a5c5a67613..dd965a9813 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js @@ -53,9 +53,9 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "reqd": 1, on_change: () => { frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) { - let start_fy = frappe.model.get_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year')); + let year_start_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), "year_start_date"); frappe.query_report.set_filter_value({ - period_start_date: start_fy.year_start_date + period_start_date: year_start_date }); }); } @@ -69,9 +69,9 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "reqd": 1, on_change: () => { frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) { - let to_fy = frappe.model.get_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year')); + let year_end_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), "year_end_date"); frappe.query_report.set_filter_value({ - period_end_date: to_fy.year_end_date + period_end_date: year_end_date }); }); } From 934db57fdd5346dde982e9022f33d61780175d07 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 6 Jun 2022 17:01:51 +0530 Subject: [PATCH 022/133] 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 d4e0d4b814..1fb00ef3fe 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -714,8 +714,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) @@ -935,13 +938,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 @@ -950,14 +957,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 9938454ce4..83b54d326c 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 b1c24ab995..c32e383b08 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 bfae76c2b2..d714b9d5fd 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 2d6429b050..49e747c4bb 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 758d8ed0ef..d16fcd0832 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,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 15101190a6efb4a596824a1c7cba88d8363e5d17 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 6 Jun 2022 17:12:43 +0530 Subject: [PATCH 023/133] 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 1fb00ef3fe..8bf124e058 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -961,11 +961,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 d94ff3ede8ca1d57b1ad869a31bbc6bf93fa8a72 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 6 Jun 2022 16:07:17 +0530 Subject: [PATCH 024/133] test: test cases to cover batch, serialized raw materials --- .../doctype/work_order/test_work_order.py | 277 +++++++++++++++++- .../stock/doctype/stock_entry/stock_entry.py | 56 ++-- 2 files changed, 303 insertions(+), 30 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 2aba48231b..bd19dec504 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import copy + import frappe from frappe.tests.utils import FrappeTestCase, change_settings, timeout from frappe.utils import add_days, add_months, cint, flt, now, today @@ -19,6 +21,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import ( ) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_entry import test_stock_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.utils import get_bin @@ -28,6 +31,7 @@ class TestWorkOrder(FrappeTestCase): def setUp(self): self.warehouse = "_Test Warehouse 2 - _TC" self.item = "_Test Item" + prepare_data_for_backflush_based_on_materials_transferred() def tearDown(self): frappe.db.rollback() @@ -518,6 +522,8 @@ class TestWorkOrder(FrappeTestCase): work_order.cancel() def test_work_order_with_non_transfer_item(self): + frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM") + items = {"Finished Good Transfer Item": 1, "_Test FG Item": 1, "_Test FG Item 1": 0} for item, allow_transfer in items.items(): make_item(item, {"include_item_in_manufacturing": allow_transfer}) @@ -1062,7 +1068,7 @@ class TestWorkOrder(FrappeTestCase): sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100)) for row in sm.get("items"): if row.get("item_code") == "_Test Item": - row.qty = 110 + row.qty = 120 sm.submit() cancel_stock_entry.append(sm.name) @@ -1070,21 +1076,21 @@ class TestWorkOrder(FrappeTestCase): s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90)) for row in s.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 100) + self.assertEqual(row.get("qty"), 108) s.submit() cancel_stock_entry.append(s.name) s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) for row in s1.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 5) + self.assertEqual(row.get("qty"), 6) s1.submit() cancel_stock_entry.append(s1.name) s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) for row in s2.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 5) + self.assertEqual(row.get("qty"), 6) cancel_stock_entry.reverse() for ste in cancel_stock_entry: @@ -1194,6 +1200,269 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[1].transferred_qty, 2) + def test_backflushed_batch_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + batch_item = "Test Batch MCC Keyboard" + fg_item = "Test FG Item with Batch Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True + ) + + ste_doc.append( + "items", + { + "item_code": batch_item, + "item_name": batch_item, + "description": batch_item, + "basic_rate": 100, + "t_warehouse": "Stores - _TC", + "qty": 2, + "uom": "Nos", + "stock_uom": "Nos", + "conversion_factor": 1, + }, + ) + + # Inward raw materials in Stores warehouse + ste_doc.insert() + ste_doc.submit() + + batch_list = [row.batch_no for row in ste_doc.items] + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].qty = 2 + transferred_ste_doc.items[0].batch_no = batch_list[0] + + new_row = copy.deepcopy(transferred_ste_doc.items[0]) + new_row.name = "" + new_row.batch_no = batch_list[1] + + # Transferred two batches from Stores to WIP Warehouse + transferred_ste_doc.append("items", new_row) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Batch no should be same as transferred Batch no + self.assertEqual(manufacture_ste_doc1.items[0].batch_no, batch_list[0]) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Batch no should be same as transferred Batch no + self.assertEqual(manufacture_ste_doc2.items[0].batch_no, batch_list[0]) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 1) + self.assertEqual(manufacture_ste_doc2.items[1].batch_no, batch_list[1]) + self.assertEqual(manufacture_ste_doc2.items[1].qty, 1) + + def test_backflushed_serial_no_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_item = "Test Serial No BTT Headphone" + fg_item = "Test FG Item with Serial No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True + ) + + # Inward raw materials in Stores warehouse + ste_doc.submit() + + serial_nos_list = sorted(get_serial_nos(ste_doc.items[0].serial_no)) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Serial nos should be same as transferred Serial nos + self.assertEqual(get_serial_nos(manufacture_ste_doc1.items[0].serial_no), serial_nos_list[0:1]) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Serial nos should be same as transferred Serial nos + self.assertEqual(get_serial_nos(manufacture_ste_doc2.items[0].serial_no), serial_nos_list[1:3]) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 2) + + def test_backflushed_serial_no_batch_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_batch_item = "Test Batch Serial No WebCam" + fg_item = "Test FG Item with Serial & Batch No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True + ) + + ste_doc.append( + "items", + { + "item_code": sn_batch_item, + "item_name": sn_batch_item, + "description": sn_batch_item, + "basic_rate": 100, + "t_warehouse": "Stores - _TC", + "qty": 2, + "uom": "Nos", + "stock_uom": "Nos", + "conversion_factor": 1, + }, + ) + + # Inward raw materials in Stores warehouse + ste_doc.insert() + ste_doc.submit() + + batch_dict = {row.batch_no: get_serial_nos(row.serial_no) for row in ste_doc.items} + batches = list(batch_dict.keys()) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].qty = 2 + transferred_ste_doc.items[0].batch_no = batches[0] + transferred_ste_doc.items[0].serial_no = "\n".join(batch_dict.get(batches[0])) + + new_row = copy.deepcopy(transferred_ste_doc.items[0]) + new_row.name = "" + new_row.batch_no = batches[1] + new_row.serial_no = "\n".join(batch_dict.get(batches[1])) + + # Transferred two batches from Stores to WIP Warehouse + transferred_ste_doc.append("items", new_row) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos + batch_no = manufacture_ste_doc1.items[0].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc1.items[0].serial_no)[0], batch_dict.get(batch_no)[0] + ) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos + batch_no = manufacture_ste_doc2.items[0].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc2.items[0].serial_no)[0], batch_dict.get(batch_no)[1] + ) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 1) + + batch_no = manufacture_ste_doc2.items[1].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc2.items[1].serial_no)[0], batch_dict.get(batch_no)[0] + ) + self.assertEqual(manufacture_ste_doc2.items[1].qty, 1) + + +def prepare_data_for_backflush_based_on_materials_transferred(): + batch_item_doc = make_item( + "Test Batch MCC Keyboard", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBMK.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Batch Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[batch_item_doc.name]) + + sn_item_doc = make_item( + "Test Serial No BTT Headphone", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSBH.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Serial No Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_item_doc.name]) + + sn_batch_item_doc = make_item( + "Test Batch Serial No WebCam", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBSW.#####", + "has_serial_no": 1, + "serial_no_series": "TBSWC.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Serial & Batch No Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_batch_item_doc.name]) + def update_job_card(job_card, jc_qty=None): employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 293c2e54f5..08ce83b707 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -596,21 +596,6 @@ class StockEntry(StockController): title=_("Insufficient Stock"), ) - def set_serial_nos(self, work_order): - previous_se = frappe.db.get_value( - "Stock Entry", - {"work_order": work_order, "purpose": "Material Transfer for Manufacture"}, - "name", - ) - - for d in self.get("items"): - transferred_serial_no = frappe.db.get_value( - "Stock Entry Detail", {"parent": previous_se, "item_code": d.item_code}, "serial_no" - ) - - if transferred_serial_no: - d.serial_no = transferred_serial_no - @frappe.whitelist() def get_stock_and_rate(self): """ @@ -1321,7 +1306,7 @@ class StockEntry(StockController): and not self.pro_doc.skip_transfer and self.flags.backflush_based_on == "Material Transferred for Manufacture" ): - self.get_transfered_raw_materials() + self.add_transfered_raw_materials_in_items() elif ( self.work_order @@ -1365,7 +1350,6 @@ class StockEntry(StockController): # fetch the serial_no of the first stock entry for the second stock entry if self.work_order and self.purpose == "Manufacture": - self.set_serial_nos(self.work_order) work_order = frappe.get_doc("Work Order", self.work_order) add_additional_cost(self, work_order) @@ -1655,7 +1639,7 @@ class StockEntry(StockController): } ) - def get_transfered_raw_materials(self): + def add_transfered_raw_materials_in_items(self) -> None: available_materials = get_available_materials(self.work_order) wo_data = frappe.db.get_value( @@ -1666,9 +1650,11 @@ class StockEntry(StockController): ) for key, row in available_materials.items(): - qty = (flt(row.qty) * flt(self.fg_completed_qty)) / ( - flt(wo_data.trans_qty) - flt(wo_data.produced_qty) - ) + remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty) + if remaining_qty_to_produce <= 0: + continue + + qty = (flt(row.qty) * flt(self.fg_completed_qty)) / remaining_qty_to_produce item = row.item_details if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")): @@ -1676,7 +1662,7 @@ class StockEntry(StockController): if row.batch_details: for batch_no, batch_qty in row.batch_details.items(): - if qty <= 0: + if qty <= 0 or batch_qty <= 0: continue if batch_qty > qty: @@ -1690,7 +1676,7 @@ class StockEntry(StockController): else: self.update_item_in_stock_entry_detail(row, item, qty) - def update_item_in_stock_entry_detail(self, row, item, qty): + def update_item_in_stock_entry_detail(self, row, item, qty) -> None: ste_item_details = { "from_warehouse": item.warehouse, "to_warehouse": "", @@ -1704,11 +1690,28 @@ class StockEntry(StockController): "original_item": item.original_item, } - if item.serial_no: - ste_item_details["serial_no"] = "\n".join(item.serial_no[0 : cint(qty)]) + if row.serial_nos: + serial_nos = row.serial_nos + if item.batch_no: + serial_nos = self.get_serial_nos_based_on_transferred_batch(item.batch_no, row.serial_nos) + + serial_nos = serial_nos[0 : cint(qty)] + ste_item_details["serial_no"] = "\n".join(serial_nos) + + # remove consumed serial nos from list + for sn in serial_nos: + row.serial_nos.remove(sn) self.add_to_stock_entry_detail({item.item_code: ste_item_details}) + @staticmethod + def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list: + serial_nos = frappe.get_all( + "Serial No", filters={"batch_no": batch_no, "name": ("in", serial_nos)}, order_by="creation" + ) + + return [d.name for d in serial_nos] + def get_pending_raw_materials(self, backflush_based_on=None): """ issue (item quantity) that is pending to issue or desire to transfer, @@ -2489,6 +2492,7 @@ def get_available_materials(work_order) -> dict: if row.serial_no: item_data.serial_nos.extend(get_serial_nos(row.serial_no)) + item_data.serial_nos.sort() else: # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture' @@ -2537,4 +2541,4 @@ def get_stock_entry_data(work_order): ) ) .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) - ).run(as_dict=1, debug=1) + ).run(as_dict=1) From 6bde1bb5d2446c3ed08f566060c321664ad1d4e4 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 7 Jun 2022 14:44:00 +0530 Subject: [PATCH 025/133] 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 62fc0724e0..bc1bea7389 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, make_variant_bom -from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost +from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import ( + update_cost_in_all_boms_in_test, +) from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, @@ -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 25730f9b9f..507d319b51 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 d714b9d5fd..71430bd57e 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 49e747c4bb..dde1e4ed75 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 4f151334a2..d770f6c56a 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 fae72a0f6f..d1882e56e9 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 9f2793ccf1e185b28c0f6b480a304641923dbc30 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 7 Jun 2022 17:44:39 +0530 Subject: [PATCH 026/133] 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 f2731ec5ef..04e937a7e3 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 018bc2af43160c4b25446bdba63c9fc5412b0be2 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 8 Jun 2022 06:12:40 +0530 Subject: [PATCH 027/133] fix: ignore payment ledger on cancellation of loan --- erpnext/loan_management/doctype/loan/loan.py | 2 +- .../doctype/loan_disbursement/loan_disbursement.py | 2 +- .../doctype/loan_interest_accrual/loan_interest_accrual.py | 2 +- .../loan_management/doctype/loan_repayment/loan_repayment.py | 2 +- .../loan_management/doctype/loan_write_off/loan_write_off.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index a0ef1b971c..bb32c946f2 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -71,7 +71,7 @@ class Loan(AccountsController): def on_cancel(self): self.unlink_loan_security_pledge() - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] def set_missing_fields(self): if not self.company: diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py index 10174e531a..0c2042ba50 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -29,7 +29,7 @@ class LoanDisbursement(AccountsController): def on_cancel(self): self.set_status_and_amounts(cancel=1) self.make_gl_entries(cancel=1) - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] def set_missing_values(self): if not self.disbursement_date: 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 3a4c6513e4..0aeb448918 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 @@ -32,7 +32,7 @@ class LoanInterestAccrual(AccountsController): self.update_is_accrued() self.make_gl_entries(cancel=1) - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] def update_is_accrued(self): frappe.db.set_value("Repayment Schedule", self.repayment_schedule_name, "is_accrued", 0) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 8614fcb9cd..35fbe3a38e 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -41,7 +41,7 @@ class LoanRepayment(AccountsController): self.check_future_accruals() self.update_repayment_schedule(cancel=1) self.mark_as_unpaid() - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] self.make_gl_entries(cancel=1) def set_missing_values(self, amounts): diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py index e19fd15fc8..25aecf673b 100644 --- a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py +++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py @@ -42,7 +42,7 @@ class LoanWriteOff(AccountsController): def on_cancel(self): self.update_outstanding_amount(cancel=1) - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] self.make_gl_entries(cancel=1) def update_outstanding_amount(self, cancel=0): From 7e41d84a116f2acd03984c98ec4eaa8e50ddc1d3 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 8 Jun 2022 14:01:04 +0530 Subject: [PATCH 028/133] 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 3e2a2d13f1..631548b309 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -932,44 +932,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 04e937a7e3..182a20c6bb 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 65b21ee7d61e5bb9744b5545d625140897afd35c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 7 Jun 2022 14:49:24 +0530 Subject: [PATCH 029/133] fix: internal transfer GLE validation --- erpnext/controllers/stock_controller.py | 2 +- .../selling/doctype/customer/test_customer.py | 6 +++++ .../delivery_note/test_delivery_note.py | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index feec42f43a..e90a4f6241 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -166,7 +166,7 @@ class StockController(AccountsController): "against": warehouse_account[sle.warehouse]["account"], "cost_center": item_row.cost_center, "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(sle.stock_value_difference, precision), + "debit": -1 * flt(sle.stock_value_difference, precision), "project": item_row.get("project") or self.get("project"), "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", }, diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 24587564cf..7dc3fab623 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -375,6 +375,12 @@ def create_internal_customer( if not allowed_to_interact_with: allowed_to_interact_with = represents_company + exisiting_representative = frappe.db.get_value( + "Customer", {"represents_company": represents_company} + ) + if exisiting_representative: + return exisiting_representative + if not frappe.db.exists("Customer", customer_name): customer = frappe.get_doc( { diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index fffcdca380..6bcab737b3 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1064,6 +1064,33 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.items[0].rate, rate) + def test_internal_transfer_precision_gle(self): + from erpnext.selling.doctype.customer.test_customer import create_internal_customer + + item = make_item(properties={"valuation_method": "Moving Average"}).name + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + target = "Finished Goods - TCP1" + customer = create_internal_customer(represents_company=company) + + # average rate = 128.015 + rates = [101.45, 150.46, 138.25, 121.9] + + for rate in rates: + make_stock_entry(item_code=item, target=warehouse, qty=1, rate=rate) + + dn = create_delivery_note( + item_code=item, + company=company, + customer=customer, + qty=4, + warehouse=warehouse, + target_warehouse=target, + ) + self.assertFalse( + frappe.db.exists("GL Entry", {"voucher_no": dn.name, "voucher_type": dn.doctype}) + ) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") From 67c26325eecb63a81f2839df1287ce8358340d1f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 4 Jun 2022 14:46:35 +0530 Subject: [PATCH 030/133] fix: unnecessary GLE reposts In Sales/Purchase invoices credit/debit are flipped and negated while making GLE, this is unflipped while posting them but if we compare the flipped ones it will always result in comparison failure and repost it. --- erpnext/accounts/utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 1869cc7b29..df5e37d83d 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1124,6 +1124,9 @@ def update_gl_entries_after( def repost_gle_for_stock_vouchers( stock_vouchers, posting_date, company=None, warehouse_account=None ): + + from erpnext.accounts.general_ledger import toggle_debit_credit_if_negative + if not stock_vouchers: return @@ -1144,8 +1147,10 @@ def repost_gle_for_stock_vouchers( gle = get_voucherwise_gl_entries(stock_vouchers, posting_date) for voucher_type, voucher_no in stock_vouchers: existing_gle = gle.get((voucher_type, voucher_no), []) - voucher_obj = frappe.get_cached_doc(voucher_type, voucher_no) - expected_gle = voucher_obj.get_gl_entries(warehouse_account) + voucher_obj = frappe.get_doc(voucher_type, voucher_no) + # Some transactions post credit as negative debit, this is handled while posting GLE + # but while comparing we need to make sure it's flipped so comparisons are accurate + expected_gle = toggle_debit_credit_if_negative(voucher_obj.get_gl_entries(warehouse_account)) if expected_gle: if not existing_gle or not compare_existing_and_expected_gle( existing_gle, expected_gle, precision From eb53a9727d2e465f74597d987f3f82b9f0530d7c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 4 Jun 2022 18:19:44 +0530 Subject: [PATCH 031/133] perf: commit GL reposting periodically If you have a huge list of docs to repost then maintaining transaction throughtout entire GL reposting is not only unnecessary but also creates performance issues. Periodically commiting the changes prevents lost progress and reduces memory usage. --- erpnext/accounts/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index df5e37d83d..8711395d55 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1145,7 +1145,7 @@ def repost_gle_for_stock_vouchers( precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2 gle = get_voucherwise_gl_entries(stock_vouchers, posting_date) - for voucher_type, voucher_no in stock_vouchers: + for idx, (voucher_type, voucher_no) in enumerate(stock_vouchers): existing_gle = gle.get((voucher_type, voucher_no), []) voucher_obj = frappe.get_doc(voucher_type, voucher_no) # Some transactions post credit as negative debit, this is handled while posting GLE @@ -1160,6 +1160,11 @@ def repost_gle_for_stock_vouchers( else: _delete_gl_entries(voucher_type, voucher_no) + if idx % 20 == 0: + # Commit every 20 documents to avoid losing progress + # and reducing memory usage + frappe.db.commit() + def sort_stock_vouchers_by_posting_date( stock_vouchers: List[Tuple[str, str]] From 20f568c159424a728d8cf87b087efea069732579 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 9 Jun 2022 11:50:37 +0530 Subject: [PATCH 032/133] fix(India): Incorrect taxable in GSTR-3B report --- .../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 91fccfa6e8..77b8a3fecd 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -244,11 +244,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) @@ -335,7 +334,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 = ( @@ -343,6 +342,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: @@ -361,7 +362,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] @@ -374,10 +374,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 50aafdbe99ff3f35b6816863768d0144c1e17e7b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 9 Jun 2022 11:52:46 +0530 Subject: [PATCH 033/133] chore: cleanup --- 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 77b8a3fecd..090697b010 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -334,7 +334,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 = ( @@ -342,8 +341,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: @@ -374,12 +371,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 bbaa14af1615cedc88b9e5f4d19289c6be6510fe Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 9 Jun 2022 15:14:44 +0530 Subject: [PATCH 034/133] fix: misaligned columns in print format of AR/AP report --- .../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 f4fd06ba03..f2bf9424f7 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 d9a52139523cf095d3cc60cf61483a8d56468595 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 9 Jun 2022 15:33:18 +0530 Subject: [PATCH 035/133] fix(ux): hide new version btn on unsaved BOM (#31297) --- 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 d74379881c..ecad41fe7b 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 3fa0a46f39f7024c5d0b235a7725eaa9ad0f3869 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 9 Jun 2022 16:22:00 +0530 Subject: [PATCH 036/133] 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 71430bd57e..9c9c24044a 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 dde1e4ed75..af115e3e42 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 d770f6c56a..b38fc8976b 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 d1882e56e9..5dd557f8ab 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 e6f65e1697dfac82688623184ba4b1fb6782da54 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 17:47:15 +0530 Subject: [PATCH 037/133] chore: Asset Arabic translation Fix (backport #31221) (#31301) 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 (cherry picked from commit 9347cbbc9f7826116faf22db7bf9f3bf32e6e3c2) Co-authored-by: meaziz --- 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 91a9da9f16..e62f61a4f5 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 17887cde7122ff2332f92394cfa2c8d1e196339a 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 038/133] fix: locale Currency and Float setting in update_employee In fieldtypes locale settings (example NL) . and , changes whereby the field is inproperly filled --- erpnext/hr/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index c730b19924..3f4e31b1b2 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -55,6 +55,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 2675751d6c2ce188b1df8be5f930869a97ebd520 Mon Sep 17 00:00:00 2001 From: Vladislav Date: Thu, 9 Jun 2022 16:16:08 +0300 Subject: [PATCH 039/133] 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 --- erpnext/translations/ru.csv | 141 +++++++++++++++++++----------------- 1 file changed, 74 insertions(+), 67 deletions(-) diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 6b766e7dc0..743b29493c 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}, @@ -643,7 +643,6 @@ Course Code: ,Код курса: , Course Enrollment {0} does not exists,Зачисление на курс {0} не существует, Course Schedule,Расписание курса, Course: ,Курс: , -Cr,Cr, 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,Поставщик, @@ -2880,7 +2878,7 @@ Supplier Name,наименование поставщика, Supplier Part No,Деталь поставщика №, Supplier Quotation,Предложение поставщика, Supplier Scorecard,Оценочная карта поставщика, -Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Поставщик Склад обязательным для субподрядчиком ТОВАРНЫЙ ЧЕК, +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,Смотрите в корзину, @@ -3314,7 +3312,7 @@ Work Orders Created: {0},Созданы рабочие задания: {0}, Work Summary for {0},Резюме работы для {0}, Work-in-Progress Warehouse is required before Submit,Работа-в-Прогресс Склад требуется перед Отправить, Workflow,Рабочий процесс, -Working,Работающий, +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 (%),Весовая нагрузка (%), 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.-,CUST-.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,Информация для транспортировки, -Driver Name,Имя драйвера, +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 (%),Макс. скидка (%), @@ -8117,7 +8119,7 @@ Item Alternative,Альтернативный продукт, Alternative Item Code,Альтернативный код продукта, Two-way,Двусторонний, Alternative Item Name,Альтернативное название продукта, -Attribute 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,Кол-во отправленных 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 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,8 +9322,8 @@ 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}, + 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,"Дата до должна быть больше, чем Дата", @@ -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 b9dbb36d0e55eb4f12e067032f5e7e93875304e3 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 9 Jun 2022 18:58:04 +0530 Subject: [PATCH 040/133] chore: Linting Issues --- 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 af447df52a..26572130d2 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 c13e5ad741de68a51ae478727c35dea5fb6f2390 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 9 Jun 2022 19:18:52 +0530 Subject: [PATCH 041/133] fix: Reset represents company on disabling internal customer and supplier (#31302) --- 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 97d0ba0b9c..43152e89a8 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 8889a5f939..35e0b0de40 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 ee2949aa3fa221877d7a16a02e1e3164894f8219 Mon Sep 17 00:00:00 2001 From: Sun Howwrongbum Date: Thu, 9 Jun 2022 19:28:59 +0530 Subject: [PATCH 042/133] fix: typo in sql condition --- 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 26572130d2..6bd08ad837 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 6fc32b83c89eed916e108a6f2ad7e55c144d7fd7 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Jun 2022 11:03:51 +0530 Subject: [PATCH 043/133] fix: revert show title field on Employee doctype (#31312) --- erpnext/hr/doctype/employee/employee.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/employee/employee.json b/erpnext/hr/doctype/employee/employee.json index a3638e1a65..42479143e7 100644 --- a/erpnext/hr/doctype/employee/employee.json +++ b/erpnext/hr/doctype/employee/employee.json @@ -827,7 +827,7 @@ "idx": 24, "image_field": "image", "links": [], - "modified": "2022-04-22 16:21:55.811983", + "modified": "2022-06-10 01:29:32.952091", "modified_by": "Administrator", "module": "HR", "name": "Employee", @@ -872,7 +872,6 @@ ], "search_fields": "employee_name", "show_name_in_global_search": 1, - "show_title_field_in_link": 1, "sort_field": "modified", "sort_order": "DESC", "states": [], From e2c52436da835969ced5a35f9d2ffedddb715dd6 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 24 May 2022 11:54:30 +0530 Subject: [PATCH 044/133] refactor: migrate gl to payment ledger using sql --- .../v14_0/migrate_gl_to_payment_ledger.py | 129 +++++++++++++++--- 1 file changed, 113 insertions(+), 16 deletions(-) diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py index c2267aa9af..1e0d20d059 100644 --- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py +++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py @@ -1,11 +1,13 @@ import frappe from frappe import qb +from frappe.query_builder import Case +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import IfNull from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_dimensions, make_dimension_in_accounting_doctypes, ) -from erpnext.accounts.utils import create_payment_ledger_entry def create_accounting_dimension_fields(): @@ -15,24 +17,119 @@ def create_accounting_dimension_fields(): make_dimension_in_accounting_doctypes(dimension, ["Payment Ledger Entry"]) -def execute(): - # create accounting dimension fields in Payment Ledger - create_accounting_dimension_fields() +def generate_name_for_payment_ledger_entries(gl_entries): + for index, entry in enumerate(gl_entries, 1): + entry.name = index + + +def get_columns(): + columns = [ + "name", + "creation", + "modified", + "modified_by", + "owner", + "docstatus", + "posting_date", + "account_type", + "account", + "party_type", + "party", + "voucher_type", + "voucher_no", + "against_voucher_type", + "against_voucher_no", + "amount", + "amount_in_account_currency", + "account_currency", + "company", + "cost_center", + "due_date", + "finance_book", + ] + + dimensions_and_defaults = get_dimensions() + if dimensions_and_defaults: + for dimension in dimensions_and_defaults[0]: + columns.append(dimension.fieldname) + + return columns + + +def build_insert_query(): + ple = qb.DocType("Payment Ledger Entry") + columns = get_columns() + insert_query = qb.into(ple) + + # build 'insert' columns in query + insert_query = insert_query.columns(tuple(columns)) + + return insert_query + + +def insert_chunk_into_payment_ledger(insert_query, gl_entries): + if gl_entries: + columns = get_columns() + + # build tuple of data with same column order + for entry in gl_entries: + data = () + for column in columns: + data += (entry[column],) + insert_query = insert_query.insert(data) + insert_query.run() + + +def execute(): + if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"): + # create accounting dimension fields in Payment Ledger + create_accounting_dimension_fields() + + gl = qb.DocType("GL Entry") + account = qb.DocType("Account") - gl = qb.DocType("GL Entry") - accounts = frappe.db.get_list( - "Account", "name", filters={"account_type": ["in", ["Receivable", "Payable"]]}, as_list=True - ) - gl_entries = [] - if accounts: - # get all gl entries on receivable/payable accounts gl_entries = ( qb.from_(gl) - .select("*") - .where(gl.account.isin(accounts)) + .inner_join(account) + .on((gl.account == account.name) & (account.account_type.isin(["Receivable", "Payable"]))) + .select( + gl.star, + ConstantColumn(1).as_("docstatus"), + account.account_type.as_("account_type"), + IfNull(gl.against_voucher_type, gl.voucher_type).as_("against_voucher_type"), + IfNull(gl.against_voucher, gl.voucher_no).as_("against_voucher_no"), + # convert debit/credit to amount + Case() + .when(account.account_type == "Receivable", gl.debit - gl.credit) + .else_(gl.credit - gl.debit) + .as_("amount"), + # convert debit/credit in account currency to amount in account currency + Case() + .when( + account.account_type == "Receivable", + gl.debit_in_account_currency - gl.credit_in_account_currency, + ) + .else_(gl.credit_in_account_currency - gl.debit_in_account_currency) + .as_("amount_in_account_currency"), + ) .where(gl.is_cancelled == 0) + .orderby(gl.creation) .run(as_dict=True) ) - if gl_entries: - # create payment ledger entries for the accounts receivable/payable - create_payment_ledger_entry(gl_entries, 0) + + # primary key(name) for payment ledger records + generate_name_for_payment_ledger_entries(gl_entries) + + # split data into chunks + chunk_size = 1000 + try: + for i in range(0, len(gl_entries), chunk_size): + insert_query = build_insert_query() + insert_chunk_into_payment_ledger(insert_query, gl_entries[i : i + chunk_size]) + frappe.db.commit() + except Exception as err: + frappe.db.rollback() + ple = qb.DocType("Payment Ledger Entry") + qb.from_(ple).delete().where(ple.docstatus >= 0).run() + frappe.db.commit() + raise err From 1646fbe478fefaa173c2c1e009d1a5d0dcb13326 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Jun 2022 13:52:17 +0530 Subject: [PATCH 045/133] 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" --- erpnext/accounts/doctype/sales_invoice/sales_invoice.json | 4 +++- erpnext/selling/doctype/sales_order/sales_order.json | 4 +++- erpnext/selling/sales_common.js | 4 +--- erpnext/stock/doctype/delivery_note/delivery_note.json | 4 +++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 80b95db886..327545aa54 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 ff921c721d..74c5c07e47 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 6cb53c3bbe..8ff01f5cb4 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -12,8 +12,6 @@ frappe.provide("erpnext.selling"); erpnext.selling.SellingController = class SellingController extends erpnext.TransactionController { setup() { super.setup(); - this.frm.add_fetch("sales_partner", "commission_rate", "commission_rate"); - this.frm.add_fetch("sales_person", "commission_rate", "commission_rate"); } onload() { @@ -514,4 +512,4 @@ frappe.ui.form.on(cur_frm.doctype, { dialog.show(); } -}) \ No newline at end of file +}) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index e3222bc885..f9e934921d 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 3a3d13622d9cdae52e11b728a196602da70d3ec3 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Jun 2022 14:01:41 +0530 Subject: [PATCH 046/133] refactor!: drop github connector from ERPNext (#31316) --- .../connectors/github_connection.py | 44 ------------------- .../data_migration_mapping/__init__.py | 0 .../issue_to_task/__init__.py | 12 ----- .../issue_to_task/issue_to_task.json | 36 --------------- .../milestone_to_project/__init__.py | 6 --- .../milestone_to_project.json | 36 --------------- .../github_sync/github_sync.json | 22 ---------- requirements.txt | 1 - 8 files changed, 157 deletions(-) delete mode 100644 erpnext/erpnext_integrations/connectors/github_connection.py delete mode 100644 erpnext/erpnext_integrations/data_migration_mapping/__init__.py delete mode 100644 erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py delete mode 100644 erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json delete mode 100644 erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py delete mode 100644 erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json delete mode 100644 erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json diff --git a/erpnext/erpnext_integrations/connectors/github_connection.py b/erpnext/erpnext_integrations/connectors/github_connection.py deleted file mode 100644 index f28065e724..0000000000 --- a/erpnext/erpnext_integrations/connectors/github_connection.py +++ /dev/null @@ -1,44 +0,0 @@ -import frappe -from frappe.data_migration.doctype.data_migration_connector.connectors.base import BaseConnection -from github import Github - -class GithubConnection(BaseConnection): - def __init__(self, connector): - self.connector = connector - - try: - password = self.get_password() - except frappe.AuthenticationError: - password = None - - if self.connector.username and password: - self.connection = Github(self.connector.username, self.get_password()) - else: - self.connection = Github() - - self.name_field = 'id' - - def insert(self, doctype, doc): - pass - - def update(self, doctype, doc, migration_id): - pass - - def delete(self, doctype, migration_id): - pass - - def get(self, remote_objectname, fields=None, filters=None, start=0, page_length=10): - repo = filters.get('repo') - - if remote_objectname == 'Milestone': - return self.get_milestones(repo, start, page_length) - if remote_objectname == 'Issue': - return self.get_issues(repo, start, page_length) - - def get_milestones(self, repo, start=0, page_length=10): - _repo = self.connection.get_repo(repo) - return list(_repo.get_milestones()[start:start+page_length]) - - def get_issues(self, repo, start=0, page_length=10): - _repo = self.connection.get_repo(repo) - return list(_repo.get_issues()[start:start+page_length]) diff --git a/erpnext/erpnext_integrations/data_migration_mapping/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py deleted file mode 100644 index 616ecfbac6..0000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -import frappe - - -def pre_process(issue): - - project = frappe.db.get_value("Project", filters={"project_name": issue.milestone}) - return { - "title": issue.title, - "body": frappe.utils.md_to_html(issue.body or ""), - "state": issue.state.title(), - "project": project or "", - } diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json deleted file mode 100644 index e945ba2261..0000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "condition": "{\"repo\":\"frappe/erpnext\"}", - "creation": "2017-10-16 16:03:32.772191", - "docstatus": 0, - "doctype": "Data Migration Mapping", - "fields": [ - { - "is_child_table": 0, - "local_fieldname": "subject", - "remote_fieldname": "title" - }, - { - "is_child_table": 0, - "local_fieldname": "description", - "remote_fieldname": "body" - }, - { - "is_child_table": 0, - "local_fieldname": "status", - "remote_fieldname": "state" - } - ], - "idx": 0, - "local_doctype": "Task", - "local_primary_key": "name", - "mapping_name": "Issue to Task", - "mapping_type": "Pull", - "migration_id_field": "github_sync_id", - "modified": "2017-10-20 11:48:54.575993", - "modified_by": "Administrator", - "name": "Issue to Task", - "owner": "Administrator", - "page_length": 10, - "remote_objectname": "Issue", - "remote_primary_key": "id" -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py deleted file mode 100644 index d44fc0454c..0000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -def pre_process(milestone): - return { - "title": milestone.title, - "description": milestone.description, - "state": milestone.state.title(), - } diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json deleted file mode 100644 index 5a3e07e37e..0000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "condition": "{\"repo\": \"frappe/erpnext\"}", - "creation": "2017-10-13 11:16:49.664925", - "docstatus": 0, - "doctype": "Data Migration Mapping", - "fields": [ - { - "is_child_table": 0, - "local_fieldname": "project_name", - "remote_fieldname": "title" - }, - { - "is_child_table": 0, - "local_fieldname": "notes", - "remote_fieldname": "description" - }, - { - "is_child_table": 0, - "local_fieldname": "status", - "remote_fieldname": "state" - } - ], - "idx": 0, - "local_doctype": "Project", - "local_primary_key": "project_name", - "mapping_name": "Milestone to Project", - "mapping_type": "Pull", - "migration_id_field": "github_sync_id", - "modified": "2017-10-20 11:48:54.552305", - "modified_by": "Administrator", - "name": "Milestone to Project", - "owner": "Administrator", - "page_length": 10, - "remote_objectname": "Milestone", - "remote_primary_key": "id" -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json b/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json deleted file mode 100644 index 20eb387cd8..0000000000 --- a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "creation": "2017-10-13 11:16:53.600026", - "docstatus": 0, - "doctype": "Data Migration Plan", - "idx": 0, - "mappings": [ - { - "enabled": 1, - "mapping": "Milestone to Project" - }, - { - "enabled": 1, - "mapping": "Issue to Task" - } - ], - "modified": "2017-10-20 11:48:54.496123", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "GitHub Sync", - "owner": "Administrator", - "plan_name": "GitHub Sync" -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 85ff515772..83e53758be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ gocardless-pro~=1.22.0 googlemaps plaid-python~=7.2.1 pycountry~=20.7.3 -PyGithub~=1.55 python-stdnum~=1.16 python-youtube~=0.8.0 taxjar~=1.9.2 From 74b274f555801356b1ef05577eb4cb2cfb79b8d3 Mon Sep 17 00:00:00 2001 From: hendrik Date: Fri, 10 Jun 2022 16:22:53 +0700 Subject: [PATCH 047/133] fix: update Period Closing Voucher per Company Validate period closing voucher company-wise --- .../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 53b1c64c46..5a86376199 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 39ec0aca9550e64bccdbdad96613f25b97026c53 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Jun 2022 18:43:46 +0530 Subject: [PATCH 048/133] fix(UX): use doc.status for Job Card status (#31320) fix(UX): use doc.status for JC status - Use doc.status directly for indicator - single source of truth - Update status to cancelled when doc is cancelled --- .../doctype/job_card/job_card.py | 13 +++++----- .../doctype/job_card/job_card_list.js | 23 +++++++++--------- .../doctype/job_card/test_job_card.py | 24 +++++++++++++++++++ 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 0199a5c31e..ed45106634 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 7f60bdc6d9..5d883bf9fa 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 7f3c7fefe9..ac7114138c 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -344,6 +344,30 @@ 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 + 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 f6695909c47265cccc6dc85744ce6906d2b01136 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 10 Jun 2022 15:51:02 +0200 Subject: [PATCH 049/133] fix: remove DATEV from accounting workspace --- .../workspace/accounting/accounting.json | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json index a456c7fb57..61f6225459 100644 --- a/erpnext/accounts/workspace/accounting/accounting.json +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -504,18 +504,6 @@ "onboard": 0, "type": "Link" }, - { - "dependencies": "GL Entry", - "hidden": 0, - "is_query_report": 1, - "label": "DATEV Export", - "link_count": 0, - "link_to": "DATEV", - "link_type": "Report", - "onboard": 0, - "only_for": "Germany", - "type": "Link" - }, { "dependencies": "GL Entry", "hidden": 0, @@ -1024,16 +1012,16 @@ "type": "Link" }, { - "dependencies": "Cost Center", - "hidden": 0, - "is_query_report": 0, - "label": "Cost Center Allocation", - "link_count": 0, - "link_to": "Cost Center Allocation", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, + "dependencies": "Cost Center", + "hidden": 0, + "is_query_report": 0, + "label": "Cost Center Allocation", + "link_count": 0, + "link_to": "Cost Center Allocation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "dependencies": "Cost Center", "hidden": 0, @@ -1235,13 +1223,14 @@ "type": "Link" } ], - "modified": "2022-01-13 17:25:09.835345", + "modified": "2022-06-10 15:49:42.990860", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting", "owner": "Administrator", "parent_page": "", "public": 1, + "quick_lists": [], "restrict_to_domain": "", "roles": [], "sequence_id": 2.0, From 2fc04f661ac0dc3df1f6d1cc2bd37920f7c98917 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Jun 2022 19:23:17 +0530 Subject: [PATCH 050/133] fix: Company address filter in quotation --- 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 34e9a52e11..b45653349e 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 243625898ee884b641c5fcbb437ce35e77eb22d9 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Jun 2022 22:05:58 +0530 Subject: [PATCH 051/133] fix(India): Sales taxes and charges template fetching in quotation --- 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 f4a44bd362..e39f22b4cf 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -211,7 +211,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 ee48ccb24a..0262469571 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -287,7 +287,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 118c786e63173413864dfd0f08a1748eef377059 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 11 Jun 2022 21:55:59 +0530 Subject: [PATCH 052/133] fix: Partially Ordered status for quotation --- erpnext/controllers/status_updater.py | 3 +- .../selling/doctype/quotation/quotation.js | 2 +- .../selling/doctype/quotation/quotation.json | 4 +-- .../selling/doctype/quotation/quotation.py | 28 +++++++++++++++++-- .../doctype/quotation/quotation_list.js | 2 ++ 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 3c0a10e086..517e080c97 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 b45653349e..70ae085051 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -84,7 +84,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. } } - 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 75443abe49..5dfd8f2965 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -897,7 +897,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 @@ -986,7 +986,7 @@ "idx": 82, "is_submittable": 1, "links": [], - "modified": "2022-04-07 11:01:31.157084", + "modified": "2022-06-11 20:35:32.635804", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 548813ddef..d5fd946bde 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -70,8 +70,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 4c8f9c4f84..29d2530086 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -25,6 +25,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 dfe3082596c3de4d155ab8b0ab74fd1b287a6869 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 18 May 2022 13:59:21 +0530 Subject: [PATCH 053/133] refactor: AR/AP will use payment ledger --- .../accounts_receivable.py | 342 ++++++++---------- 1 file changed, 155 insertions(+), 187 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index de9d63d849..ad8fa5ba05 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -5,7 +5,8 @@ from collections import OrderedDict import frappe -from frappe import _, scrub +from frappe import _, qb, scrub +from frappe.query_builder import Criterion from frappe.utils import cint, cstr, flt, getdate, nowdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( @@ -41,6 +42,8 @@ def execute(filters=None): class ReceivablePayableReport(object): def __init__(self, filters=None): self.filters = frappe._dict(filters or {}) + self.qb_selection_filter = [] + self.ple = qb.DocType("Payment Ledger Entry") self.filters.report_date = getdate(self.filters.report_date or nowdate()) self.age_as_on = ( getdate(nowdate()) @@ -78,7 +81,7 @@ class ReceivablePayableReport(object): self.skip_total_row = 1 def get_data(self): - self.get_gl_entries() + self.get_ple_entries() self.get_sales_invoices_or_customers_based_on_sales_person() self.voucher_balance = OrderedDict() self.init_voucher_balance() # invoiced, paid, credit_note, outstanding @@ -96,25 +99,25 @@ class ReceivablePayableReport(object): self.get_return_entries() self.data = [] - for gle in self.gl_entries: - self.update_voucher_balance(gle) + + for ple in self.ple_entries: + self.update_voucher_balance(ple) self.build_data() def init_voucher_balance(self): # build all keys, since we want to exclude vouchers beyond the report date - for gle in self.gl_entries: + for ple in self.ple_entries: # get the balance object for voucher_type - key = (gle.voucher_type, gle.voucher_no, gle.party) + key = (ple.voucher_type, ple.voucher_no, ple.party) if not key in self.voucher_balance: self.voucher_balance[key] = frappe._dict( - voucher_type=gle.voucher_type, - voucher_no=gle.voucher_no, - party=gle.party, - party_account=gle.account, - posting_date=gle.posting_date, - account_currency=gle.account_currency, - remarks=gle.remarks if self.filters.get("show_remarks") else None, + voucher_type=ple.voucher_type, + voucher_no=ple.voucher_no, + party=ple.party, + party_account=ple.account, + posting_date=ple.posting_date, + account_currency=ple.account_currency, invoiced=0.0, paid=0.0, credit_note=0.0, @@ -124,23 +127,22 @@ class ReceivablePayableReport(object): credit_note_in_account_currency=0.0, outstanding_in_account_currency=0.0, ) - self.get_invoices(gle) if self.filters.get("group_by_party"): - self.init_subtotal_row(gle.party) + self.init_subtotal_row(ple.party) if self.filters.get("group_by_party"): self.init_subtotal_row("Total") - def get_invoices(self, gle): - if gle.voucher_type in ("Sales Invoice", "Purchase Invoice"): + def get_invoices(self, ple): + if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"): if self.filters.get("sales_person"): - if gle.voucher_no in self.sales_person_records.get( + if ple.voucher_no in self.sales_person_records.get( "Sales Invoice", [] - ) or gle.party in self.sales_person_records.get("Customer", []): - self.invoices.add(gle.voucher_no) + ) or ple.party in self.sales_person_records.get("Customer", []): + self.invoices.add(ple.voucher_no) else: - self.invoices.add(gle.voucher_no) + self.invoices.add(ple.voucher_no) def init_subtotal_row(self, party): if not self.total_row_map.get(party): @@ -162,39 +164,49 @@ class ReceivablePayableReport(object): "range5", ] - def update_voucher_balance(self, gle): + def get_voucher_balance(self, ple): + if self.filters.get("sales_person"): + if not ( + ple.party in self.sales_person_records.get("Customer", []) + or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", []) + ): + return + + key = (ple.against_voucher_type, ple.against_voucher_no, ple.party) + row = self.voucher_balance.get(key) + return row + + def update_voucher_balance(self, ple): # get the row where this balance needs to be updated # if its a payment, it will return the linked invoice or will be considered as advance - row = self.get_voucher_balance(gle) + row = self.get_voucher_balance(ple) if not row: return - # gle_balance will be the total "debit - credit" for receivable type reports and - # and vice-versa for payable type reports - gle_balance = self.get_gle_balance(gle) - gle_balance_in_account_currency = self.get_gle_balance_in_account_currency(gle) - if gle_balance > 0: - if gle.voucher_type in ("Journal Entry", "Payment Entry") and gle.against_voucher: - # debit against sales / purchase invoice - row.paid -= gle_balance - row.paid_in_account_currency -= gle_balance_in_account_currency + amount = ple.amount + amount_in_account_currency = ple.amount_in_account_currency + + # update voucher + if ple.amount > 0: + if ( + ple.voucher_type in ["Journal Entry", "Payment Entry"] + and ple.voucher_no != ple.against_voucher_no + ): + row.paid -= amount + row.paid_in_account_currency -= amount_in_account_currency else: - # invoice - row.invoiced += gle_balance - row.invoiced_in_account_currency += gle_balance_in_account_currency + row.invoiced += amount + row.invoiced_in_account_currency += amount_in_account_currency else: - # payment or credit note for receivables - if self.is_invoice(gle): - # stand alone debit / credit note - row.credit_note -= gle_balance - row.credit_note_in_account_currency -= gle_balance_in_account_currency + if self.is_invoice(ple): + row.credit_note -= amount + row.credit_note_in_account_currency -= amount_in_account_currency else: - # advance / unlinked payment or other adjustment - row.paid -= gle_balance - row.paid_in_account_currency -= gle_balance_in_account_currency + row.paid -= amount + row.paid_in_account_currency -= amount_in_account_currency - if gle.cost_center: - row.cost_center = str(gle.cost_center) + if ple.cost_center: + row.cost_center = str(ple.cost_center) def update_sub_total_row(self, row, party): total_row = self.total_row_map.get(party) @@ -210,39 +222,6 @@ class ReceivablePayableReport(object): self.data.append({}) self.update_sub_total_row(sub_total_row, "Total") - def get_voucher_balance(self, gle): - if self.filters.get("sales_person"): - against_voucher = gle.against_voucher or gle.voucher_no - if not ( - gle.party in self.sales_person_records.get("Customer", []) - or against_voucher in self.sales_person_records.get("Sales Invoice", []) - ): - return - - voucher_balance = None - if gle.against_voucher: - # find invoice - against_voucher = gle.against_voucher - - # If payment is made against credit note - # and credit note is made against a Sales Invoice - # then consider the payment against original sales invoice. - if gle.against_voucher_type in ("Sales Invoice", "Purchase Invoice"): - if gle.against_voucher in self.return_entries: - return_against = self.return_entries.get(gle.against_voucher) - if return_against: - against_voucher = return_against - - voucher_balance = self.voucher_balance.get( - (gle.against_voucher_type, against_voucher, gle.party) - ) - - if not voucher_balance: - # no invoice, this is an invoice / stand-alone payment / credit note - voucher_balance = self.voucher_balance.get((gle.voucher_type, gle.voucher_no, gle.party)) - - return voucher_balance - def build_data(self): # set outstanding for all the accumulated balances # as we can use this to filter out invoices without outstanding @@ -260,6 +239,7 @@ class ReceivablePayableReport(object): if (abs(row.outstanding) > 1.0 / 10**self.currency_precision) and ( abs(row.outstanding_in_account_currency) > 1.0 / 10**self.currency_precision ): + # non-zero oustanding, we must consider this row if self.is_invoice(row) and self.filters.based_on_payment_terms: @@ -669,48 +649,42 @@ class ReceivablePayableReport(object): index = 4 row["range" + str(index + 1)] = row.outstanding - def get_gl_entries(self): + def get_ple_entries(self): # get all the GL entries filtered by the given filters - conditions, values = self.prepare_conditions() - order_by = self.get_order_by_condition() + self.prepare_conditions() - if self.filters.show_future_payments: - values.insert(2, self.filters.report_date) + self.qb_selection_filter.append(self.ple.posting_date.lte(self.filters.report_date)) - date_condition = """AND (posting_date <= %s - OR (against_voucher IS NULL AND DATE(creation) <= %s))""" - else: - date_condition = "AND posting_date <=%s" - - if self.filters.get(scrub(self.party_type)): - select_fields = "debit_in_account_currency as debit, credit_in_account_currency as credit" - else: - select_fields = "debit, credit" - - doc_currency_fields = "debit_in_account_currency, credit_in_account_currency" - - remarks = ", remarks" if self.filters.get("show_remarks") else "" - - self.gl_entries = frappe.db.sql( - """ - select - name, posting_date, account, party_type, party, voucher_type, voucher_no, cost_center, - against_voucher_type, against_voucher, account_currency, {0}, {1} {remarks} - from - `tabGL Entry` - where - docstatus < 2 - and is_cancelled = 0 - and party_type=%s - and (party is not null and party != '') - {2} {3} {4}""".format( - select_fields, doc_currency_fields, date_condition, conditions, order_by, remarks=remarks - ), - values, - as_dict=True, + ple = qb.DocType("Payment Ledger Entry") + query = ( + qb.from_(ple) + .select( + ple.account, + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.party_type, + ple.cost_center, + ple.party, + ple.posting_date, + ple.due_date, + ple.account_currency.as_("currency"), + ple.amount, + ple.amount_in_account_currency, + ) + .where(ple.delinked == 0) + .where(Criterion.all(self.qb_selection_filter)) ) + if self.filters.get("group_by_party"): + query = query.orderby(self.ple.party, self.ple.posting_date) + else: + query = query.orderby(self.ple.posting_date, self.ple.party) + + self.ple_entries = query.run(as_dict=True) + def get_sales_invoices_or_customers_based_on_sales_person(self): if self.filters.get("sales_person"): lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"]) @@ -731,23 +705,21 @@ class ReceivablePayableReport(object): self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent) def prepare_conditions(self): - conditions = [""] - values = [self.party_type, self.filters.report_date] + self.qb_selection_filter = [] party_type_field = scrub(self.party_type) - self.add_common_filters(conditions, values, party_type_field) + self.add_common_filters(party_type_field=party_type_field) if party_type_field == "customer": - self.add_customer_filters(conditions, values) + self.add_customer_filters() elif party_type_field == "supplier": - self.add_supplier_filters(conditions, values) + self.add_supplier_filters() if self.filters.cost_center: - self.get_cost_center_conditions(conditions) + self.get_cost_center_conditions() - self.add_accounting_dimensions_filters(conditions, values) - return " and ".join(conditions), values + self.add_accounting_dimensions_filters() def get_cost_center_conditions(self, conditions): lft, rgt = frappe.db.get_value("Cost Center", self.filters.cost_center, ["lft", "rgt"]) @@ -755,32 +727,20 @@ class ReceivablePayableReport(object): center.name for center in frappe.get_list("Cost Center", filters={"lft": (">=", lft), "rgt": ("<=", rgt)}) ] + self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list)) - cost_center_string = '", "'.join(cost_center_list) - conditions.append('cost_center in ("{0}")'.format(cost_center_string)) - - def get_order_by_condition(self): - if self.filters.get("group_by_party"): - return "order by party, posting_date" - else: - return "order by posting_date, party" - - def add_common_filters(self, conditions, values, party_type_field): + def add_common_filters(self, party_type_field): if self.filters.company: - conditions.append("company=%s") - values.append(self.filters.company) + self.qb_selection_filter.append(self.ple.company == self.filters.company) if self.filters.finance_book: - conditions.append("ifnull(finance_book, '') in (%s, '')") - values.append(self.filters.finance_book) + self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book) if self.filters.get(party_type_field): - conditions.append("party=%s") - values.append(self.filters.get(party_type_field)) + self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field)) if self.filters.party_account: - conditions.append("account =%s") - values.append(self.filters.party_account) + self.qb_selection_filter.append(self.ple.account == self.filters.party_account) else: # get GL with "receivable" or "payable" account_type account_type = "Receivable" if self.party_type == "Customer" else "Payable" @@ -792,46 +752,68 @@ class ReceivablePayableReport(object): ] if accounts: - conditions.append("account in (%s)" % ",".join(["%s"] * len(accounts))) - values += accounts + self.qb_selection_filter.append(self.ple.account.isin(accounts)) + + def add_customer_filters( + self, + ): + self.customter = qb.DocType("Customer") - def add_customer_filters(self, conditions, values): if self.filters.get("customer_group"): - conditions.append(self.get_hierarchical_filters("Customer Group", "customer_group")) + self.get_hierarchical_filters("Customer Group", "customer_group") if self.filters.get("territory"): - conditions.append(self.get_hierarchical_filters("Territory", "territory")) + self.get_hierarchical_filters("Territory", "territory") if self.filters.get("payment_terms_template"): - conditions.append("party in (select name from tabCustomer where payment_terms=%s)") - values.append(self.filters.get("payment_terms_template")) + self.qb_selection_filter.append( + self.ple.party_isin( + qb.from_(self.customer).where( + self.customer.payment_terms == self.filters.get("payment_terms_template") + ) + ) + ) if self.filters.get("sales_partner"): - conditions.append("party in (select name from tabCustomer where default_sales_partner=%s)") - values.append(self.filters.get("sales_partner")) - - def add_supplier_filters(self, conditions, values): - if self.filters.get("supplier_group"): - conditions.append( - """party in (select name from tabSupplier - where supplier_group=%s)""" + self.qb_selection_filter.append( + self.ple.party_isin( + qb.from_(self.customer).where( + self.customer.default_sales_partner == self.filters.get("payment_terms_template") + ) + ) + ) + + def add_supplier_filters(self): + supplier = qb.DocType("Supplier") + if self.filters.get("supplier_group"): + self.qb_selection_filter.append( + self.ple.party.isin( + qb.from_(supplier) + .select(supplier.name) + .where(supplier.supplier_group == self.filters.get("supplier_group")) + ) ) - values.append(self.filters.get("supplier_group")) if self.filters.get("payment_terms_template"): - conditions.append("party in (select name from tabSupplier where payment_terms=%s)") - values.append(self.filters.get("payment_terms_template")) + self.qb_selection_filter.append( + self.ple.party.isin( + qb.from_(supplier) + .select(supplier.name) + .where(supplier.payment_terms == self.filters.get("supplier_group")) + ) + ) def get_hierarchical_filters(self, doctype, key): lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"]) - return """party in (select name from tabCustomer - where exists(select name from `tab{doctype}` where lft >= {lft} and rgt <= {rgt} - and name=tabCustomer.{key}))""".format( - doctype=doctype, lft=lft, rgt=rgt, key=key - ) + doc = qb.DocType(doctype) + ple = self.ple + customer = self.customer + groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt)) + customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups)) + self.qb_selection_filter.append(ple.isin(ple.party.isin(customers))) - def add_accounting_dimensions_filters(self, conditions, values): + def add_accounting_dimensions_filters(self): accounting_dimensions = get_accounting_dimensions(as_list=False) if accounting_dimensions: @@ -841,30 +823,16 @@ class ReceivablePayableReport(object): self.filters[dimension.fieldname] = get_dimension_with_children( dimension.document_type, self.filters.get(dimension.fieldname) ) - conditions.append("{0} in %s".format(dimension.fieldname)) - values.append(tuple(self.filters.get(dimension.fieldname))) + self.qb_selection_filter.append( + self.ple[dimension.fieldname].isin(self.filters[dimension.fieldname]) + ) + else: + self.qb_selection_filter.append( + self.ple[dimension.fieldname] == self.filters[dimension.fieldname] + ) - def get_gle_balance(self, gle): - # get the balance of the GL (debit - credit) or reverse balance based on report type - return gle.get(self.dr_or_cr) - self.get_reverse_balance(gle) - - def get_gle_balance_in_account_currency(self, gle): - # get the balance of the GL (debit - credit) or reverse balance based on report type - return gle.get( - self.dr_or_cr + "_in_account_currency" - ) - self.get_reverse_balance_in_account_currency(gle) - - def get_reverse_balance_in_account_currency(self, gle): - return gle.get( - "debit_in_account_currency" if self.dr_or_cr == "credit" else "credit_in_account_currency" - ) - - def get_reverse_balance(self, gle): - # get "credit" balance if report type is "debit" and vice versa - return gle.get("debit" if self.dr_or_cr == "credit" else "credit") - - def is_invoice(self, gle): - if gle.voucher_type in ("Sales Invoice", "Purchase Invoice"): + def is_invoice(self, ple): + if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"): return True def get_party_details(self, party): From cd9d70d6eee69b1de8c0df2b9289c9a0ce5e1395 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 30 May 2022 18:49:17 +0530 Subject: [PATCH 054/133] refactor: show advance payments in AR/AP report --- .../accounts_receivable/accounts_receivable.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index ad8fa5ba05..7329fd1418 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -7,6 +7,7 @@ from collections import OrderedDict import frappe from frappe import _, qb, scrub from frappe.query_builder import Criterion +from frappe.query_builder.functions import Date from frappe.utils import cint, cstr, flt, getdate, nowdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( @@ -654,7 +655,18 @@ class ReceivablePayableReport(object): self.prepare_conditions() - self.qb_selection_filter.append(self.ple.posting_date.lte(self.filters.report_date)) + if self.filters.show_future_payments: + self.qb_selection_filter.append( + ( + self.ple.posting_date.lte(self.filters.report_date) + | ( + (self.ple.voucher_no == self.ple.against_voucher_no) + & (Date(self.ple.creation).lte(self.filters.report_date)) + ) + ) + ) + else: + self.qb_selection_filter.append(self.ple.posting_date.lte(self.filters.report_date)) ple = qb.DocType("Payment Ledger Entry") query = ( From 71521b6550d45f57b1e0f9efa333738b6f6ee0d5 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 23 May 2022 15:38:56 +0530 Subject: [PATCH 055/133] refactor: unit test for AR/AP report --- .../report/accounts_receivable/test_accounts_receivable.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index f38890e980..edddbbce21 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -12,6 +12,7 @@ class TestAccountsReceivable(unittest.TestCase): def test_accounts_receivable(self): frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'") frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'") + frappe.db.sql("delete from `tabPayment Ledger Entry` where company='_Test Company 2'") filters = { "company": "_Test Company 2", From 3018756482256060242584f3fbf9f228c0a45f06 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 30 May 2022 18:50:15 +0530 Subject: [PATCH 056/133] refactor: remove 'show remarks' --- .../report/accounts_receivable/accounts_receivable.js | 5 ----- .../report/accounts_receivable/accounts_receivable.py | 3 --- 2 files changed, 8 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 748bcde435..0238711a70 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -172,11 +172,6 @@ frappe.query_reports["Accounts Receivable"] = { "label": __("Show Sales Person"), "fieldtype": "Check", }, - { - "fieldname": "show_remarks", - "label": __("Show Remarks"), - "fieldtype": "Check", - }, { "fieldname": "tax_id", "label": __("Tax Id"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 7329fd1418..1911152dec 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -906,9 +906,6 @@ class ReceivablePayableReport(object): width=180, ) - if self.filters.show_remarks: - self.add_column(label=_("Remarks"), fieldname="remarks", fieldtype="Text", width=200), - self.add_column(label="Due Date", fieldtype="Date") if self.party_type == "Supplier": From 83367bfe5e286244461caa6d2dcddc5e851f2e3c Mon Sep 17 00:00:00 2001 From: HENRY Florian Date: Mon, 13 Jun 2022 07:45:47 +0200 Subject: [PATCH 057/133] 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 --- 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 22e3c356d6..40fb5ad19a 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, @@ -9871,8 +9871,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 d3d0ade89f93fe353569068a01b8f4eb911f5537 Mon Sep 17 00:00:00 2001 From: Marica Date: Mon, 13 Jun 2022 11:54:12 +0530 Subject: [PATCH 058/133] chore: Accidental '=' instead of comma in French translation (#31335) --- erpnext/translations/fr.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index 40fb5ad19a..ffc46d2fd0 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -9908,5 +9908,5 @@ 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 +Publish in Website,Publier sur le Site Web List View,Vue en liste From 3701cdbaf195d421b83d86a3242bb488548def13 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 13 Jun 2022 12:14:31 +0530 Subject: [PATCH 059/133] ci(Mergify): configuration update (#31336) Signed-off-by: Ankush Menat --- .mergify.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.mergify.yml b/.mergify.yml index cc8c0802f1..d7f82e696b 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -9,6 +9,8 @@ pull_request_rules: - author!=nabinhait - author!=ankush - author!=deepeshgarg007 + - author!=mergify[bot] + - or: - base=version-13 - base=version-12 @@ -19,6 +21,16 @@ pull_request_rules: @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch + - name: Auto-close PRs on pre-release branch + conditions: + - base=version-13-pre-release + actions: + close: + comment: + message: | + @{{author}}, pre-release branch is not maintained anymore. Releases are directly done by merging hotfix branch to stable branches. + + - name: backport to develop conditions: - label="backport develop" From 0727d1d99b237780f6d9a4d66fa9c5ffd3a991ea Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Jun 2022 12:39:28 +0530 Subject: [PATCH 060/133] refactor: get_fiscal_years API * Optimize fiscal year options generation * Don't pass unrequired criterions / values to prepared query * Use QB notation for raw query --- erpnext/accounts/utils.py | 54 +++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 8711395d55..65e05410aa 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -9,7 +9,10 @@ import frappe import frappe.defaults from frappe import _, qb, throw from frappe.model.meta import get_field_precision +from frappe.query_builder.utils import DocType from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate +from pypika import Order +from pypika.terms import ExistsCriterion import erpnext @@ -42,37 +45,32 @@ def get_fiscal_years( if not fiscal_years: # if year start date is 2012-04-01, year end date should be 2013-03-31 (hence subdate) - cond = "" - if fiscal_year: - cond += " and fy.name = {0}".format(frappe.db.escape(fiscal_year)) - if company: - cond += """ - and (not exists (select name - from `tabFiscal Year Company` fyc - where fyc.parent = fy.name) - or exists(select company - from `tabFiscal Year Company` fyc - where fyc.parent = fy.name - and fyc.company=%(company)s) - ) - """ + FY = DocType("Fiscal Year") - fiscal_years = frappe.db.sql( - """ - select - fy.name, fy.year_start_date, fy.year_end_date - from - `tabFiscal Year` fy - where - disabled = 0 {0} - order by - fy.year_start_date desc""".format( - cond - ), - {"company": company}, - as_dict=True, + query = ( + frappe.qb.from_(FY) + .select(FY.name, FY.year_start_date, FY.year_end_date) + .where(FY.disabled == 0) ) + if fiscal_year: + query = query.where(FY.name == fiscal_year) + + if company: + FYC = DocType("Fiscal Year Company") + query = query.where( + ExistsCriterion(frappe.qb.from_(FYC).select(FYC.name).where(FYC.parent == FY.name)).negate() + | ExistsCriterion( + frappe.qb.from_(FYC) + .select(FYC.company) + .where(FYC.parent == FY.name) + .where(FYC.company == company) + ) + ) + + query = query.orderby(FY.year_start_date, Order.desc) + fiscal_years = query.run(as_dict=True) + frappe.cache().hset("fiscal_years", company, fiscal_years) if not transaction_date and not fiscal_year: From 5f8cd34da501199c61688e1927a99183d34a7655 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Jun 2022 12:41:35 +0530 Subject: [PATCH 061/133] fix: Use newer PyPDF2 APIs Depends on https://github.com/frappe/frappe/pull/17127 --- erpnext/regional/report/irs_1099/irs_1099.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index 92aeb5ee6f..0f578be175 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -10,7 +10,7 @@ from frappe.utils.data import fmt_money from frappe.utils.jinja import render_template from frappe.utils.pdf import get_pdf from frappe.utils.print_format import read_multi_pdf -from PyPDF2 import PdfFileWriter +from PyPDF2 import PdfWriter from erpnext.accounts.utils import get_fiscal_year @@ -106,7 +106,7 @@ def irs_1099_print(filters): columns, data = execute(filters) template = frappe.get_doc("Print Format", "IRS 1099 Form").html - output = PdfFileWriter() + output = PdfWriter() for row in data: row["fiscal_year"] = fiscal_year From 6c726a161cdf653e96cca334018d5c2cf9329e23 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Jun 2022 13:22:04 +0530 Subject: [PATCH 062/133] ci(patch): Setup python dependencies after switching to current branch --- .github/workflows/patch.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index afabe43fec..2cf44444cc 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -115,4 +115,5 @@ jobs: echo "Updating to latest version" git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA" + bench setup requirements --python bench --site test_site migrate From e9af68e9474e7e7c03f6ede92aac5901dbd4c2c5 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Jun 2022 14:26:59 +0530 Subject: [PATCH 063/133] test: Remove deprecated as_tuple kwarg in FrappeTestAPI.post --- erpnext/tests/test_exotel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/tests/test_exotel.py b/erpnext/tests/test_exotel.py index 76bbb3e05a..31baf7594d 100644 --- a/erpnext/tests/test_exotel.py +++ b/erpnext/tests/test_exotel.py @@ -59,7 +59,6 @@ class TestExotel(FrappeAPITestCase): f"/api/method/erpnext.erpnext_integrations.exotel_integration.{api_method}", data=frappe.as_json(data), content_type="application/json", - as_tuple=True, ) # restart db connection to get latest data frappe.connect() From 883598d59a9e17f24e1c407f17ad1fa0b6ecf5f9 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 13 Jun 2022 16:24:48 +0530 Subject: [PATCH 064/133] chore: delete BOT RIP --- erpnext/utilities/bot.py | 46 ---------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 erpnext/utilities/bot.py diff --git a/erpnext/utilities/bot.py b/erpnext/utilities/bot.py deleted file mode 100644 index 5c2e576dd2..0000000000 --- a/erpnext/utilities/bot.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - - -import frappe -from frappe import _ -from frappe.utils.bot import BotParser - - -class FindItemBot(BotParser): - def get_reply(self): - if self.startswith("where is", "find item", "locate"): - if not frappe.has_permission("Warehouse"): - raise frappe.PermissionError - - item = "%{0}%".format(self.strip_words(self.query, "where is", "find item", "locate")) - items = frappe.db.sql( - """select name from `tabItem` where item_code like %(txt)s - or item_name like %(txt)s or description like %(txt)s""", - dict(txt=item), - ) - - if items: - out = [] - warehouses = frappe.get_all("Warehouse") - for item in items: - found = False - for warehouse in warehouses: - qty = frappe.db.get_value( - "Bin", {"item_code": item[0], "warehouse": warehouse.name}, "actual_qty" - ) - if qty: - out.append( - _("{0} units of [{1}](/app/Form/Item/{1}) found in [{2}](/app/Form/Warehouse/{2})").format( - qty, item[0], warehouse.name - ) - ) - found = True - - if not found: - out.append(_("[{0}](/app/Form/Item/{0}) is out of stock").format(item[0])) - - return "\n\n".join(out) - - else: - return _("Did not find any item called {0}").format(item) From fa1d9d548e1d1d3af7041c495ba3dc8829cbf7aa Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 13 Jun 2022 17:59:03 +0530 Subject: [PATCH 065/133] 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 --- .../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 f1df54dd6a..a9176a9f12 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1980,23 +1980,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 b8f468cb4f33a3983428b7c4614168a0e4405608 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 13 Jun 2022 18:31:35 +0530 Subject: [PATCH 066/133] test: PO Supplied Qty reset on cancel/submit --- 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 bf12181c52..93d1c8e6a5 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 00ef499739959630cd7cf97419fbb6ca59be05f2 Mon Sep 17 00:00:00 2001 From: Conor Date: Tue, 14 Jun 2022 00:19:07 -0500 Subject: [PATCH 067/133] refactor: use db independent offset syntax (#31345) * chore: use db independent offset syntax * fix: typo * style: reformat code to black spec Co-authored-by: Ankush Menat --- erpnext/accounts/doctype/account/account.py | 4 ++-- .../doctype/journal_entry/journal_entry.py | 2 +- .../doctype/payment_order/payment_order.py | 4 ++-- .../doctype/pos_profile/pos_profile.py | 2 +- .../request_for_quotation.py | 2 +- erpnext/controllers/queries.py | 20 +++++++++---------- .../bom_variance_report.py | 2 +- .../doctype/payroll_entry/payroll_entry.py | 4 ++-- erpnext/projects/doctype/project/project.py | 2 +- erpnext/projects/doctype/task/task.py | 2 +- .../projects/doctype/timesheet/timesheet.py | 4 ++-- erpnext/projects/utils.py | 4 ++-- .../doctype/product_bundle/product_bundle.py | 4 ++-- .../page/point_of_sale/point_of_sale.py | 4 ++-- .../setup/doctype/party_type/party_type.py | 2 +- .../item_alternative/item_alternative.py | 2 +- .../doctype/packing_slip/packing_slip.py | 4 ++-- .../quality_inspection/quality_inspection.py | 4 ++-- erpnext/templates/pages/product_search.py | 5 ++++- 19 files changed, 40 insertions(+), 37 deletions(-) diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index c71ea3648b..2610c8655e 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -322,9 +322,9 @@ def get_parent_account(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name from tabAccount where is_group = 1 and docstatus != 2 and company = %s - and %s like %s order by name limit %s, %s""" + and %s like %s order by name limit %s offset %s""" % ("%s", searchfield, "%s", "%s", "%s"), - (filters["company"], "%%%s%%" % txt, start, page_len), + (filters["company"], "%%%s%%" % txt, page_len, start), as_list=1, ) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 145118957b..8f0fe51e3d 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1239,7 +1239,7 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters): AND jv.docstatus = 1 AND jv.`{0}` LIKE %(txt)s ORDER BY jv.name DESC - LIMIT %(offset)s, %(limit)s + LIMIT %(limit)s offset %(offset)s """.format( searchfield ), diff --git a/erpnext/accounts/doctype/payment_order/payment_order.py b/erpnext/accounts/doctype/payment_order/payment_order.py index 3c45d20770..ff9615d14f 100644 --- a/erpnext/accounts/doctype/payment_order/payment_order.py +++ b/erpnext/accounts/doctype/payment_order/payment_order.py @@ -39,7 +39,7 @@ def get_mop_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """ select mode_of_payment from `tabPayment Order Reference` where parent = %(parent)s and mode_of_payment like %(txt)s - limit %(start)s, %(page_len)s""", + limit %(page_len)s offset %(start)s""", {"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt}, ) @@ -51,7 +51,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters): """ select supplier from `tabPayment Order Reference` where parent = %(parent)s and supplier like %(txt)s and (payment_reference is null or payment_reference='') - limit %(start)s, %(page_len)s""", + limit %(page_len)s offset %(start)s""", {"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt}, ) diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index e83dc0f11e..e8aee737f2 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -173,7 +173,7 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters): where pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s and (pf.name like %(txt)s) - and pf.disabled = 0 limit %(start)s, %(page_len)s""", + and pf.disabled = 0 limit %(page_len)s offset %(start)s""", args, ) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index d39aec19ba..67affe770f 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -285,7 +285,7 @@ def get_supplier_contacts(doctype, txt, searchfield, start, page_len, filters): """select `tabContact`.name from `tabContact`, `tabDynamic Link` where `tabDynamic Link`.link_doctype = 'Supplier' and (`tabDynamic Link`.link_name=%(name)s and `tabDynamic Link`.link_name like %(txt)s) and `tabContact`.name = `tabDynamic Link`.parent - limit %(start)s, %(page_len)s""", + limit %(page_len)s offset %(start)s""", {"start": start, "page_len": page_len, "txt": "%%%s%%" % txt, "name": filters.get("supplier")}, ) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index eeb5a7f1a9..1497b182e5 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -33,7 +33,7 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), idx desc, name, employee_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "fields": ", ".join(fields), "key": searchfield, @@ -65,7 +65,7 @@ def lead_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, company_name), locate(%(_txt)s, company_name), 99999), idx desc, name, lead_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{"fields": ", ".join(fields), "key": searchfield, "mcond": get_match_cond(doctype)} ), {"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len}, @@ -100,7 +100,7 @@ def customer_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, customer_name), locate(%(_txt)s, customer_name), 99999), idx desc, name, customer_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "fields": ", ".join(fields), "scond": searchfields, @@ -137,7 +137,7 @@ def supplier_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999), idx desc, name, supplier_name - limit %(start)s, %(page_len)s """.format( + limit %(page_len)s offset %(start)s""".format( **{"field": ", ".join(fields), "key": searchfield, "mcond": get_match_cond(doctype)} ), {"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len}, @@ -167,7 +167,7 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters): AND `{searchfield}` LIKE %(txt)s {mcond} ORDER BY idx DESC, name - LIMIT %(offset)s, %(limit)s + LIMIT %(limit)s offset %(offset)s """.format( account_type_condition=account_type_condition, searchfield=searchfield, @@ -351,7 +351,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), idx desc, `tabProject`.name asc - limit {start}, {page_len}""".format( + limit {page_len} offset {start}""".format( fields=", ".join(["`tabProject`.{0}".format(f) for f in fields]), cond=cond, scond=searchfields, @@ -383,7 +383,7 @@ def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, and return_against in (select name from `tabDelivery Note` where per_billed < 100) ) ) - %(mcond)s order by `tabDelivery Note`.`%(key)s` asc limit %(start)s, %(page_len)s + %(mcond)s order by `tabDelivery Note`.`%(key)s` asc limit %(page_len)s offset %(start)s """ % { "fields": ", ".join(["`tabDelivery Note`.{0}".format(f) for f in fields]), @@ -456,7 +456,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): {match_conditions} group by batch_no {having_clause} order by batch.expiry_date, sle.batch_no desc - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( search_columns=search_columns, cond=cond, match_conditions=get_match_cond(doctype), @@ -483,7 +483,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): {match_conditions} order by expiry_date, name desc - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( cond, search_columns=search_columns, search_cond=search_cond, @@ -662,7 +662,7 @@ def warehouse_query(doctype, txt, searchfield, start, page_len, filters): {fcond} {mcond} order by ifnull(`tabBin`.actual_qty, 0) desc limit - {start}, {page_len} + {page_len} offset {start} """.format( bin_conditions=get_filters_cond( doctype, filter_dict.get("Bin"), bin_conditions, ignore_permissions=True diff --git a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py index 3fe2198966..da283435b9 100644 --- a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py +++ b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py @@ -102,7 +102,7 @@ def get_work_orders(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name from `tabWork Order` where name like %(name)s and {0} and produced_qty > qty and docstatus = 1 - order by name limit {1}, {2}""".format( + order by name limit {2} offset {1}""".format( cond, start, page_len ), {"name": "%%%s%%" % txt}, diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 620fcadceb..1524fb7c9e 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -970,7 +970,7 @@ def get_payroll_entries_for_jv(doctype, txt, searchfield, start, page_len, filte and name not in (select reference_name from `tabJournal Entry Account` where reference_type="Payroll Entry") - order by name limit %(start)s, %(page_len)s""".format( + order by name limit %(page_len)s offset %(start)s""".format( key=searchfield ), {"txt": "%%%s%%" % txt, "start": start, "page_len": page_len}, @@ -1039,7 +1039,7 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), idx desc, name, employee_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "key": searchfield, "fcond": get_filters_cond(doctype, filters, conditions), diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 8a8e1d1bf5..c613fe633d 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -391,7 +391,7 @@ def get_users_for_project(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, full_name), locate(%(_txt)s, full_name), 99999), idx desc, name, full_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "key": searchfield, "fcond": get_filters_cond(doctype, filters, conditions), diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index 4575fb544c..0e409fce9c 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -288,7 +288,7 @@ def get_project(doctype, txt, searchfield, start, page_len, filters): %(mcond)s {search_condition} order by name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( search_columns=search_columns, search_condition=search_cond ), { diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 2ef966b319..88d5beef17 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -328,7 +328,7 @@ def get_timesheet(doctype, txt, searchfield, start, page_len, filters): ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and tsd.docstatus = 1 and ts.total_billable_amount > 0 and tsd.parent LIKE %(txt)s {condition} - order by tsd.parent limit %(start)s, %(page_len)s""".format( + order by tsd.parent limit %(page_len)s offset %(start)s""".format( condition=condition ), { @@ -515,7 +515,7 @@ def get_timesheets_list( tsd.project IN %(projects)s ) ORDER BY `end_date` ASC - LIMIT {0}, {1} + LIMIT {1} offset {0} """.format( limit_start, limit_page_length ), diff --git a/erpnext/projects/utils.py b/erpnext/projects/utils.py index 000ea66275..3cc4da4f07 100644 --- a/erpnext/projects/utils.py +++ b/erpnext/projects/utils.py @@ -25,7 +25,7 @@ def query_task(doctype, txt, searchfield, start, page_len, filters): case when `%s` like %s then 0 else 1 end, `%s`, subject - limit %s, %s""" + limit %s offset %s""" % (searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"), - (search_string, search_string, order_by_string, order_by_string, start, page_len), + (search_string, search_string, order_by_string, order_by_string, page_len, start), ) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index 575b956686..ac83c0f046 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -78,7 +78,7 @@ def get_new_item_code(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name, item_name, description from tabItem where is_stock_item=0 and name not in (select name from `tabProduct Bundle`) - and %s like %s %s limit %s, %s""" + and %s like %s %s limit %s offset %s""" % (searchfield, "%s", get_match_cond(doctype), "%s", "%s"), - ("%%%s%%" % txt, start, page_len), + ("%%%s%%" % txt, page_len, start), ) diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 99afe813cb..13d5069ea6 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -107,7 +107,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te ORDER BY item.name asc LIMIT - {start}, {page_length}""".format( + {page_length} offset {start}""".format( start=start, page_length=page_length, lft=lft, @@ -204,7 +204,7 @@ def item_group_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """ select distinct name from `tabItem Group` - where {condition} and (name like %(txt)s) limit {start}, {page_len}""".format( + where {condition} and (name like %(txt)s) limit {page_len} offset {start}""".format( condition=cond, start=start, page_len=page_len ), {"txt": "%%%s%%" % txt}, diff --git a/erpnext/setup/doctype/party_type/party_type.py b/erpnext/setup/doctype/party_type/party_type.py index d07ab08450..cf7cba8452 100644 --- a/erpnext/setup/doctype/party_type/party_type.py +++ b/erpnext/setup/doctype/party_type/party_type.py @@ -21,7 +21,7 @@ def get_party_type(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name from `tabParty Type` where `{key}` LIKE %(txt)s {cond} - order by name limit %(start)s, %(page_len)s""".format( + order by name limit %(page_len)s offset %(start)s""".format( key=searchfield, cond=cond ), {"txt": "%" + txt + "%", "start": start, "page_len": page_len}, diff --git a/erpnext/stock/doctype/item_alternative/item_alternative.py b/erpnext/stock/doctype/item_alternative/item_alternative.py index 0f93bb9e95..fb1a28d846 100644 --- a/erpnext/stock/doctype/item_alternative/item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/item_alternative.py @@ -77,7 +77,7 @@ def get_alternative_items(doctype, txt, searchfield, start, page_len, filters): union (select item_code from `tabItem Alternative` where alternative_item_code = %(item_code)s and item_code like %(txt)s - and two_way = 1) limit {0}, {1} + and two_way = 1) limit {1} offset {0} """.format( start, page_len ), diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.py b/erpnext/stock/doctype/packing_slip/packing_slip.py index e9ccf5fc77..e5b9de8789 100644 --- a/erpnext/stock/doctype/packing_slip/packing_slip.py +++ b/erpnext/stock/doctype/packing_slip/packing_slip.py @@ -203,7 +203,7 @@ def item_details(doctype, txt, searchfield, start, page_len, filters): where name in ( select item_code FROM `tabDelivery Note Item` where parent= %s) and %s like "%s" %s - limit %s, %s """ + limit %s offset %s """ % ("%s", searchfield, "%s", get_match_cond(doctype), "%s", "%s"), - ((filters or {}).get("delivery_note"), "%%%s%%" % txt, start, page_len), + ((filters or {}).get("delivery_note"), "%%%s%%" % txt, page_len, start), ) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 331d3e812b..13abfad455 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -232,7 +232,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): FROM `tab{doc}` WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s {qi_condition} {cond} {mcond} - ORDER BY item_code limit {start}, {page_len} + ORDER BY item_code limit {page_len} offset {start} """.format( doc=filters.get("from"), cond=cond, @@ -252,7 +252,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): WHERE name = %(reference_name)s and docstatus < 2 and production_item like %(txt)s {qi_condition} {cond} {mcond} ORDER BY production_item - LIMIT {start}, {page_len} + limit {page_len} offset {start} """.format( doc=filters.get("from"), cond=cond, diff --git a/erpnext/templates/pages/product_search.py b/erpnext/templates/pages/product_search.py index 3ed056f55e..0768cc3fa6 100644 --- a/erpnext/templates/pages/product_search.py +++ b/erpnext/templates/pages/product_search.py @@ -56,7 +56,10 @@ def get_product_data(search=None, start=0, limit=12): search = "%" + cstr(search) + "%" # order by - query += """ ORDER BY ranking desc, modified desc limit %s, %s""" % (cint(start), cint(limit)) + query += """ ORDER BY ranking desc, modified desc limit %s offset %s""" % ( + cint(limit), + cint(start), + ) return frappe.db.sql(query, {"search": search}, as_dict=1) # nosemgrep From fb3da124e5ca97ab0194a1da21f40f8e4db30f90 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 14 Jun 2022 10:50:38 +0530 Subject: [PATCH 068/133] chore: linting issues --- 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 29d2530086..32fce1f2ad 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -25,7 +25,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 d05d15346a22de311cf16f437e80804207b4fe60 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 14 Jun 2022 12:50:49 +0530 Subject: [PATCH 069/133] fix: Conversion rate validation for multi-currency invoices --- .../doctype/purchase_invoice/purchase_invoice.py | 11 ----------- .../accounts/doctype/sales_invoice/sales_invoice.py | 1 + erpnext/controllers/accounts_controller.py | 11 +++++++++++ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 23ad223e77..4e0d1c966d 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -165,17 +165,6 @@ class PurchaseInvoice(BuyingController): super(PurchaseInvoice, self).set_missing_values(for_validate) - def check_conversion_rate(self): - default_currency = erpnext.get_company_currency(self.company) - if not default_currency: - throw(_("Please enter default currency in Company Master")) - if ( - (self.currency == default_currency and flt(self.conversion_rate) != 1.00) - or not self.conversion_rate - or (self.currency != default_currency and flt(self.conversion_rate) == 1.00) - ): - throw(_("Conversion rate cannot be 0 or 1")) - def validate_credit_to_acc(self): if not self.credit_to: self.credit_to = get_party_account("Supplier", self.supplier, self.company) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index a580d45acc..1a3164b0d9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -114,6 +114,7 @@ class SalesInvoice(SellingController): self.set_income_account_for_fixed_assets() self.validate_item_cost_centers() self.validate_income_account() + self.check_conversion_rate() validate_inter_company_party( self.doctype, self.customer, self.company, self.inter_company_invoice_reference diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 854c0d00f5..389d7cc983 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1838,6 +1838,17 @@ class AccountsController(TransactionBase): jv.save() jv.submit() + def check_conversion_rate(self): + default_currency = erpnext.get_company_currency(self.company) + if not default_currency: + throw(_("Please enter default currency in Company Master")) + if ( + (self.currency == default_currency and flt(self.conversion_rate) != 1.00) + or not self.conversion_rate + or (self.currency != default_currency and flt(self.conversion_rate) == 1.00) + ): + throw(_("Conversion rate cannot be 0 or 1")) + @frappe.whitelist() def get_tax_rate(account_head): From 9f2d325e67d3517480ff1580bd28e86f4ff4fc45 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 14 Jun 2022 17:20:44 +0530 Subject: [PATCH 070/133] fix: Pick Template BOM if variant BOM absent in WO popup from SO - Use `get_default_bom` in sales_order.py (reduce duplicate utility functions) - Remove redundant if else in `get_work_order_items` - `get_default_bom`: If no BOM and template exists try to fetch template BOM - test: `get_work_order_items` via SO and if right BOM is picked --- .../doctype/sales_order/sales_order.py | 48 ++++++----------- .../doctype/sales_order/test_sales_order.py | 53 +++++++++++++++++++ erpnext/stock/get_item_details.py | 20 +++++-- 3 files changed, 83 insertions(+), 38 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 7522e92a8a..8c03cb5b41 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -25,6 +25,7 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import ( from erpnext.selling.doctype.customer.customer import check_credit_limit from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.get_item_details import get_default_bom from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -423,8 +424,9 @@ class SalesOrder(SellingController): for table in [self.items, self.packed_items]: for i in table: - bom = get_default_bom_item(i.item_code) + bom = get_default_bom(i.item_code) stock_qty = i.qty if i.doctype == "Packed Item" else i.stock_qty + if not for_raw_material_request: total_work_order_qty = flt( frappe.db.sql( @@ -438,32 +440,19 @@ class SalesOrder(SellingController): pending_qty = stock_qty if pending_qty and i.item_code not in product_bundle_parents: - if bom: - items.append( - dict( - name=i.name, - item_code=i.item_code, - description=i.description, - bom=bom, - warehouse=i.warehouse, - pending_qty=pending_qty, - required_qty=pending_qty if for_raw_material_request else 0, - sales_order_item=i.name, - ) - ) - else: - items.append( - dict( - name=i.name, - item_code=i.item_code, - description=i.description, - bom="", - warehouse=i.warehouse, - pending_qty=pending_qty, - required_qty=pending_qty if for_raw_material_request else 0, - sales_order_item=i.name, - ) + items.append( + dict( + name=i.name, + item_code=i.item_code, + description=i.description, + bom=bom or "", + warehouse=i.warehouse, + pending_qty=pending_qty, + required_qty=pending_qty if for_raw_material_request else 0, + sales_order_item=i.name, ) + ) + return items def on_recurring(self, reference_doc, auto_repeat_doc): @@ -1167,13 +1156,6 @@ def update_status(status, name): so.update_status(status) -def get_default_bom_item(item_code): - bom = frappe.get_all("BOM", dict(item=item_code, is_active=True), order_by="is_default desc") - bom = bom[0].name if bom else None - - return bom - - @frappe.whitelist() def make_raw_material_request(items, company, sales_order, project=None): if not frappe.has_permission("Sales Order", "write"): diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 96308f0bee..dfb8e0b447 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1380,6 +1380,59 @@ class TestSalesOrder(FrappeTestCase): except Exception: self.fail("Can not cancel sales order with linked cancelled payment entry") + def test_work_order_pop_up_from_sales_order(self): + "Test `get_work_order_items` in Sales Order picks the right BOM for items to manufacture." + + from erpnext.controllers.item_variant import create_variant + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + make_item( # template item + "Test-WO-Tshirt", + { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [{"attribute": "Test Colour"}], + }, + ) + make_item("Test-RM-Cotton") # RM for BOM + + for colour in ( + "Red", + "Green", + ): + variant = create_variant("Test-WO-Tshirt", {"Test Colour": colour}) + variant.save() + + template_bom = make_bom(item="Test-WO-Tshirt", rate=100, raw_materials=["Test-RM-Cotton"]) + red_var_bom = make_bom(item="Test-WO-Tshirt-R", rate=100, raw_materials=["Test-RM-Cotton"]) + + so = make_sales_order( + **{ + "item_list": [ + { + "item_code": "Test-WO-Tshirt-R", + "qty": 1, + "rate": 1000, + "warehouse": "_Test Warehouse - _TC", + }, + { + "item_code": "Test-WO-Tshirt-G", + "qty": 1, + "rate": 1000, + "warehouse": "_Test Warehouse - _TC", + }, + ] + } + ) + wo_items = so.get_work_order_items() + + self.assertEqual(wo_items[0].get("item_code"), "Test-WO-Tshirt-R") + self.assertEqual(wo_items[0].get("bom"), red_var_bom.name) + + # Must pick Template Item BOM for Test-WO-Tshirt-G as it has no BOM + self.assertEqual(wo_items[1].get("item_code"), "Test-WO-Tshirt-G") + self.assertEqual(wo_items[1].get("bom"), template_bom.name) + def test_request_for_raw_materials(self): item = make_item( "_Test Finished Item", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index c8d9f5404f..3776a27b35 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1352,12 +1352,22 @@ def get_price_list_currency_and_exchange_rate(args): @frappe.whitelist() def get_default_bom(item_code=None): - if item_code: - bom = frappe.db.get_value( - "BOM", {"docstatus": 1, "is_default": 1, "is_active": 1, "item": item_code} + def _get_bom(item): + bom = frappe.get_all( + "BOM", dict(item=item, is_active=True, is_default=True, docstatus=1), limit=1 ) - if bom: - return bom + return bom[0].name if bom else None + + if not item_code: + return + + bom_name = _get_bom(item_code) + + template_item = frappe.db.get_value("Item", item_code, "variant_of") + if not bom_name and template_item: + bom_name = _get_bom(template_item) + + return bom_name @frappe.whitelist() From 2535d5edd0658844a74177da3a3a4bb6abe3f1a2 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 14 Jun 2022 18:20:33 +0530 Subject: [PATCH 071/133] perf: GLE reposting with progress and chunking (#31343) If stock voucher count goes >1000 then fetching all gles and reposting them all at once requires much more memory and can cause crash. - This PR ensures that GLE reposting is done in chunks of 100 vouchers. - This PR also starts keeping track of how many such chunks were processed so in future progress is resumed in event of timeout. --- erpnext/accounts/test/test_utils.py | 2 +- erpnext/accounts/utils.py | 84 ++++++++++++------- .../repost_item_valuation.json | 13 ++- .../repost_item_valuation.py | 1 + .../test_repost_item_valuation.py | 32 +++++++ 5 files changed, 99 insertions(+), 33 deletions(-) diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py index 77c40bae2d..882cd694a3 100644 --- a/erpnext/accounts/test/test_utils.py +++ b/erpnext/accounts/test/test_utils.py @@ -62,8 +62,8 @@ class TestUtils(unittest.TestCase): stock_entry = {"item": item, "to_warehouse": "_Test Warehouse - _TC", "qty": 1, "rate": 10} se1 = make_stock_entry(posting_date="2022-01-01", **stock_entry) - se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry) se3 = make_stock_entry(posting_date="2022-03-01", **stock_entry) + se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry) for doc in (se1, se2, se3): vouchers.append((doc.doctype, doc.name)) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 65e05410aa..2d86dead2f 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2,8 +2,9 @@ # License: GNU General Public License v3. See license.txt +import itertools from json import loads -from typing import List, Tuple +from typing import TYPE_CHECKING, List, Optional, Tuple import frappe import frappe.defaults @@ -22,6 +23,9 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g from erpnext.stock import get_warehouse_account_map from erpnext.stock.utils import get_stock_value_on +if TYPE_CHECKING: + from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import RepostItemValuation + class FiscalYearError(frappe.ValidationError): pass @@ -31,6 +35,9 @@ class PaymentEntryUnlinkError(frappe.ValidationError): pass +GL_REPOSTING_CHUNK = 100 + + @frappe.whitelist() def get_fiscal_year( date=None, fiscal_year=None, label="Date", verbose=1, company=None, as_dict=False @@ -1120,7 +1127,11 @@ def update_gl_entries_after( def repost_gle_for_stock_vouchers( - stock_vouchers, posting_date, company=None, warehouse_account=None + stock_vouchers: List[Tuple[str, str]], + posting_date: str, + company: Optional[str] = None, + warehouse_account=None, + repost_doc: Optional["RepostItemValuation"] = None, ): from erpnext.accounts.general_ledger import toggle_debit_credit_if_negative @@ -1128,40 +1139,50 @@ def repost_gle_for_stock_vouchers( if not stock_vouchers: return - def _delete_gl_entries(voucher_type, voucher_no): - frappe.db.sql( - """delete from `tabGL Entry` - where voucher_type=%s and voucher_no=%s""", - (voucher_type, voucher_no), - ) - - stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers) - if not warehouse_account: warehouse_account = get_warehouse_account_map(company) + stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers) + if repost_doc and repost_doc.gl_reposting_index: + # Restore progress + stock_vouchers = stock_vouchers[cint(repost_doc.gl_reposting_index) :] + precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2 - gle = get_voucherwise_gl_entries(stock_vouchers, posting_date) - for idx, (voucher_type, voucher_no) in enumerate(stock_vouchers): - existing_gle = gle.get((voucher_type, voucher_no), []) - voucher_obj = frappe.get_doc(voucher_type, voucher_no) - # Some transactions post credit as negative debit, this is handled while posting GLE - # but while comparing we need to make sure it's flipped so comparisons are accurate - expected_gle = toggle_debit_credit_if_negative(voucher_obj.get_gl_entries(warehouse_account)) - if expected_gle: - if not existing_gle or not compare_existing_and_expected_gle( - existing_gle, expected_gle, precision - ): - _delete_gl_entries(voucher_type, voucher_no) - voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True) - else: - _delete_gl_entries(voucher_type, voucher_no) + stock_vouchers_iterator = iter(stock_vouchers) - if idx % 20 == 0: - # Commit every 20 documents to avoid losing progress - # and reducing memory usage - frappe.db.commit() + while stock_vouchers_chunk := list(itertools.islice(stock_vouchers_iterator, GL_REPOSTING_CHUNK)): + gle = get_voucherwise_gl_entries(stock_vouchers_chunk, posting_date) + + for voucher_type, voucher_no in stock_vouchers_chunk: + existing_gle = gle.get((voucher_type, voucher_no), []) + voucher_obj = frappe.get_doc(voucher_type, voucher_no) + # Some transactions post credit as negative debit, this is handled while posting GLE + # but while comparing we need to make sure it's flipped so comparisons are accurate + expected_gle = toggle_debit_credit_if_negative(voucher_obj.get_gl_entries(warehouse_account)) + if expected_gle: + if not existing_gle or not compare_existing_and_expected_gle( + existing_gle, expected_gle, precision + ): + _delete_gl_entries(voucher_type, voucher_no) + voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True) + else: + _delete_gl_entries(voucher_type, voucher_no) + frappe.db.commit() + + if repost_doc: + repost_doc.db_set( + "gl_reposting_index", + cint(repost_doc.gl_reposting_index) + GL_REPOSTING_CHUNK, + ) + + +def _delete_gl_entries(voucher_type, voucher_no): + frappe.db.sql( + """delete from `tabGL Entry` + where voucher_type=%s and voucher_no=%s""", + (voucher_type, voucher_no), + ) def sort_stock_vouchers_by_posting_date( @@ -1175,6 +1196,9 @@ def sort_stock_vouchers_by_posting_date( .select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation) .where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos))) .groupby(sle.voucher_type, sle.voucher_no) + .orderby(sle.posting_date) + .orderby(sle.posting_time) + .orderby(sle.creation) ).run(as_dict=True) sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles] diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json index 156f77f5ac..e093933829 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json @@ -25,7 +25,8 @@ "items_to_be_repost", "affected_transactions", "distinct_item_and_warehouse", - "current_index" + "current_index", + "gl_reposting_index" ], "fields": [ { @@ -181,12 +182,20 @@ "label": "Affected Transactions", "no_copy": 1, "read_only": 1 + }, + { + "default": "0", + "fieldname": "gl_reposting_index", + "fieldtype": "Int", + "hidden": 1, + "label": "GL reposting index", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-04-18 14:08:08.821602", + "modified": "2022-06-13 12:20:22.182322", "modified_by": "Administrator", "module": "Stock", "name": "Repost Item Valuation", diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 328afc86be..ea24b47a29 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -192,6 +192,7 @@ def repost_gl_entries(doc): directly_dependent_transactions + list(repost_affected_transaction), doc.posting_date, doc.company, + repost_doc=doc, ) diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 3184f69aa4..3c74619b46 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -2,10 +2,14 @@ # See license.txt +from unittest.mock import MagicMock, call + import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate +from frappe.utils.data import today +from erpnext.accounts.utils import repost_gle_for_stock_vouchers from erpnext.controllers.stock_controller import create_item_wise_repost_entries from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -193,3 +197,31 @@ class TestRepostItemValuation(FrappeTestCase): [["a", "b"], ["c", "d"]], sorted(frappe.parse_json(frappe.as_json(set([("a", "b"), ("c", "d")])))), ) + + def test_gl_repost_progress(self): + from erpnext.accounts import utils + + # lower numbers to simplify test + orig_chunk_size = utils.GL_REPOSTING_CHUNK + utils.GL_REPOSTING_CHUNK = 1 + self.addCleanup(setattr, utils, "GL_REPOSTING_CHUNK", orig_chunk_size) + + doc = frappe.new_doc("Repost Item Valuation") + doc.db_set = MagicMock() + + vouchers = [] + company = "_Test Company with perpetual inventory" + posting_date = today() + + for _ in range(3): + se = make_stock_entry(company=company, qty=1, rate=2, target="Stores - TCP1") + vouchers.append((se.doctype, se.name)) + + repost_gle_for_stock_vouchers(stock_vouchers=vouchers, posting_date=posting_date, repost_doc=doc) + self.assertIn(call("gl_reposting_index", 1), doc.db_set.mock_calls) + doc.db_set.reset_mock() + + doc.gl_reposting_index = 1 + repost_gle_for_stock_vouchers(stock_vouchers=vouchers, posting_date=posting_date, repost_doc=doc) + + self.assertNotIn(call("gl_reposting_index", 1), doc.db_set.mock_calls) From 2a9105f26f4720a486ae54e7e777123e3fd345a8 Mon Sep 17 00:00:00 2001 From: Conor Date: Wed, 15 Jun 2022 00:54:24 -0500 Subject: [PATCH 072/133] refactor: DB independent capitalization of test cases (#31359) --- .../employee_advance/test_employee_advance.py | 2 +- .../test_landed_cost_voucher.py | 6 +++--- .../purchase_receipt/test_purchase_receipt.py | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/erpnext/hr/doctype/employee_advance/test_employee_advance.py b/erpnext/hr/doctype/employee_advance/test_employee_advance.py index 44d68c9483..81a0876a2b 100644 --- a/erpnext/hr/doctype/employee_advance/test_employee_advance.py +++ b/erpnext/hr/doctype/employee_advance/test_employee_advance.py @@ -216,7 +216,7 @@ def make_payment_entry(advance): def make_employee_advance(employee_name, args=None): doc = frappe.new_doc("Employee Advance") doc.employee = employee_name - doc.company = "_Test company" + doc.company = "_Test Company" doc.purpose = "For site visit" doc.currency = erpnext.get_company_currency("_Test company") doc.exchange_rate = 1 diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 1af9953451..1ba801134e 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -24,7 +24,7 @@ class TestLandedCostVoucher(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", get_multiple_items=True, get_taxes_and_charges=True, ) @@ -195,7 +195,7 @@ class TestLandedCostVoucher(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", get_multiple_items=True, get_taxes_and_charges=True, do_not_submit=True, @@ -280,7 +280,7 @@ class TestLandedCostVoucher(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", do_not_save=True, ) pr.items[0].cost_center = "Main - TCP1" diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 7fbfa62939..be4f27465e 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -276,7 +276,7 @@ class TestPurchaseReceipt(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", get_multiple_items=True, get_taxes_and_charges=True, ) @@ -486,13 +486,13 @@ class TestPurchaseReceipt(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", ) return_pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", is_return=1, return_against=pr.name, qty=-2, @@ -573,13 +573,13 @@ class TestPurchaseReceipt(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", ) return_pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", is_return=1, return_against=pr.name, qty=-5, @@ -615,7 +615,7 @@ class TestPurchaseReceipt(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", qty=2, rejected_qty=2, rejected_warehouse=rejected_warehouse, @@ -624,7 +624,7 @@ class TestPurchaseReceipt(FrappeTestCase): return_pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", is_return=1, return_against=pr.name, qty=-2, @@ -951,7 +951,7 @@ class TestPurchaseReceipt(FrappeTestCase): cost_center=cost_center, company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", ) stock_in_hand_account = get_inventory_account(pr.company, pr.get("items")[0].warehouse) @@ -975,7 +975,7 @@ class TestPurchaseReceipt(FrappeTestCase): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", - supplier_warehouse="Work in Progress - TCP1", + supplier_warehouse="Work In Progress - TCP1", ) stock_in_hand_account = get_inventory_account(pr.company, pr.get("items")[0].warehouse) From 37e9622426a3996eb8e09da82bd9eb05575c22fb Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <99652762+nihantra@users.noreply.github.com> Date: Wed, 15 Jun 2022 12:02:57 +0530 Subject: [PATCH 073/133] fix: Spelling mistake in quotation depend on (#31362) Update quotation.json --- erpnext/selling/doctype/quotation/quotation.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index 5dfd8f2965..bb2f95dd17 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -296,7 +296,7 @@ "read_only": 1 }, { - "depends_on": "eval:doc.quotaion_to=='Customer' && doc.party_name", + "depends_on": "eval:doc.quotation_to=='Customer' && doc.party_name", "fieldname": "col_break98", "fieldtype": "Column Break", "width": "50%" @@ -316,7 +316,7 @@ "read_only": 1 }, { - "depends_on": "eval:doc.quotaion_to=='Customer' && doc.party_name", + "depends_on": "eval:doc.quotation_to=='Customer' && doc.party_name", "fieldname": "customer_group", "fieldtype": "Link", "hidden": 1, @@ -1084,4 +1084,4 @@ "states": [], "timeline_field": "party_name", "title_field": "title" -} \ No newline at end of file +} From b8f728a40aaea8b7e86b78b2e8a8cbecb8cb8775 Mon Sep 17 00:00:00 2001 From: Conor Date: Wed, 15 Jun 2022 01:37:33 -0500 Subject: [PATCH 074/133] refactor: use CURRENT_DATE instead of CURDATE() (#31356) * refactor: use CURRENT_DATE instead of CURDATE() * style: reformat to black spec * refactor: use QB for auto_close queries Co-authored-by: Ankush Menat --- .../doctype/payment_entry/payment_entry.py | 2 +- .../inactive_sales_items.py | 2 +- .../purchase_order/test_purchase_order.py | 2 +- erpnext/controllers/queries.py | 2 +- .../crm/doctype/opportunity/opportunity.py | 19 +++++++++++-------- .../doctype/project_update/project_update.py | 4 ++-- .../doctype/sales_order/test_sales_order.py | 2 +- .../inactive_customers/inactive_customers.py | 4 ++-- .../sales_order_analysis.py | 2 +- .../doctype/email_digest/email_digest.py | 4 ++-- erpnext/stock/doctype/batch/batch.py | 2 +- erpnext/support/doctype/issue/issue.py | 18 +++++++++++------- 12 files changed, 35 insertions(+), 28 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index a10a810d1d..f7a57bb96e 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1444,7 +1444,7 @@ def get_negative_outstanding_invoices( voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" supplier_condition = "" if voucher_type == "Purchase Invoice": - supplier_condition = "and (release_date is null or release_date <= CURDATE())" + supplier_condition = "and (release_date is null or release_date <= CURRENT_DATE)" if party_account_currency == company_currency: grand_total_field = "base_grand_total" rounded_total_field = "base_rounded_total" diff --git a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py index 1a003993aa..230b18c293 100644 --- a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py +++ b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py @@ -100,7 +100,7 @@ def get_sales_details(filters): sales_data = frappe.db.sql( """ select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date, - DATEDIFF(CURDATE(), {date_field}) as days_since_last_order + DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order from `tab{doctype}` s, `tab{doctype} Item` si where s.name = si.parent and s.docstatus = 1 order by days_since_last_order """.format( # nosec diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 1a7f2dd5d9..d732b755fe 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -330,7 +330,7 @@ class TestPurchaseOrder(FrappeTestCase): else: # update valid from frappe.db.sql( - """UPDATE `tabItem Tax` set valid_from = CURDATE() + """UPDATE `tabItem Tax` set valid_from = CURRENT_DATE where parent = %(item)s and item_tax_template = %(tax)s""", {"item": item, "tax": tax_template}, ) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 1497b182e5..a725f674c9 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -691,7 +691,7 @@ def get_doctype_wise_filters(filters): def get_batch_numbers(doctype, txt, searchfield, start, page_len, filters): query = """select batch_id from `tabBatch` where disabled = 0 - and (expiry_date >= CURDATE() or expiry_date IS NULL) + and (expiry_date >= CURRENT_DATE or expiry_date IS NULL) and name like {txt}""".format( txt=frappe.db.escape("%{0}%".format(txt)) ) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index b590177562..c70a4f61b8 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -8,7 +8,8 @@ import frappe from frappe import _ from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc -from frappe.query_builder import DocType +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now from frappe.utils import cint, flt, get_fullname from erpnext.crm.utils import add_link_in_communication, copy_comments @@ -398,15 +399,17 @@ def auto_close_opportunity(): frappe.db.get_single_value("CRM Settings", "close_opportunity_after_days") or 15 ) - opportunities = frappe.db.sql( - """ select name from tabOpportunity where status='Replied' and - modifiedProject Name: " diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 96308f0bee..9e5d40b5a8 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -644,7 +644,7 @@ class TestSalesOrder(FrappeTestCase): else: # update valid from frappe.db.sql( - """UPDATE `tabItem Tax` set valid_from = CURDATE() + """UPDATE `tabItem Tax` set valid_from = CURRENT_DATE where parent = %(item)s and item_tax_template = %(tax)s""", {"item": item, "tax": tax_template}, ) diff --git a/erpnext/selling/report/inactive_customers/inactive_customers.py b/erpnext/selling/report/inactive_customers/inactive_customers.py index 1b337fc495..a166085327 100644 --- a/erpnext/selling/report/inactive_customers/inactive_customers.py +++ b/erpnext/selling/report/inactive_customers/inactive_customers.py @@ -31,13 +31,13 @@ def execute(filters=None): def get_sales_details(doctype): cond = """sum(so.base_net_total) as 'total_order_considered', max(so.posting_date) as 'last_order_date', - DATEDIFF(CURDATE(), max(so.posting_date)) as 'days_since_last_order' """ + DATEDIFF(CURRENT_DATE, max(so.posting_date)) as 'days_since_last_order' """ if doctype == "Sales Order": cond = """sum(if(so.status = "Stopped", so.base_net_total * so.per_delivered/100, so.base_net_total)) as 'total_order_considered', max(so.transaction_date) as 'last_order_date', - DATEDIFF(CURDATE(), max(so.transaction_date)) as 'days_since_last_order'""" + DATEDIFF(CURRENT_DATE, max(so.transaction_date)) as 'days_since_last_order'""" return frappe.db.sql( """select diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py index cc61594af4..720aa41982 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -64,7 +64,7 @@ def get_data(conditions, filters): soi.delivery_date as delivery_date, so.name as sales_order, so.status, so.customer, soi.item_code, - DATEDIFF(CURDATE(), soi.delivery_date) as delay_days, + DATEDIFF(CURRENT_DATE, soi.delivery_date) as delay_days, IF(so.status in ('Completed','To Bill'), 0, (SELECT delay_days)) as delay, soi.qty, soi.delivered_qty, (soi.qty - soi.delivered_qty) AS pending_qty, diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index cdfea7764f..42ba6ce394 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -854,7 +854,7 @@ class EmailDigest(Document): sql_po = """select {fields} from `tabPurchase Order Item` left join `tabPurchase Order` on `tabPurchase Order`.name = `tabPurchase Order Item`.parent - where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and curdate() > `tabPurchase Order Item`.schedule_date + where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and CURRENT_DATE > `tabPurchase Order Item`.schedule_date and received_qty < qty order by `tabPurchase Order Item`.parent DESC, `tabPurchase Order Item`.schedule_date DESC""".format( fields=fields_po @@ -862,7 +862,7 @@ class EmailDigest(Document): sql_poi = """select {fields} from `tabPurchase Order Item` left join `tabPurchase Order` on `tabPurchase Order`.name = `tabPurchase Order Item`.parent - where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and curdate() > `tabPurchase Order Item`.schedule_date + where status<>'Closed' and `tabPurchase Order Item`.docstatus=1 and CURRENT_DATE > `tabPurchase Order Item`.schedule_date and received_qty < qty order by `tabPurchase Order Item`.idx""".format( fields=fields_poi ) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 559883f224..52854a0f01 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -335,7 +335,7 @@ def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None): on (`tabBatch`.batch_id = `tabStock Ledger Entry`.batch_no ) where `tabStock Ledger Entry`.item_code = %s and `tabStock Ledger Entry`.warehouse = %s and `tabStock Ledger Entry`.is_cancelled = 0 - and (`tabBatch`.expiry_date >= CURDATE() or `tabBatch`.expiry_date IS NULL) {0} + and (`tabBatch`.expiry_date >= CURRENT_DATE or `tabBatch`.expiry_date IS NULL) {0} group by batch_id order by `tabBatch`.expiry_date ASC, `tabBatch`.creation ASC """.format( diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index 08a06b19b4..7f3e0cf4c2 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -11,6 +11,8 @@ from frappe.core.utils import get_parent_doc from frappe.email.inbox import link_communication_to_document from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Interval +from frappe.query_builder.functions import Now from frappe.utils import date_diff, get_datetime, now_datetime, time_diff_in_seconds from frappe.utils.user import is_website_user @@ -190,15 +192,17 @@ def auto_close_tickets(): frappe.db.get_value("Support Settings", "Support Settings", "close_issue_after_days") or 7 ) - issues = frappe.db.sql( - """ select name from tabIssue where status='Replied' and - modified Date: Wed, 15 Jun 2022 13:17:06 +0530 Subject: [PATCH 075/133] chore: add gl to payment ledger migarion to patches --- erpnext/patches.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5a984635fd..5b59161609 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -374,3 +374,4 @@ erpnext.patches.v13_0.set_per_billed_in_return_delivery_note execute:frappe.delete_doc("DocType", "Naming Series") erpnext.patches.v13_0.set_payroll_entry_status erpnext.patches.v13_0.job_card_status_on_hold +erpnext.patches.v14_0.migrate_gl_to_payment_ledger From 94ad66e55b9741d2a793fecb7c8dfad2ac971c41 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 15 Jun 2022 13:35:42 +0530 Subject: [PATCH 076/133] chore: revert naming to default (#31364) --- .../doctype/bom_update_batch/bom_update_batch.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 83b54d326c..b867d2aa5d 100644 --- a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json +++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json @@ -1,6 +1,6 @@ { "actions": [], - "autoname": "autoincrement", + "autoname": "hash", "creation": "2022-05-31 17:34:39.825537", "doctype": "DocType", "engine": "InnoDB", @@ -46,10 +46,9 @@ "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 +} From 276267d5a6dbb0a183ef8eb9612476c245bd637a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 15 Jun 2022 15:26:05 +0530 Subject: [PATCH 077/133] fix: remove agriculture module from patch (#31369) --- erpnext/patches.txt | 2 +- erpnext/patches/v14_0/delete_agriculture_doctypes.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5b59161609..318875d2a4 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -339,7 +339,7 @@ erpnext.patches.v14_0.delete_shopify_doctypes erpnext.patches.v14_0.delete_healthcare_doctypes erpnext.patches.v14_0.delete_hub_doctypes erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022 -erpnext.patches.v14_0.delete_agriculture_doctypes +erpnext.patches.v14_0.delete_agriculture_doctypes # 15-06-2022 erpnext.patches.v14_0.delete_education_doctypes erpnext.patches.v14_0.delete_datev_doctypes erpnext.patches.v14_0.rearrange_company_fields diff --git a/erpnext/patches/v14_0/delete_agriculture_doctypes.py b/erpnext/patches/v14_0/delete_agriculture_doctypes.py index e0b12a2579..8ec0c33090 100644 --- a/erpnext/patches/v14_0/delete_agriculture_doctypes.py +++ b/erpnext/patches/v14_0/delete_agriculture_doctypes.py @@ -2,6 +2,9 @@ import frappe def execute(): + if "agriculture" in frappe.get_installed_apps(): + return + frappe.delete_doc("Module Def", "Agriculture", ignore_missing=True, force=True) frappe.delete_doc("Workspace", "Agriculture", ignore_missing=True, force=True) @@ -19,3 +22,5 @@ def execute(): doctypes = frappe.get_all("DocType", {"module": "agriculture", "custom": 0}, pluck="name") for doctype in doctypes: frappe.delete_doc("DocType", doctype, ignore_missing=True) + + frappe.delete_doc("Module Def", "Agriculture", ignore_missing=True, force=True) From c0f9b34ede50c8df4c634abf2b58a37d8e2ffdbb Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Wed, 15 Jun 2022 16:08:05 +0530 Subject: [PATCH 078/133] fix(minor): move variants to separate tab (#31354) * fix(minor): move variants to separate tab * fix(minor): variants tab * fix(minor): add counts Co-authored-by: Deepesh Garg Co-authored-by: Ankush Menat --- .../module_onboarding/accounts/accounts.json | 2 +- .../setup_taxes/setup_taxes.json | 4 +- .../manufacturing/manufacturing.json | 64 +++++++------------ erpnext/stock/doctype/item/item.json | 13 ++-- 4 files changed, 34 insertions(+), 49 deletions(-) diff --git a/erpnext/accounts/module_onboarding/accounts/accounts.json b/erpnext/accounts/module_onboarding/accounts/accounts.json index b9040e3309..9916d1622d 100644 --- a/erpnext/accounts/module_onboarding/accounts/accounts.json +++ b/erpnext/accounts/module_onboarding/accounts/accounts.json @@ -13,7 +13,7 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/accounts", "idx": 0, "is_complete": 0, - "modified": "2022-06-07 14:29:21.352132", + "modified": "2022-06-14 17:38:24.967834", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts", diff --git a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json index b6e9f5cd87..e323f6cb1a 100644 --- a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json +++ b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json @@ -2,14 +2,14 @@ "action": "Create Entry", "action_label": "Manage Sales Tax Templates", "creation": "2020-05-13 19:29:43.844463", - "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n\n[Checkout pre-configured taxes](/app/sales-taxes-and-charges-template)\n", + "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2022-06-07 14:27:15.906286", + "modified": "2022-06-14 17:37:56.694261", "modified_by": "Administrator", "name": "Setup Taxes", "owner": "Administrator", diff --git a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json index 9829a96e09..549f5afc70 100644 --- a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json +++ b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Work Order\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Plan\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Forecasting\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Work Order Summary\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM Stock Report\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Planning Report\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Production\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Bill of Materials\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]", + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Plan\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Work Order\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Job Card\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Forecasting\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"BOM Stock Report\",\"col\":3}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Production Planning Report\",\"col\":3}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Production\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Bill of Materials\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]", "creation": "2020-03-02 17:11:37.032604", "docstatus": 0, "doctype": "Workspace", @@ -402,7 +402,7 @@ "type": "Link" } ], - "modified": "2022-05-31 22:08:19.408223", + "modified": "2022-06-15 15:18:57.062935", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing", @@ -415,39 +415,35 @@ "sequence_id": 17.0, "shortcuts": [ { - "color": "Green", - "format": "{} Active", - "label": "Item", - "link_to": "Item", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{\n \"disabled\": 0\n}", - "type": "DocType" - }, - { - "color": "Green", - "format": "{} Active", + "color": "Grey", + "doc_view": "List", "label": "BOM", "link_to": "BOM", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{\n \"is_active\": 1\n}", + "stats_filter": "{\"is_active\":[\"=\",1]}", "type": "DocType" }, { - "color": "Yellow", - "format": "{} Open", - "label": "Work Order", - "link_to": "Work Order", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{ \n \"status\": [\"in\", \n [\"Draft\", \"Not Started\", \"In Process\"]\n ]\n}", - "type": "DocType" - }, - { - "color": "Yellow", - "format": "{} Open", + "color": "Grey", + "doc_view": "List", "label": "Production Plan", "link_to": "Production Plan", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{ \n \"status\": [\"not in\", [\"Completed\"]]\n}", + "stats_filter": "{\"status\":[\"not in\",[\"Closed\",\"Cancelled\",\"Completed\"]]}", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Work Order", + "link_to": "Work Order", + "stats_filter": "{\"status\":[\"not in\",[\"Closed\",\"Cancelled\",\"Completed\"]]}", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Job Card", + "link_to": "Job Card", + "stats_filter": "{\"status\":[\"not in\",[\"Cancelled\",\"Completed\",null]]}", "type": "DocType" }, { @@ -455,12 +451,6 @@ "link_to": "Exponential Smoothing Forecasting", "type": "Report" }, - { - "label": "Work Order Summary", - "link_to": "Work Order Summary", - "restrict_to_domain": "Manufacturing", - "type": "Report" - }, { "label": "BOM Stock Report", "link_to": "BOM Stock Report", @@ -470,12 +460,6 @@ "label": "Production Planning Report", "link_to": "Production Planning Report", "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Manufacturing", - "restrict_to_domain": "Manufacturing", - "type": "Dashboard" } ], "title": "Manufacturing" diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 2f6d4fb783..76cb31dc42 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -14,7 +14,6 @@ "details", "naming_series", "item_code", - "variant_of", "item_name", "item_group", "stock_uom", @@ -22,6 +21,7 @@ "disabled", "allow_alternative_item", "is_stock_item", + "has_variants", "include_item_in_manufacturing", "opening_stock", "valuation_rate", @@ -66,7 +66,7 @@ "has_serial_no", "serial_no_series", "variants_section", - "has_variants", + "variant_of", "variant_based_on", "attributes", "accounting", @@ -112,8 +112,8 @@ "quality_inspection_template", "inspection_required_before_delivery", "manufacturing", - "default_bom", "is_sub_contracted_item", + "default_bom", "column_break_74", "customer_code", "default_item_manufacturer", @@ -479,7 +479,7 @@ "collapsible_depends_on": "attributes", "depends_on": "eval:!doc.is_fixed_asset", "fieldname": "variants_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Variants" }, { @@ -504,7 +504,8 @@ "fieldname": "attributes", "fieldtype": "Table", "hidden": 1, - "label": "Attributes", + "label": "Variant Attributes", + "mandatory_depends_on": "has_variants", "options": "Item Variant Attribute" }, { @@ -909,7 +910,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-06-08 11:35:20.094546", + "modified": "2022-06-15 09:02:06.177691", "modified_by": "Administrator", "module": "Stock", "name": "Item", From d9c6b7218a11025f9ca61c000fc8d0f9e9eaf60b Mon Sep 17 00:00:00 2001 From: Marica Date: Wed, 15 Jun 2022 18:57:39 +0530 Subject: [PATCH 079/133] chore: Sponsor credit for BOM Update Tool perf --- sponsors.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sponsors.md b/sponsors.md index 125b3588e2..57adc8dad4 100644 --- a/sponsors.md +++ b/sponsors.md @@ -61,5 +61,13 @@ Bulk edit via export-import in Bank Reconciliation #4356 + + + Sapcon Instruments Pvt Ltd + + + Level wise BOM Cost Updation and Performance Enhancement #31072 + + From 5c6f22f27553bc53950c59f64947ae5da908d667 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 15 Jun 2022 19:30:26 +0530 Subject: [PATCH 080/133] refactor: simpler batching for GLE reposting (#31374) * refactor: simpler batching for GLE reposting * test: add "actual" test for chunked GLE reposting --- erpnext/accounts/utils.py | 19 ++++--- .../repost_item_valuation.py | 1 + .../test_repost_item_valuation.py | 51 ++++++++++++++++++- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 2d86dead2f..f824a00743 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2,7 +2,6 @@ # License: GNU General Public License v3. See license.txt -import itertools from json import loads from typing import TYPE_CHECKING, List, Optional, Tuple @@ -11,7 +10,17 @@ import frappe.defaults from frappe import _, qb, throw from frappe.model.meta import get_field_precision from frappe.query_builder.utils import DocType -from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate +from frappe.utils import ( + cint, + create_batch, + cstr, + flt, + formatdate, + get_number_format_info, + getdate, + now, + nowdate, +) from pypika import Order from pypika.terms import ExistsCriterion @@ -1149,9 +1158,7 @@ def repost_gle_for_stock_vouchers( precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2 - stock_vouchers_iterator = iter(stock_vouchers) - - while stock_vouchers_chunk := list(itertools.islice(stock_vouchers_iterator, GL_REPOSTING_CHUNK)): + for stock_vouchers_chunk in create_batch(stock_vouchers, GL_REPOSTING_CHUNK): gle = get_voucherwise_gl_entries(stock_vouchers_chunk, posting_date) for voucher_type, voucher_no in stock_vouchers_chunk: @@ -1173,7 +1180,7 @@ def repost_gle_for_stock_vouchers( if repost_doc: repost_doc.db_set( "gl_reposting_index", - cint(repost_doc.gl_reposting_index) + GL_REPOSTING_CHUNK, + cint(repost_doc.gl_reposting_index) + len(stock_vouchers_chunk), ) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index ea24b47a29..b1017d2c9c 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -87,6 +87,7 @@ class RepostItemValuation(Document): self.current_index = 0 self.distinct_item_and_warehouse = None self.items_to_be_repost = None + self.gl_reposting_index = 0 self.db_update() def deduplicate_similar_repost(self): diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 3c74619b46..edd2553d5d 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate -from frappe.utils.data import today +from frappe.utils.data import add_to_date, today from erpnext.accounts.utils import repost_gle_for_stock_vouchers from erpnext.controllers.stock_controller import create_item_wise_repost_entries @@ -17,10 +17,11 @@ from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import ( in_configured_timeslot, ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.tests.test_utils import StockTestMixin from erpnext.stock.utils import PendingRepostingError -class TestRepostItemValuation(FrappeTestCase): +class TestRepostItemValuation(FrappeTestCase, StockTestMixin): def tearDown(self): frappe.flags.dont_execute_stock_reposts = False @@ -225,3 +226,49 @@ class TestRepostItemValuation(FrappeTestCase): repost_gle_for_stock_vouchers(stock_vouchers=vouchers, posting_date=posting_date, repost_doc=doc) self.assertNotIn(call("gl_reposting_index", 1), doc.db_set.mock_calls) + + def test_gl_complete_gl_reposting(self): + from erpnext.accounts import utils + + # lower numbers to simplify test + orig_chunk_size = utils.GL_REPOSTING_CHUNK + utils.GL_REPOSTING_CHUNK = 2 + self.addCleanup(setattr, utils, "GL_REPOSTING_CHUNK", orig_chunk_size) + + item = self.make_item().name + + company = "_Test Company with perpetual inventory" + + for _ in range(10): + make_stock_entry(item=item, company=company, qty=1, rate=10, target="Stores - TCP1") + + # consume + consumption = make_stock_entry(item=item, company=company, qty=1, source="Stores - TCP1") + + self.assertGLEs( + consumption, + [{"credit": 10, "debit": 0}], + gle_filters={"account": "Stock In Hand - TCP1"}, + ) + + # backdated receipt + backdated_receipt = make_stock_entry( + item=item, + company=company, + qty=1, + rate=50, + target="Stores - TCP1", + posting_date=add_to_date(today(), days=-1), + ) + self.assertGLEs( + backdated_receipt, + [{"credit": 0, "debit": 50}], + gle_filters={"account": "Stock In Hand - TCP1"}, + ) + + # check that original consumption GLe is updated + self.assertGLEs( + consumption, + [{"credit": 50, "debit": 0}], + gle_filters={"account": "Stock In Hand - TCP1"}, + ) From 86919d2a6d2fd7447a4559a45bcd5f4e27a1eccc Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 15 Jun 2022 21:19:09 +0530 Subject: [PATCH 081/133] test: silent test failure in stock assertions (#31377) If actual values are not present then test is silently passing, # of actual values should be at least equal to expected values. --- erpnext/accounts/utils.py | 4 +++- erpnext/stock/tests/test_utils.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index f824a00743..ccf4b40246 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1175,7 +1175,9 @@ def repost_gle_for_stock_vouchers( voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True) else: _delete_gl_entries(voucher_type, voucher_no) - frappe.db.commit() + + if not frappe.flags.in_test: + frappe.db.commit() if repost_doc: repost_doc.db_set( diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py index b046dbda24..4e93ac93cb 100644 --- a/erpnext/stock/tests/test_utils.py +++ b/erpnext/stock/tests/test_utils.py @@ -26,6 +26,7 @@ class StockTestMixin: filters=filters, order_by="timestamp(posting_date, posting_time), creation", ) + self.assertGreaterEqual(len(sles), len(expected_sles)) for exp_sle, act_sle in zip(expected_sles, sles): for k, v in exp_sle.items(): @@ -49,7 +50,7 @@ class StockTestMixin: filters=filters, order_by=order_by or "posting_date, creation", ) - + self.assertGreaterEqual(len(actual_gles), len(expected_gles)) for exp_gle, act_gle in zip(expected_gles, actual_gles): for k, exp_value in exp_gle.items(): act_value = act_gle[k] From b4a93da9f3d69c2f45525ba8b41e7def08c74944 Mon Sep 17 00:00:00 2001 From: Jingxuan He Date: Thu, 16 Jun 2022 08:46:59 +0200 Subject: [PATCH 082/133] chore: Fix a potential variable misuse bug (#31372) * Fix a potential variable misuse bug * chore: Separate check (separate line) for empty table in Pricing Rule * chore: Code readability & check for field in row (now row itself) Co-authored-by: marination --- erpnext/accounts/doctype/pricing_rule/pricing_rule.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 2438f4b1ab..98e0a9b215 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -36,8 +36,12 @@ class PricingRule(Document): def validate_duplicate_apply_on(self): if self.apply_on != "Transaction": - field = apply_on_dict.get(self.apply_on) - values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field] + apply_on_table = apply_on_dict.get(self.apply_on) + if not apply_on_table: + return + + apply_on_field = frappe.scrub(self.apply_on) + values = [d.get(apply_on_field) for d in self.get(apply_on_table) if d.get(apply_on_field)] if len(values) != len(set(values)): frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on)) From 6f2086d770647a449eca36114d7b8fd2e97ea25d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 16 Jun 2022 22:15:06 +0530 Subject: [PATCH 083/133] test: verify that all patches exist in patches.txt (#31371) * chore: delete orphaned patches * test: orphan/missing entries in patches.txt [skip ci] --- .../update_healthcare_refactored_changes.py | 131 ------------------ .../healthcare_lab_module_rename_doctypes.py | 94 ------------- .../v13_0/print_uom_after_quantity_patch.py | 9 -- .../rename_discharge_date_in_ip_record.py | 8 -- ...et_company_field_in_healthcare_doctypes.py | 25 ---- erpnext/tests/test_init.py | 5 + 6 files changed, 5 insertions(+), 267 deletions(-) delete mode 100644 erpnext/patches/v12_0/update_healthcare_refactored_changes.py delete mode 100644 erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py delete mode 100644 erpnext/patches/v13_0/print_uom_after_quantity_patch.py delete mode 100644 erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py delete mode 100644 erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py diff --git a/erpnext/patches/v12_0/update_healthcare_refactored_changes.py b/erpnext/patches/v12_0/update_healthcare_refactored_changes.py deleted file mode 100644 index 5ca0d5d47d..0000000000 --- a/erpnext/patches/v12_0/update_healthcare_refactored_changes.py +++ /dev/null @@ -1,131 +0,0 @@ -import frappe -from frappe.model.utils.rename_field import rename_field -from frappe.modules import get_doctype_module, scrub - -field_rename_map = { - "Healthcare Settings": [ - ["patient_master_name", "patient_name_by"], - ["max_visit", "max_visits"], - ["reg_sms", "send_registration_msg"], - ["reg_msg", "registration_msg"], - ["app_con", "send_appointment_confirmation"], - ["app_con_msg", "appointment_confirmation_msg"], - ["no_con", "avoid_confirmation"], - ["app_rem", "send_appointment_reminder"], - ["app_rem_msg", "appointment_reminder_msg"], - ["rem_before", "remind_before"], - ["manage_customer", "link_customer_to_patient"], - ["create_test_on_si_submit", "create_lab_test_on_si_submit"], - ["require_sample_collection", "create_sample_collection_for_lab_test"], - ["require_test_result_approval", "lab_test_approval_required"], - ["manage_appointment_invoice_automatically", "automate_appointment_invoicing"], - ], - "Drug Prescription": [["use_interval", "usage_interval"], ["in_every", "interval_uom"]], - "Lab Test Template": [ - ["sample_quantity", "sample_qty"], - ["sample_collection_details", "sample_details"], - ], - "Sample Collection": [ - ["sample_quantity", "sample_qty"], - ["sample_collection_details", "sample_details"], - ], - "Fee Validity": [["max_visit", "max_visits"]], -} - - -def execute(): - for dn in field_rename_map: - if frappe.db.exists("DocType", dn): - if dn == "Healthcare Settings": - frappe.reload_doctype("Healthcare Settings") - else: - frappe.reload_doc(get_doctype_module(dn), "doctype", scrub(dn)) - - for dt, field_list in field_rename_map.items(): - if frappe.db.exists("DocType", dt): - for field in field_list: - if dt == "Healthcare Settings": - rename_field(dt, field[0], field[1]) - elif frappe.db.has_column(dt, field[0]): - rename_field(dt, field[0], field[1]) - - # first name mandatory in Patient - if frappe.db.exists("DocType", "Patient"): - patients = frappe.db.sql("select name, patient_name from `tabPatient`", as_dict=1) - frappe.reload_doc("healthcare", "doctype", "patient") - for entry in patients: - name = entry.patient_name.split(" ") - frappe.db.set_value("Patient", entry.name, "first_name", name[0]) - - # mark Healthcare Practitioner status as Disabled - if frappe.db.exists("DocType", "Healthcare Practitioner"): - practitioners = frappe.db.sql( - "select name from `tabHealthcare Practitioner` where 'active'= 0", as_dict=1 - ) - practitioners_lst = [p.name for p in practitioners] - frappe.reload_doc("healthcare", "doctype", "healthcare_practitioner") - if practitioners_lst: - frappe.db.sql( - "update `tabHealthcare Practitioner` set status = 'Disabled' where name IN %(practitioners)s" - "", - {"practitioners": practitioners_lst}, - ) - - # set Clinical Procedure status - if frappe.db.exists("DocType", "Clinical Procedure"): - frappe.reload_doc("healthcare", "doctype", "clinical_procedure") - frappe.db.sql( - """ - UPDATE - `tabClinical Procedure` - SET - docstatus = (CASE WHEN status = 'Cancelled' THEN 2 - WHEN status = 'Draft' THEN 0 - ELSE 1 - END) - """ - ) - - # set complaints and diagnosis in table multiselect in Patient Encounter - if frappe.db.exists("DocType", "Patient Encounter"): - field_list = [["visit_department", "medical_department"], ["type", "appointment_type"]] - encounter_details = frappe.db.sql( - """select symptoms, diagnosis, name from `tabPatient Encounter`""", as_dict=True - ) - frappe.reload_doc("healthcare", "doctype", "patient_encounter") - frappe.reload_doc("healthcare", "doctype", "patient_encounter_symptom") - frappe.reload_doc("healthcare", "doctype", "patient_encounter_diagnosis") - - for field in field_list: - if frappe.db.has_column(dt, field[0]): - rename_field(dt, field[0], field[1]) - - for entry in encounter_details: - doc = frappe.get_doc("Patient Encounter", entry.name) - symptoms = entry.symptoms.split("\n") if entry.symptoms else [] - for symptom in symptoms: - if not frappe.db.exists("Complaint", symptom): - frappe.get_doc({"doctype": "Complaint", "complaints": symptom}).insert() - row = doc.append("symptoms", {"complaint": symptom}) - row.db_update() - - diagnosis = entry.diagnosis.split("\n") if entry.diagnosis else [] - for d in diagnosis: - if not frappe.db.exists("Diagnosis", d): - frappe.get_doc({"doctype": "Diagnosis", "diagnosis": d}).insert() - row = doc.append("diagnosis", {"diagnosis": d}) - row.db_update() - doc.db_update() - - if frappe.db.exists("DocType", "Fee Validity"): - # update fee validity status - frappe.db.sql( - """ - UPDATE - `tabFee Validity` - SET - status = (CASE WHEN visited >= max_visits THEN 'Completed' - ELSE 'Pending' - END) - """ - ) diff --git a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py b/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py deleted file mode 100644 index 30b84accf3..0000000000 --- a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py +++ /dev/null @@ -1,94 +0,0 @@ -import frappe -from frappe.model.utils.rename_field import rename_field - - -def execute(): - if frappe.db.exists("DocType", "Lab Test") and frappe.db.exists("DocType", "Lab Test Template"): - # rename child doctypes - doctypes = { - "Lab Test Groups": "Lab Test Group Template", - "Normal Test Items": "Normal Test Result", - "Sensitivity Test Items": "Sensitivity Test Result", - "Special Test Items": "Descriptive Test Result", - "Special Test Template": "Descriptive Test Template", - } - - frappe.reload_doc("healthcare", "doctype", "lab_test") - frappe.reload_doc("healthcare", "doctype", "lab_test_template") - - for old_dt, new_dt in doctypes.items(): - frappe.flags.link_fields = {} - should_rename = frappe.db.table_exists(old_dt) and not frappe.db.table_exists(new_dt) - if should_rename: - frappe.reload_doc("healthcare", "doctype", frappe.scrub(old_dt)) - frappe.rename_doc("DocType", old_dt, new_dt, force=True) - frappe.reload_doc("healthcare", "doctype", frappe.scrub(new_dt)) - frappe.delete_doc_if_exists("DocType", old_dt) - - parent_fields = { - "Lab Test Group Template": "lab_test_groups", - "Descriptive Test Template": "descriptive_test_templates", - "Normal Test Result": "normal_test_items", - "Sensitivity Test Result": "sensitivity_test_items", - "Descriptive Test Result": "descriptive_test_items", - } - - for doctype, parentfield in parent_fields.items(): - frappe.db.sql( - """ - UPDATE `tab{0}` - SET parentfield = %(parentfield)s - """.format( - doctype - ), - {"parentfield": parentfield}, - ) - - # copy renamed child table fields (fields were already renamed in old doctype json, hence sql) - rename_fields = { - "lab_test_name": "test_name", - "lab_test_event": "test_event", - "lab_test_uom": "test_uom", - "lab_test_comment": "test_comment", - } - - for new, old in rename_fields.items(): - if frappe.db.has_column("Normal Test Result", old): - frappe.db.sql("""UPDATE `tabNormal Test Result` SET {} = {}""".format(new, old)) - - if frappe.db.has_column("Normal Test Template", "test_event"): - frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_event = test_event""") - - if frappe.db.has_column("Normal Test Template", "test_uom"): - frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_uom = test_uom""") - - if frappe.db.has_column("Descriptive Test Result", "test_particulars"): - frappe.db.sql( - """UPDATE `tabDescriptive Test Result` SET lab_test_particulars = test_particulars""" - ) - - rename_fields = { - "lab_test_template": "test_template", - "lab_test_description": "test_description", - "lab_test_rate": "test_rate", - } - - for new, old in rename_fields.items(): - if frappe.db.has_column("Lab Test Group Template", old): - frappe.db.sql("""UPDATE `tabLab Test Group Template` SET {} = {}""".format(new, old)) - - # rename field - frappe.reload_doc("healthcare", "doctype", "lab_test") - if frappe.db.has_column("Lab Test", "special_toggle"): - rename_field("Lab Test", "special_toggle", "descriptive_toggle") - - if frappe.db.exists("DocType", "Lab Test Group Template"): - # fix select field option - frappe.reload_doc("healthcare", "doctype", "lab_test_group_template") - frappe.db.sql( - """ - UPDATE `tabLab Test Group Template` - SET template_or_new_line = 'Add New Line' - WHERE template_or_new_line = 'Add new line' - """ - ) diff --git a/erpnext/patches/v13_0/print_uom_after_quantity_patch.py b/erpnext/patches/v13_0/print_uom_after_quantity_patch.py deleted file mode 100644 index a16f909fc3..0000000000 --- a/erpnext/patches/v13_0/print_uom_after_quantity_patch.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2019, Frappe and Contributors -# License: GNU General Public License v3. See license.txt - - -from erpnext.setup.install import create_print_uom_after_qty_custom_field - - -def execute(): - create_print_uom_after_qty_custom_field() diff --git a/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py b/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py deleted file mode 100644 index 3bd717d77b..0000000000 --- a/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py +++ /dev/null @@ -1,8 +0,0 @@ -import frappe -from frappe.model.utils.rename_field import rename_field - - -def execute(): - frappe.reload_doc("Healthcare", "doctype", "Inpatient Record") - if frappe.db.has_column("Inpatient Record", "discharge_date"): - rename_field("Inpatient Record", "discharge_date", "discharge_datetime") diff --git a/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py b/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py deleted file mode 100644 index bc2d1b94f7..0000000000 --- a/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py +++ /dev/null @@ -1,25 +0,0 @@ -import frappe - - -def execute(): - company = frappe.db.get_single_value("Global Defaults", "default_company") - doctypes = [ - "Clinical Procedure", - "Inpatient Record", - "Lab Test", - "Sample Collection", - "Patient Appointment", - "Patient Encounter", - "Vital Signs", - "Therapy Session", - "Therapy Plan", - "Patient Assessment", - ] - for entry in doctypes: - if frappe.db.exists("DocType", entry): - frappe.reload_doc("Healthcare", "doctype", entry) - frappe.db.sql( - "update `tab{dt}` set company = {company} where ifnull(company, '') = ''".format( - dt=entry, company=frappe.db.escape(company) - ) - ) diff --git a/erpnext/tests/test_init.py b/erpnext/tests/test_init.py index 4d5fced083..18ce93ab83 100644 --- a/erpnext/tests/test_init.py +++ b/erpnext/tests/test_init.py @@ -45,3 +45,8 @@ class TestInit(unittest.TestCase): from frappe.tests.test_translate import verify_translation_files verify_translation_files("erpnext") + + def test_patches(self): + from frappe.tests.test_patches import check_patch_files + + check_patch_files("erpnext") From 1a3997a5669893d76ef743ff07c15dfe63fde1ae Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Thu, 16 Jun 2022 17:03:47 +0000 Subject: [PATCH 084/133] fix: transaction date gets unset in material request (#31327) * fix: set date correctly in material request * fix: use only `transaction_date` in `get_item_details` --- erpnext/public/js/controllers/transaction.js | 1 - erpnext/stock/get_item_details.py | 21 ++++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index de93c82ef2..01f72adf34 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -453,7 +453,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe is_pos: cint(me.frm.doc.is_pos), is_return: cint(me.frm.doc.is_return), is_subcontracted: me.frm.doc.is_subcontracted, - transaction_date: me.frm.doc.transaction_date || me.frm.doc.posting_date, ignore_pricing_rule: me.frm.doc.ignore_pricing_rule, doctype: me.frm.doc.doctype, name: me.frm.doc.name, diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 3776a27b35..7cff85fb57 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -63,18 +63,16 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru item = frappe.get_cached_doc("Item", args.item_code) validate_item_details(args, item) - out = get_basic_details(args, item, overwrite_warehouse) - if isinstance(doc, str): doc = json.loads(doc) - if doc and doc.get("doctype") == "Purchase Invoice": - args["bill_date"] = doc.get("bill_date") - if doc: - args["posting_date"] = doc.get("posting_date") - args["transaction_date"] = doc.get("transaction_date") + args["transaction_date"] = doc.get("transaction_date") or doc.get("posting_date") + if doc.get("doctype") == "Purchase Invoice": + args["bill_date"] = doc.get("bill_date") + + out = get_basic_details(args, item, overwrite_warehouse) get_item_tax_template(args, item, out) out["item_tax_rate"] = get_item_tax_map( args.company, @@ -596,9 +594,7 @@ def _get_item_tax_template(args, taxes, out=None, for_validate=False): if tax.valid_from or tax.maximum_net_rate: # In purchase Invoice first preference will be given to supplier invoice date # if supplier date is not present then posting date - validation_date = ( - args.get("transaction_date") or args.get("bill_date") or args.get("posting_date") - ) + validation_date = args.get("bill_date") or args.get("transaction_date") if getdate(tax.valid_from) <= getdate(validation_date) and is_within_valid_range(args, tax): taxes_with_validity.append(tax) @@ -891,10 +887,6 @@ def get_item_price(args, item_code, ignore_party=False): conditions += """ and %(transaction_date)s between ifnull(valid_from, '2000-01-01') and ifnull(valid_upto, '2500-12-31')""" - if args.get("posting_date"): - conditions += """ and %(posting_date)s between - ifnull(valid_from, '2000-01-01') and ifnull(valid_upto, '2500-12-31')""" - return frappe.db.sql( """ select name, price_list_rate, uom from `tabItem Price` {conditions} @@ -921,7 +913,6 @@ def get_price_list_rate_for(args, item_code): "supplier": args.get("supplier"), "uom": args.get("uom"), "transaction_date": args.get("transaction_date"), - "posting_date": args.get("posting_date"), "batch_no": args.get("batch_no"), } From 10583eb3cebc2491e192721be1d49dd10aa00860 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 17 Jun 2022 12:13:27 +0530 Subject: [PATCH 085/133] fix: UOM handling for transaction without item (#31389) If invoice is made without item code then UOM, Stock UOM and conversion_factor all need to be manually added, this is confusing and leads missing them out leads to errors. Simplest solution: - if either UOM exists then set both to same uom conversion factor to - also set conversion factor based on UOM conversions --- .../purchase_invoice/test_purchase_invoice.py | 20 +++++++++++++++++++ erpnext/controllers/accounts_controller.py | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 3c70e24cae..6412da709f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1616,6 +1616,26 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): company.enable_provisional_accounting_for_non_stock_items = 0 company.save() + def test_item_less_defaults(self): + + pi = frappe.new_doc("Purchase Invoice") + pi.supplier = "_Test Supplier" + pi.company = "_Test Company" + pi.append( + "items", + { + "item_name": "Opening item", + "qty": 1, + "uom": "Tonne", + "stock_uom": "Kg", + "rate": 1000, + "expense_account": "Stock Received But Not Billed - _TC", + }, + ) + + pi.save() + self.assertEqual(pi.items[0].conversion_factor, 1000) + def check_gl_entries(doc, voucher_no, expected_gle, posting_date): gl_entries = frappe.db.sql( diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 854c0d00f5..f49366a956 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -46,6 +46,7 @@ from erpnext.controllers.print_settings import ( from erpnext.controllers.sales_and_purchase_return import validate_return from erpnext.exceptions import InvalidCurrency from erpnext.setup.utils import get_exchange_rate +from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.get_item_details import ( _get_item_tax_template, @@ -548,6 +549,15 @@ class AccountsController(TransactionBase): if ret.get("pricing_rules"): self.apply_pricing_rule_on_items(item, ret) self.set_pricing_rule_details(item, ret) + else: + # Transactions line item without item code + + uom = item.get("uom") + stock_uom = item.get("stock_uom") + if bool(uom) != bool(stock_uom): # xor + item.stock_uom = item.uom = uom or stock_uom + + item.conversion_factor = get_uom_conv_factor(item.get("uom"), item.get("stock_uom")) if self.doctype == "Purchase Invoice": self.set_expense_account(for_validate) From 74007c8e9106997511f26e2ec355de745b8be013 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 17 Jun 2022 15:10:21 +0530 Subject: [PATCH 086/133] fix(UX): hide irrelevant UOM fields (#31392) fix(UX): hide UOM-related fields if they are inconsequential --- .../purchase_invoice_item/purchase_invoice_item.json | 8 ++++++-- .../doctype/sales_invoice_item/sales_invoice_item.json | 5 ++++- .../doctype/purchase_order_item/purchase_order_item.json | 5 ++++- .../doctype/sales_order_item/sales_order_item.json | 7 +++++-- .../doctype/delivery_note_item/delivery_note_item.json | 5 ++++- .../purchase_receipt_item/purchase_receipt_item.json | 8 ++++++-- .../doctype/stock_entry_detail/stock_entry_detail.json | 5 ++++- 7 files changed, 33 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 6651195e5f..1f79d4761e 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -195,6 +195,7 @@ "label": "Rejected Qty" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -214,6 +215,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", @@ -222,6 +224,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_qty", "fieldtype": "Float", "label": "Accepted Qty in Stock UOM", @@ -871,7 +874,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-11-15 17:04:07.191013", + "modified": "2022-06-17 05:31:10.520171", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", @@ -879,5 +882,6 @@ "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index b3ba1199b6..b417c7de03 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -182,6 +182,7 @@ "oldfieldtype": "Currency" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -200,6 +201,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", @@ -207,6 +209,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_qty", "fieldtype": "Float", "label": "Qty as per Stock UOM", @@ -843,7 +846,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-03-23 08:18:04.928287", + "modified": "2022-06-17 05:33:15.335912", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index f72c598840..7994b08ad4 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -213,6 +213,7 @@ "width": "60px" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -242,6 +243,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", @@ -593,6 +595,7 @@ "label": "Billed, Received & Returned" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_qty", "fieldtype": "Float", "label": "Qty in Stock UOM", @@ -851,7 +854,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-02-02 13:10:18.398976", + "modified": "2022-06-17 05:29:40.602349", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 3797856db2..318799907e 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -23,7 +23,6 @@ "quantity_and_rate", "qty", "stock_uom", - "picked_qty", "col_break2", "uom", "conversion_factor", @@ -87,6 +86,7 @@ "delivered_qty", "produced_qty", "returned_qty", + "picked_qty", "shopping_cart_section", "additional_notes", "section_break_63", @@ -198,6 +198,7 @@ "width": "100px" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -220,6 +221,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", @@ -228,6 +230,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_qty", "fieldtype": "Float", "label": "Qty as per Stock UOM", @@ -811,7 +814,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-04-27 03:15:34.366563", + "modified": "2022-06-17 05:27:41.603006", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 2d7abc8a0d..2de4842ebe 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -184,6 +184,7 @@ "width": "100px" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -209,6 +210,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "conversion_factor", "fieldtype": "Float", "label": "UOM Conversion Factor", @@ -217,6 +219,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_qty", "fieldtype": "Float", "label": "Qty in Stock UOM", @@ -780,7 +783,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-05-02 12:09:39.610075", + "modified": "2022-06-17 05:25:47.711177", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 1c65ac86c9..b45d66391c 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -252,6 +252,7 @@ "width": "100px" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -265,6 +266,7 @@ "width": "100px" }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "conversion_factor", "fieldtype": "Float", "label": "Conversion Factor", @@ -547,6 +549,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_qty", "fieldtype": "Float", "label": "Accepted Qty in Stock UOM", @@ -878,7 +881,7 @@ "fieldtype": "Column Break" }, { - "depends_on": "returned_qty", + "depends_on": "doc.returned_qty", "fieldname": "returned_qty", "fieldtype": "Float", "label": "Returned Qty in Stock UOM", @@ -887,6 +890,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "received_stock_qty", "fieldtype": "Float", "label": "Received Qty in Stock UOM", @@ -994,7 +998,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-04-11 13:07:32.061402", + "modified": "2022-06-17 05:32:16.483178", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 83aed904dd..d758c8a0ea 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -233,6 +233,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "conversion_factor", "fieldtype": "Float", "label": "Conversion Factor", @@ -242,6 +243,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -253,6 +255,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.uom != doc.stock_uom", "fieldname": "transfer_qty", "fieldtype": "Float", "label": "Qty as per Stock UOM", @@ -556,7 +559,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-02-26 00:51:24.963653", + "modified": "2022-06-17 05:06:33.621264", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", From d6078aa911a135ab7bfd99c3246c44f992225ce8 Mon Sep 17 00:00:00 2001 From: Marica Date: Fri, 17 Jun 2022 15:13:13 +0530 Subject: [PATCH 087/133] fix: Respect system precision for user facing balance qty values (#30837) * fix: Respect system precision for user facing balance qty values - `get_precision` -> `set_precision` - Use system wide currency precision for `stock_value` - Round of qty defiiciency as per user defined precision (system flt precision), so that it is WYSIWYG for users * fix: Consider system precision when validating future negative qty * test: Immediate Negative Qty precision test - Test for Immediate Negative Qty precision - Stock Entry Negative Qty message: Format available qty in system precision - Pass `stock_uom` as confugrable option in `make_item` * test: Future Negative Qty validation with precision * fix: Use `get_field_precision` for currency precision as it used to - `get_field_precision` defaults to number format for precision (maintain old behaviour) - Don't pass `currency` to `get_field_precision` as its not used anymore --- erpnext/stock/doctype/item/test_item.py | 2 + .../stock/doctype/stock_entry/stock_entry.py | 2 +- .../test_stock_ledger_entry.py | 90 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 36 ++++++-- 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index aa0a5490b6..d5074e7eb5 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -800,6 +800,7 @@ def create_item( item_code, is_stock_item=1, valuation_rate=0, + stock_uom="Nos", warehouse="_Test Warehouse - _TC", is_customer_provided_item=None, customer=None, @@ -815,6 +816,7 @@ def create_item( item.item_name = item_code item.description = item_code item.item_group = "All Item Groups" + item.stock_uom = stock_uom item.is_stock_item = is_stock_item item.is_fixed_asset = is_fixed_asset item.asset_category = asset_category diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a9176a9f12..e902d1e56b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -590,7 +590,7 @@ class StockEntry(StockController): ) + "

" + _("Available quantity is {0}, you need {1}").format( - frappe.bold(d.actual_qty), frappe.bold(d.transfer_qty) + frappe.bold(flt(d.actual_qty, d.precision("actual_qty"))), frappe.bold(d.transfer_qty) ), NegativeStockError, title=_("Insufficient Stock"), 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 55a213ccc3..f669e90308 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 @@ -42,6 +42,9 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): "delete from `tabBin` where item_code in (%s)" % (", ".join(["%s"] * len(items))), items ) + def tearDown(self): + frappe.db.rollback() + def test_item_cost_reposting(self): company = "_Test Company" @@ -1230,6 +1233,93 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): ) self.assertEqual(abs(sles[0].stock_value_difference), sles[1].stock_value_difference) + @change_settings("System Settings", {"float_precision": 4}) + def test_negative_qty_with_precision(self): + "Test if system precision is respected while validating negative qty." + from erpnext.stock.doctype.item.test_item import create_item + from erpnext.stock.utils import get_stock_balance + + item_code = "ItemPrecisionTest" + warehouse = "_Test Warehouse - _TC" + create_item(item_code, is_stock_item=1, stock_uom="Kg") + + create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=559.8327, rate=100) + + make_stock_entry(item_code=item_code, source=warehouse, qty=470.84, rate=100) + self.assertEqual(get_stock_balance(item_code, warehouse), 88.9927) + + settings = frappe.get_doc("System Settings") + settings.float_precision = 3 + settings.save() + + # To deliver 100 qty we fall short of 11.0073 qty (11.007 with precision 3) + # Stock up with 11.007 (balance in db becomes 99.9997, on UI it will show as 100) + make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100) + self.assertEqual(get_stock_balance(item_code, warehouse), 99.9997) + + # See if delivery note goes through + # Negative qty error should not be raised as 99.9997 is 100 with precision 3 (system precision) + dn = create_delivery_note( + item_code=item_code, + qty=100, + rate=150, + warehouse=warehouse, + company="_Test Company", + expense_account="Cost of Goods Sold - _TC", + cost_center="Main - _TC", + do_not_submit=True, + ) + dn.submit() + + self.assertEqual(flt(get_stock_balance(item_code, warehouse), 3), 0.000) + + @change_settings("System Settings", {"float_precision": 4}) + def test_future_negative_qty_with_precision(self): + """ + Ledger: + | Voucher | Qty | Balance + ------------------- + | Reco | 559.8327| 559.8327 + | SE | -470.84 | [Backdated] (new bal: 88.9927) + | SE | 11.007 | 570.8397 (new bal: 99.9997) + | DN | -100 | 470.8397 (new bal: -0.0003) + + Check if future negative qty is asserted as per precision 3. + -0.0003 should be considered as 0.000 + """ + from erpnext.stock.doctype.item.test_item import create_item + + item_code = "ItemPrecisionTest" + warehouse = "_Test Warehouse - _TC" + create_item(item_code, is_stock_item=1, stock_uom="Kg") + + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=559.8327, + rate=100, + posting_date=add_days(today(), -2), + ) + make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100) + create_delivery_note( + item_code=item_code, + qty=100, + rate=150, + warehouse=warehouse, + company="_Test Company", + expense_account="Cost of Goods Sold - _TC", + cost_center="Main - _TC", + ) + + settings = frappe.get_doc("System Settings") + settings.float_precision = 3 + settings.save() + + # Make backdated SE and make sure SE goes through as per precision (no negative qty error) + make_stock_entry( + item_code=item_code, source=warehouse, qty=470.84, rate=100, posting_date=add_days(today(), -1) + ) + def create_repack_entry(**args): args = frappe._dict(args) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 4789b52d50..ba2d3c1512 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1,4 +1,4 @@ -# 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 copy @@ -370,7 +370,7 @@ class update_entries_after(object): self.args["name"] = self.args.sle_id self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") - self.get_precision() + self.set_precision() self.valuation_method = get_valuation_method(self.item_code) self.new_items_found = False @@ -381,10 +381,10 @@ class update_entries_after(object): self.initialize_previous_data(self.args) self.build() - def get_precision(self): - company_base_currency = frappe.get_cached_value("Company", self.company, "default_currency") - self.precision = get_field_precision( - frappe.get_meta("Stock Ledger Entry").get_field("stock_value"), currency=company_base_currency + def set_precision(self): + self.flt_precision = cint(frappe.db.get_default("float_precision")) or 2 + self.currency_precision = get_field_precision( + frappe.get_meta("Stock Ledger Entry").get_field("stock_value") ) def initialize_previous_data(self, args): @@ -581,7 +581,7 @@ class update_entries_after(object): self.update_queue_values(sle) # rounding as per precision - self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision) + self.wh_data.stock_value = flt(self.wh_data.stock_value, self.currency_precision) if not self.wh_data.qty_after_transaction: self.wh_data.stock_value = 0.0 stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value @@ -605,6 +605,7 @@ class update_entries_after(object): will not consider cancelled entries """ diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) + diff = flt(diff, self.flt_precision) # respect system precision if diff < 0 and abs(diff) > 0.0001: # negative stock! @@ -1405,7 +1406,8 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): return neg_sle = get_future_sle_with_negative_qty(args) - if neg_sle: + + if is_negative_with_precision(neg_sle): message = _( "{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." ).format( @@ -1423,7 +1425,7 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): return neg_batch_sle = get_future_sle_with_negative_batch_qty(args) - if neg_batch_sle: + if is_negative_with_precision(neg_batch_sle, is_batch=True): message = _( "{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." ).format( @@ -1437,6 +1439,22 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch")) +def is_negative_with_precision(neg_sle, is_batch=False): + """ + Returns whether system precision rounded qty is insufficient. + E.g: -0.0003 in precision 3 (0.000) is sufficient for the user. + """ + + if not neg_sle: + return False + + field = "cumulative_total" if is_batch else "qty_after_transaction" + precision = cint(frappe.db.get_default("float_precision")) or 2 + qty_deficit = flt(neg_sle[0][field], precision) + + return qty_deficit < 0 and abs(qty_deficit) > 0.0001 + + def get_future_sle_with_negative_qty(args): return frappe.db.sql( """ From 74a782d81d8f8c4a4d9214a9c06377e5e6e464dd Mon Sep 17 00:00:00 2001 From: Conor Date: Fri, 17 Jun 2022 06:31:27 -0500 Subject: [PATCH 088/133] refactor: DB independent quoting and truthy/falsy values (#31358) * refactor: DB independent quoting and truthy/falsy values * style: reformat to black spec * fix: ifnull -> coalesce * fix: coalesce -> Coalesce * fix: revert pypika comparison * refactor: convert queries to QB * fix: incorrect value types for query `=` query makes no sense with list of values * fix: remove warehouse docstatus condition * fix: keep using base rate as rate Co-authored-by: Ankush Menat --- .../doctype/journal_entry/journal_entry.py | 2 +- .../doctype/subscription/subscription.py | 15 +++++----- .../sales_payment_summary.py | 8 ++--- erpnext/accounts/utils.py | 4 +-- .../asset_maintenance/asset_maintenance.py | 6 ++-- .../procurement_tracker.py | 6 ++-- erpnext/controllers/accounts_controller.py | 6 ++-- erpnext/controllers/queries.py | 8 ++--- erpnext/controllers/status_updater.py | 10 +++---- .../mpesa_settings/test_mpesa_settings.py | 2 +- .../doctype/exit_interview/exit_interview.py | 2 +- .../hr/doctype/job_offer/test_job_offer.py | 2 +- .../leave_application/leave_application.py | 6 ++-- .../test_leave_application.py | 2 +- .../report/employee_exits/employee_exits.py | 3 +- .../vehicle_expenses/test_vehicle_expenses.py | 2 +- erpnext/hr/utils.py | 2 +- erpnext/manufacturing/doctype/bom/bom.py | 2 +- .../production_plan/production_plan.py | 4 +-- .../doctype/work_order/work_order.py | 2 +- .../doctype/payroll_entry/payroll_entry.py | 2 +- .../doctype/salary_slip/salary_slip.py | 2 +- .../project_profitability.py | 30 ++++++++++--------- .../hsn_wise_summary_of_outward_supplies.py | 2 +- erpnext/regional/report/irs_1099/irs_1099.py | 2 +- .../vat_audit_report/vat_audit_report.py | 2 +- .../selling/doctype/quotation/quotation.py | 17 +++++++---- .../doctype/sales_order/test_sales_order.py | 2 +- .../pending_so_items_for_purchase_request.py | 2 +- erpnext/setup/doctype/company/company.py | 4 +-- .../doctype/email_digest/email_digest.py | 2 +- .../transaction_deletion_record.py | 8 ++--- .../doctype/delivery_trip/delivery_trip.py | 10 +++---- erpnext/stock/doctype/item/item.py | 4 +-- erpnext/stock/doctype/item/test_item.py | 4 +-- .../stock_reconciliation.py | 4 +-- erpnext/stock/doctype/warehouse/warehouse.py | 3 +- erpnext/stock/get_item_details.py | 2 +- erpnext/stock/reorder_item.py | 2 +- ...incorrect_balance_qty_after_transaction.py | 2 +- .../incorrect_serial_no_valuation.py | 2 +- .../stock/report/stock_ledger/stock_ledger.py | 2 +- erpnext/stock/stock_balance.py | 4 +-- 43 files changed, 109 insertions(+), 99 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 8f0fe51e3d..2c16ca3275 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -416,7 +416,7 @@ class JournalEntry(AccountsController): against_entries = frappe.db.sql( """select * from `tabJournal Entry Account` where account = %s and docstatus = 1 and parent = %s - and (reference_type is null or reference_type in ("", "Sales Order", "Purchase Order")) + and (reference_type is null or reference_type in ('', 'Sales Order', 'Purchase Order')) """, (d.account, d.reference_name), as_dict=True, diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 2243b191da..9dab4e91fb 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -145,13 +145,14 @@ class Subscription(Document): You shouldn't need to call this directly. Use `get_billing_cycle` instead. """ plan_names = [plan.plan for plan in self.plans] - billing_info = frappe.db.sql( - "select distinct `billing_interval`, `billing_interval_count` " - "from `tabSubscription Plan` " - "where name in %s", - (plan_names,), - as_dict=1, - ) + + subscription_plan = frappe.qb.DocType("Subscription Plan") + billing_info = ( + frappe.qb.from_(subscription_plan) + .select(subscription_plan.billing_interval, subscription_plan.billing_interval_count) + .distinct() + .where(subscription_plan.name.isin(plan_names)) + ).run(as_dict=1) return billing_info diff --git a/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py b/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py index 4eef307286..057721479e 100644 --- a/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py +++ b/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py @@ -179,7 +179,7 @@ def get_sales_invoice_data(filters): def get_mode_of_payments(filters): mode_of_payments = {} invoice_list = get_invoices(filters) - invoice_list_names = ",".join('"' + invoice["name"] + '"' for invoice in invoice_list) + invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list) if invoice_list: inv_mop = frappe.db.sql( """select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment @@ -200,7 +200,7 @@ def get_mode_of_payments(filters): from `tabJournal Entry` a, `tabJournal Entry Account` b where a.name = b.parent and a.docstatus = 1 - and b.reference_type = "Sales Invoice" + and b.reference_type = 'Sales Invoice' and b.reference_name in ({invoice_list_names}) """.format( invoice_list_names=invoice_list_names @@ -228,7 +228,7 @@ def get_invoices(filters): def get_mode_of_payment_details(filters): mode_of_payment_details = {} invoice_list = get_invoices(filters) - invoice_list_names = ",".join('"' + invoice["name"] + '"' for invoice in invoice_list) + invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list) if invoice_list: inv_mop_detail = frappe.db.sql( """ @@ -259,7 +259,7 @@ def get_mode_of_payment_details(filters): from `tabJournal Entry` a, `tabJournal Entry Account` b where a.name = b.parent and a.docstatus = 1 - and b.reference_type = "Sales Invoice" + and b.reference_type = 'Sales Invoice' and b.reference_name in ({invoice_list_names}) group by a.owner, a.posting_date, mode_of_payment ) t diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index ccf4b40246..41f3223b54 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -475,7 +475,7 @@ def check_if_advance_entry_modified(args): select t2.{dr_or_cr} from `tabJournal Entry` t1, `tabJournal Entry Account` t2 where t1.name = t2.parent and t2.account = %(account)s and t2.party_type = %(party_type)s and t2.party = %(party)s - and (t2.reference_type is null or t2.reference_type in ("", "Sales Order", "Purchase Order")) + and (t2.reference_type is null or t2.reference_type in ('', 'Sales Order', 'Purchase Order')) and t1.name = %(voucher_no)s and t2.name = %(voucher_detail_no)s and t1.docstatus=1 """.format( dr_or_cr=args.get("dr_or_cr") @@ -495,7 +495,7 @@ def check_if_advance_entry_modified(args): t1.name = t2.parent and t1.docstatus = 1 and t1.name = %(voucher_no)s and t2.name = %(voucher_detail_no)s and t1.party_type = %(party_type)s and t1.party = %(party)s and t1.{0} = %(account)s - and t2.reference_doctype in ("", "Sales Order", "Purchase Order") + and t2.reference_doctype in ('', 'Sales Order', 'Purchase Order') and t2.allocated_amount = %(unreconciled_amount)s """.format( party_account_field diff --git a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py index e603d34626..0028d84508 100644 --- a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py +++ b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py @@ -47,17 +47,19 @@ def assign_tasks(asset_maintenance_name, assign_to_member, maintenance_task, nex team_member = frappe.db.get_value("User", assign_to_member, "email") args = { "doctype": "Asset Maintenance", - "assign_to": [team_member], + "assign_to": team_member, "name": asset_maintenance_name, "description": maintenance_task, "date": next_due_date, } if not frappe.db.sql( """select owner from `tabToDo` - where reference_type=%(doctype)s and reference_name=%(name)s and status="Open" + where reference_type=%(doctype)s and reference_name=%(name)s and status='Open' and owner=%(assign_to)s""", args, ): + # assign_to function expects a list + args["assign_to"] = [args["assign_to"]] assign_to.add(args) diff --git a/erpnext/buying/report/procurement_tracker/procurement_tracker.py b/erpnext/buying/report/procurement_tracker/procurement_tracker.py index e0b02ee4e2..d70ac46ce3 100644 --- a/erpnext/buying/report/procurement_tracker/procurement_tracker.py +++ b/erpnext/buying/report/procurement_tracker/procurement_tracker.py @@ -252,7 +252,7 @@ def get_mapped_pi_records(): ON pi_item.`purchase_order` = po.`name` WHERE pi_item.docstatus = 1 - AND po.status not in ("Closed","Completed","Cancelled") + AND po.status not in ('Closed','Completed','Cancelled') AND pi_item.po_detail IS NOT NULL """ ) @@ -271,7 +271,7 @@ def get_mapped_pr_records(): pr.docstatus=1 AND pr.name=pr_item.parent AND pr_item.purchase_order_item IS NOT NULL - AND pr.status not in ("Closed","Completed","Cancelled") + AND pr.status not in ('Closed','Completed','Cancelled') """ ) ) @@ -302,7 +302,7 @@ def get_po_entries(conditions): WHERE parent.docstatus = 1 AND parent.name = child.parent - AND parent.status not in ("Closed","Completed","Cancelled") + AND parent.status not in ('Closed','Completed','Cancelled') {conditions} GROUP BY parent.name, child.item_code diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f49366a956..ded9a303a9 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2049,7 +2049,7 @@ def get_advance_journal_entries( journal_entries = frappe.db.sql( """ select - "Journal Entry" as reference_type, t1.name as reference_name, + 'Journal Entry' as reference_type, t1.name as reference_name, t1.remark as remarks, t2.{0} as amount, t2.name as reference_row, t2.reference_name as against_order, t2.exchange_rate from @@ -2104,7 +2104,7 @@ def get_advance_payment_entries( payment_entries_against_order = frappe.db.sql( """ select - "Payment Entry" as reference_type, t1.name as reference_name, + 'Payment Entry' as reference_type, t1.name as reference_name, t1.remarks, t2.allocated_amount as amount, t2.name as reference_row, t2.reference_name as against_order, t1.posting_date, t1.{0} as currency, t1.{4} as exchange_rate @@ -2124,7 +2124,7 @@ def get_advance_payment_entries( if include_unallocated: unallocated_payment_entries = frappe.db.sql( """ - select "Payment Entry" as reference_type, name as reference_name, posting_date, + select 'Payment Entry' as reference_type, name as reference_name, posting_date, remarks, unallocated_amount as amount, {2} as exchange_rate, {3} as currency from `tabPayment Entry` where diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index a725f674c9..5ba314ecf0 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -340,12 +340,12 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters): fields = get_fields("Project", ["name", "project_name"]) searchfields = frappe.get_meta("Project").get_search_fields() - searchfields = " or ".join([field + " like %(txt)s" for field in searchfields]) + searchfields = " or ".join(["`tabProject`." + field + " like %(txt)s" for field in searchfields]) return frappe.db.sql( """select {fields} from `tabProject` where - `tabProject`.status not in ("Completed", "Cancelled") + `tabProject`.status not in ('Completed', 'Cancelled') and {cond} {scond} {match_cond} order by if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), @@ -374,7 +374,7 @@ def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, from `tabDelivery Note` where `tabDelivery Note`.`%(key)s` like %(txt)s and `tabDelivery Note`.docstatus = 1 - and status not in ("Stopped", "Closed") %(fcond)s + and status not in ('Stopped', 'Closed') %(fcond)s and ( (`tabDelivery Note`.is_return = 0 and `tabDelivery Note`.per_billed < 100) or (`tabDelivery Note`.grand_total = 0 and `tabDelivery Note`.per_billed < 100) @@ -654,7 +654,7 @@ def warehouse_query(doctype, txt, searchfield, start, page_len, filters): filter_dict = get_doctype_wise_filters(filters) query = """select `tabWarehouse`.name, - CONCAT_WS(" : ", "Actual Qty", ifnull(round(`tabBin`.actual_qty, 2), 0 )) actual_qty + CONCAT_WS(' : ', 'Actual Qty', ifnull(round(`tabBin`.actual_qty, 2), 0 )) actual_qty from `tabWarehouse` left join `tabBin` on `tabBin`.warehouse = `tabWarehouse`.name {bin_conditions} where diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 517e080c97..76a25a0de1 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -352,9 +352,9 @@ class StatusUpdater(Document): for args in self.status_updater: # condition to include current record (if submit or no if cancel) if self.docstatus == 1: - args["cond"] = ' or parent="%s"' % self.name.replace('"', '"') + args["cond"] = " or parent='%s'" % self.name.replace('"', '"') else: - args["cond"] = ' and parent!="%s"' % self.name.replace('"', '"') + args["cond"] = " and parent!='%s'" % self.name.replace('"', '"') self._update_children(args, update_modified) @@ -384,7 +384,7 @@ class StatusUpdater(Document): args["second_source_condition"] = frappe.db.sql( """ select ifnull((select sum(%(second_source_field)s) from `tab%(second_source_dt)s` - where `%(second_join_field)s`="%(detail_id)s" + where `%(second_join_field)s`='%(detail_id)s' and (`tab%(second_source_dt)s`.docstatus=1) %(second_source_extra_cond)s), 0) """ % args @@ -398,7 +398,7 @@ class StatusUpdater(Document): frappe.db.sql( """ (select ifnull(sum(%(source_field)s), 0) - from `tab%(source_dt)s` where `%(join_field)s`="%(detail_id)s" + from `tab%(source_dt)s` where `%(join_field)s`='%(detail_id)s' and (docstatus=1 %(cond)s) %(extra_cond)s) """ % args @@ -445,7 +445,7 @@ class StatusUpdater(Document): ifnull((select ifnull(sum(if(abs(%(target_ref_field)s) > abs(%(target_field)s), abs(%(target_field)s), abs(%(target_ref_field)s))), 0) / sum(abs(%(target_ref_field)s)) * 100 - from `tab%(target_dt)s` where parent="%(name)s" having sum(abs(%(target_ref_field)s)) > 0), 0), 6) + from `tab%(target_dt)s` where parent='%(name)s' having sum(abs(%(target_ref_field)s)) > 0), 0), 6) %(update_modified)s where name='%(name)s'""" % args diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 17e332c7df..b52662421d 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -23,7 +23,7 @@ class TestMpesaSettings(unittest.TestCase): def tearDown(self): frappe.db.sql("delete from `tabMpesa Settings`") - frappe.db.sql('delete from `tabIntegration Request` where integration_request_service = "Mpesa"') + frappe.db.sql("delete from `tabIntegration Request` where integration_request_service = 'Mpesa'") def test_creation_of_payment_gateway(self): mode_of_payment = create_mode_of_payment("Mpesa-_Test", payment_type="Phone") diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index 8317310202..ce4355bdd4 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -88,7 +88,7 @@ def send_exit_questionnaire(interviews): reference_doctype=interview.doctype, reference_name=interview.name, ) - interview.db_set("questionnaire_email_sent", True) + interview.db_set("questionnaire_email_sent", 1) interview.notify_update() email_success.append(email) else: diff --git a/erpnext/hr/doctype/job_offer/test_job_offer.py b/erpnext/hr/doctype/job_offer/test_job_offer.py index 7d8ef115d1..9c4cb36eff 100644 --- a/erpnext/hr/doctype/job_offer/test_job_offer.py +++ b/erpnext/hr/doctype/job_offer/test_job_offer.py @@ -49,7 +49,7 @@ class TestJobOffer(unittest.TestCase): frappe.db.set_value("HR Settings", None, "check_vacancies", 1) def tearDown(self): - frappe.db.sql("DELETE FROM `tabJob Offer` WHERE 1") + frappe.db.sql("DELETE FROM `tabJob Offer`") def create_job_offer(**args): diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py index 43c2bb37b2..d49d1bd976 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -399,7 +399,7 @@ class LeaveApplication(Document): select name, leave_type, posting_date, from_date, to_date, total_leave_days, half_day_date from `tabLeave Application` - where employee = %(employee)s and docstatus < 2 and status in ("Open", "Approved") + where employee = %(employee)s and docstatus < 2 and status in ('Open', 'Approved') and to_date >= %(from_date)s and from_date <= %(to_date)s and name != %(name)s""", { @@ -439,7 +439,7 @@ class LeaveApplication(Document): """select count(name) from `tabLeave Application` where employee = %(employee)s and docstatus < 2 - and status in ("Open", "Approved") + and status in ('Open', 'Approved') and half_day = 1 and half_day_date = %(half_day_date)s and name != %(name)s""", @@ -456,7 +456,7 @@ class LeaveApplication(Document): def validate_attendance(self): attendance = frappe.db.sql( """select name from `tabAttendance` where employee = %s and (attendance_date between %s and %s) - and status = "Present" and docstatus = 1""", + and status = 'Present' and docstatus = 1""", (self.employee, self.from_date, self.to_date), ) if attendance: diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 27c54109de..1b9505eac3 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -108,7 +108,7 @@ class TestLeaveApplication(unittest.TestCase): def _clear_roles(self): frappe.db.sql( """delete from `tabHas Role` where parent in - ("test@example.com", "test1@example.com", "test2@example.com")""" + ('test@example.com', 'test1@example.com', 'test2@example.com')""" ) def _clear_applications(self): diff --git a/erpnext/hr/report/employee_exits/employee_exits.py b/erpnext/hr/report/employee_exits/employee_exits.py index 9cd9ff0a6b..80b9ec1eca 100644 --- a/erpnext/hr/report/employee_exits/employee_exits.py +++ b/erpnext/hr/report/employee_exits/employee_exits.py @@ -5,6 +5,7 @@ import frappe from frappe import _ from frappe.query_builder import Order from frappe.utils import getdate +from pypika import functions as fn def execute(filters=None): @@ -110,7 +111,7 @@ def get_data(filters): ) .distinct() .where( - ((employee.relieving_date.isnotnull()) | (employee.relieving_date != "")) + (fn.Coalesce(fn.Cast(employee.relieving_date, "char"), "") != "") & ((interview.name.isnull()) | ((interview.name.isnotnull()) & (interview.docstatus != 2))) & ((fnf.name.isnull()) | ((fnf.name.isnotnull()) & (fnf.docstatus != 2))) ) diff --git a/erpnext/hr/report/vehicle_expenses/test_vehicle_expenses.py b/erpnext/hr/report/vehicle_expenses/test_vehicle_expenses.py index da6dace72b..e5468104b7 100644 --- a/erpnext/hr/report/vehicle_expenses/test_vehicle_expenses.py +++ b/erpnext/hr/report/vehicle_expenses/test_vehicle_expenses.py @@ -20,7 +20,7 @@ class TestVehicleExpenses(unittest.TestCase): frappe.db.sql("delete from `tabVehicle Log`") employee_id = frappe.db.sql( - '''select name from `tabEmployee` where name="testdriver@example.com"''' + """select name from `tabEmployee` where name='testdriver@example.com' """ ) self.employee_id = employee_id[0][0] if employee_id else None if not self.employee_id: diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 3f4e31b1b2..db691474f7 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -458,7 +458,7 @@ def get_salary_assignments(employee, payroll_period): def get_sal_slip_total_benefit_given(employee, payroll_period, component=False): total_given_benefit_amount = 0 query = """ - select sum(sd.amount) as 'total_amount' + select sum(sd.amount) as total_amount from `tabSalary Slip` ss, `tabSalary Detail` sd where ss.employee=%(employee)s and ss.docstatus = 1 and ss.name = sd.parent diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 631548b309..4c88eca8f6 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1305,7 +1305,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): if not field in searchfields ] - query_filters = {"disabled": 0, "ifnull(end_of_life, '5050-50-50')": (">", today())} + query_filters = {"disabled": 0, "end_of_life": (">", today())} or_cond_filters = {} if txt: diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 9ca05b927f..8a28454af2 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -849,7 +849,7 @@ def get_subitems( FROM `tabBOM Item` bom_item JOIN `tabBOM` bom ON bom.name = bom_item.parent - JOIN tabItem item ON bom_item.item_code = item.name + JOIN `tabItem` item ON bom_item.item_code = item.name LEFT JOIN `tabItem Default` item_default ON item.name = item_default.parent and item_default.company = %(company)s LEFT JOIN `tabUOM Conversion Detail` item_uom @@ -979,7 +979,7 @@ def get_sales_orders(self): select distinct so.name, so.transaction_date, so.customer, so.base_grand_total from `tabSales Order` so, `tabSales Order Item` so_item where so_item.parent = so.name - and so.docstatus = 1 and so.status not in ("Stopped", "Closed") + and so.docstatus = 1 and so.status not in ('Stopped', 'Closed') and so.company = %(company)s and so_item.qty > so_item.work_order_qty {so_filter} {item_filter} and (exists (select name from `tabBOM` bom where {bom_item} diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 2802310250..7b8625372a 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -939,7 +939,7 @@ class WorkOrder(Document): from `tabStock Entry` entry, `tabStock Entry Detail` detail where entry.work_order = %(name)s - and entry.purpose = "Material Transfer for Manufacture" + and entry.purpose = 'Material Transfer for Manufacture' and entry.docstatus = 1 and detail.parent = entry.name and (detail.item_code = %(item)s or detail.original_item = %(item)s)""", diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 1524fb7c9e..a0cef7038a 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -674,7 +674,7 @@ def get_filter_condition(filters): def get_joining_relieving_condition(start_date, end_date): cond = """ - and ifnull(t1.date_of_joining, '0000-00-00') <= '%(end_date)s' + and ifnull(t1.date_of_joining, '1900-01-01') <= '%(end_date)s' and ifnull(t1.relieving_date, '2199-12-31') >= '%(start_date)s' """ % { "start_date": start_date, diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 6a35985e64..e1ccc117e7 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -508,7 +508,7 @@ class SalarySlip(TransactionBase): SELECT attendance_date, status, leave_type FROM `tabAttendance` WHERE - status in ("Absent", "Half Day", "On leave") + status in ('Absent', 'Half Day', 'On leave') AND employee = %s AND docstatus = 1 AND attendance_date between %s and %s diff --git a/erpnext/projects/report/project_profitability/project_profitability.py b/erpnext/projects/report/project_profitability/project_profitability.py index abbbaf5d92..aa955bcc47 100644 --- a/erpnext/projects/report/project_profitability/project_profitability.py +++ b/erpnext/projects/report/project_profitability/project_profitability.py @@ -39,17 +39,17 @@ def get_rows(filters): FROM (SELECT si.customer_name,si.base_grand_total, - si.name as voucher_no,tabTimesheet.employee, - tabTimesheet.title as employee_name,tabTimesheet.parent_project as project, - tabTimesheet.start_date,tabTimesheet.end_date, - tabTimesheet.total_billed_hours,tabTimesheet.name as timesheet, + si.name as voucher_no,`tabTimesheet`.employee, + `tabTimesheet`.title as employee_name,`tabTimesheet`.parent_project as project, + `tabTimesheet`.start_date,`tabTimesheet`.end_date, + `tabTimesheet`.total_billed_hours,`tabTimesheet`.name as timesheet, ss.base_gross_pay,ss.total_working_days, - tabTimesheet.total_billed_hours/(ss.total_working_days * {0}) as utilization + `tabTimesheet`.total_billed_hours/(ss.total_working_days * {0}) as utilization FROM - `tabSalary Slip Timesheet` as sst join `tabTimesheet` on tabTimesheet.name = sst.time_sheet - join `tabSales Invoice Timesheet` as sit on sit.time_sheet = tabTimesheet.name - join `tabSales Invoice` as si on si.name = sit.parent and si.status != "Cancelled" - join `tabSalary Slip` as ss on ss.name = sst.parent and ss.status != "Cancelled" """.format( + `tabSalary Slip Timesheet` as sst join `tabTimesheet` on `tabTimesheet`.name = sst.time_sheet + join `tabSales Invoice Timesheet` as sit on sit.time_sheet = `tabTimesheet`.name + join `tabSales Invoice` as si on si.name = sit.parent and si.status != 'Cancelled' + join `tabSalary Slip` as ss on ss.name = sst.parent and ss.status != 'Cancelled' """.format( standard_working_hours ) if conditions: @@ -72,23 +72,25 @@ def get_conditions(filters): conditions = [] if filters.get("company"): - conditions.append("tabTimesheet.company={0}".format(frappe.db.escape(filters.get("company")))) + conditions.append("`tabTimesheet`.company={0}".format(frappe.db.escape(filters.get("company")))) if filters.get("start_date"): - conditions.append("tabTimesheet.start_date>='{0}'".format(filters.get("start_date"))) + conditions.append("`tabTimesheet`.start_date>='{0}'".format(filters.get("start_date"))) if filters.get("end_date"): - conditions.append("tabTimesheet.end_date<='{0}'".format(filters.get("end_date"))) + conditions.append("`tabTimesheet`.end_date<='{0}'".format(filters.get("end_date"))) if filters.get("customer_name"): conditions.append("si.customer_name={0}".format(frappe.db.escape(filters.get("customer_name")))) if filters.get("employee"): - conditions.append("tabTimesheet.employee={0}".format(frappe.db.escape(filters.get("employee")))) + conditions.append( + "`tabTimesheet`.employee={0}".format(frappe.db.escape(filters.get("employee"))) + ) if filters.get("project"): conditions.append( - "tabTimesheet.parent_project={0}".format(frappe.db.escape(filters.get("project"))) + "`tabTimesheet`.parent_project={0}".format(frappe.db.escape(filters.get("project"))) ) conditions = " and ".join(conditions) diff --git a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py index 5ceb2c0a81..1d4f96b50a 100644 --- a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py +++ b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py @@ -83,7 +83,7 @@ def get_conditions(filters): ("gst_hsn_code", " and gst_hsn_code=%(gst_hsn_code)s"), ("company_gstin", " and company_gstin=%(company_gstin)s"), ("from_date", " and posting_date >= %(from_date)s"), - ("to_date", "and posting_date <= %(to_date)s"), + ("to_date", " and posting_date <= %(to_date)s"), ): if filters.get(opts[0]): conditions += opts[1] diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index 0f578be175..66ade1f89f 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -47,7 +47,7 @@ def execute(filters=None): s.name = gl.party AND s.irs_1099 = 1 AND gl.fiscal_year = %(fiscal_year)s - AND gl.party_type = "Supplier" + AND gl.party_type = 'Supplier' AND gl.company = %(company)s {conditions} diff --git a/erpnext/regional/report/vat_audit_report/vat_audit_report.py b/erpnext/regional/report/vat_audit_report/vat_audit_report.py index 70f2c0a333..3d486ce650 100644 --- a/erpnext/regional/report/vat_audit_report/vat_audit_report.py +++ b/erpnext/regional/report/vat_audit_report/vat_audit_report.py @@ -65,7 +65,7 @@ class VATAuditReport(object): `tab{doctype}` WHERE docstatus = 1 {where_conditions} - and is_opening = "No" + and is_opening = 'No' ORDER BY posting_date DESC """.format( diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index d5fd946bde..f6877e90af 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -267,7 +267,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): def set_expired_status(): # filter out submitted non expired quotations whose validity has been ended - cond = "qo.docstatus = 1 and qo.status != 'Expired' and qo.valid_till < %s" + cond = "`tabQuotation`.docstatus = 1 and `tabQuotation`.status != 'Expired' and `tabQuotation`.valid_till < %s" # check if those QUO have SO against it so_against_quo = """ SELECT @@ -275,13 +275,18 @@ def set_expired_status(): WHERE so_item.docstatus = 1 and so.docstatus = 1 and so_item.parent = so.name - and so_item.prevdoc_docname = qo.name""" + and so_item.prevdoc_docname = `tabQuotation`.name""" # if not exists any SO, set status as Expired - frappe.db.sql( - """UPDATE `tabQuotation` qo SET qo.status = 'Expired' WHERE {cond} and not exists({so_against_quo})""".format( - cond=cond, so_against_quo=so_against_quo - ), + frappe.db.multisql( + { + "mariadb": """UPDATE `tabQuotation` SET `tabQuotation`.status = 'Expired' WHERE {cond} and not exists({so_against_quo})""".format( + cond=cond, so_against_quo=so_against_quo + ), + "postgres": """UPDATE `tabQuotation` SET status = 'Expired' FROM `tabSales Order`, `tabSales Order Item` WHERE {cond} and not exists({so_against_quo})""".format( + cond=cond, so_against_quo=so_against_quo + ), + }, (nowdate()), ) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 45868fb329..e5e317c506 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -329,7 +329,7 @@ class TestSalesOrder(FrappeTestCase): def test_sales_order_on_hold(self): so = make_sales_order(item_code="_Test Product Bundle Item") - so.db_set("Status", "On Hold") + so.db_set("status", "On Hold") si = make_sales_invoice(so.name) self.assertRaises(frappe.ValidationError, create_dn_against_so, so.name) self.assertRaises(frappe.ValidationError, si.submit) diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py index cc1055c787..928ed80d5c 100644 --- a/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py +++ b/erpnext/selling/report/pending_so_items_for_purchase_request/pending_so_items_for_purchase_request.py @@ -65,7 +65,7 @@ def get_data(): WHERE so.docstatus = 1 and so.name = so_item.parent - and so.status not in ("Closed","Completed","Cancelled") + and so.status not in ('Closed','Completed','Cancelled') GROUP BY so.name,so_item.item_code """, diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 9bde6e2c47..9ffd6dfddc 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -464,7 +464,7 @@ class Company(NestedSet): # reset default company frappe.db.sql( - """update `tabSingles` set value="" + """update `tabSingles` set value='' where doctype='Global Defaults' and field='default_company' and value=%s""", self.name, @@ -472,7 +472,7 @@ class Company(NestedSet): # reset default company frappe.db.sql( - """update `tabSingles` set value="" + """update `tabSingles` set value='' where doctype='Chart of Accounts Importer' and field='company' and value=%s""", self.name, diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index 42ba6ce394..4fc20e6103 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -198,7 +198,7 @@ class EmailDigest(Document): todo_list = frappe.db.sql( """select * - from `tabToDo` where (owner=%s or assigned_by=%s) and status="Open" + from `tabToDo` where (owner=%s or assigned_by=%s) and status='Open' order by field(priority, 'High', 'Medium', 'Low') asc, date asc limit 20""", (user_id, user_id), as_dict=True, diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 78b3939012..7c478bb4ed 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -42,7 +42,7 @@ class TransactionDeletionRecord(Document): def delete_bins(self): frappe.db.sql( - """delete from tabBin where warehouse in + """delete from `tabBin` where warehouse in (select name from tabWarehouse where company=%s)""", self.company, ) @@ -64,7 +64,7 @@ class TransactionDeletionRecord(Document): addresses = ["%s" % frappe.db.escape(addr) for addr in addresses] frappe.db.sql( - """delete from tabAddress where name in ({addresses}) and + """delete from `tabAddress` where name in ({addresses}) and name not in (select distinct dl1.parent from `tabDynamic Link` dl1 inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent and dl1.link_doctype<>dl2.link_doctype)""".format( @@ -80,7 +80,7 @@ class TransactionDeletionRecord(Document): ) frappe.db.sql( - """update tabCustomer set lead_name=NULL where lead_name in ({leads})""".format( + """update `tabCustomer` set lead_name=NULL where lead_name in ({leads})""".format( leads=",".join(leads) ) ) @@ -178,7 +178,7 @@ class TransactionDeletionRecord(Document): else: last = 0 - frappe.db.sql("""update tabSeries set current = %s where name=%s""", (last, prefix)) + frappe.db.sql("""update `tabSeries` set current = %s where name=%s""", (last, prefix)) def delete_version_log(self, doctype, company_fieldname): frappe.db.sql( diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index 73b250db54..ff95c500ab 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -263,9 +263,9 @@ def get_default_contact(out, name): FROM `tabDynamic Link` dl WHERE - dl.link_doctype="Customer" + dl.link_doctype='Customer' AND dl.link_name=%s - AND dl.parenttype = "Contact" + AND dl.parenttype = 'Contact' """, (name), as_dict=1, @@ -289,9 +289,9 @@ def get_default_address(out, name): FROM `tabDynamic Link` dl WHERE - dl.link_doctype="Customer" + dl.link_doctype='Customer' AND dl.link_name=%s - AND dl.parenttype = "Address" + AND dl.parenttype = 'Address' """, (name), as_dict=1, @@ -388,7 +388,7 @@ def notify_customers(delivery_trip): if email_recipients: frappe.msgprint(_("Email sent to {0}").format(", ".join(email_recipients))) - delivery_trip.db_set("email_notification_sent", True) + delivery_trip.db_set("email_notification_sent", 1) else: frappe.msgprint(_("No contacts with email IDs found.")) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index b2f5fb7d20..87fa72d74f 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -1155,7 +1155,7 @@ def check_stock_uom_with_bin(item, stock_uom): bin_list = frappe.db.sql( """ - select * from tabBin where item_code = %s + select * from `tabBin` where item_code = %s and (reserved_qty > 0 or ordered_qty > 0 or indented_qty > 0 or planned_qty > 0) and stock_uom != %s """, @@ -1171,7 +1171,7 @@ def check_stock_uom_with_bin(item, stock_uom): ) # No SLE or documents against item. Bin UOM can be changed safely. - frappe.db.sql("""update tabBin set stock_uom=%s where item_code=%s""", (stock_uom, item)) + frappe.db.sql("""update `tabBin` set stock_uom=%s where item_code=%s""", (stock_uom, item)) def get_item_defaults(item_code, company): diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index d5074e7eb5..3366c737cb 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -381,8 +381,8 @@ class TestItem(FrappeTestCase): frappe.delete_doc_if_exists("Item Attribute", "Test Item Length") frappe.db.sql( - '''delete from `tabItem Variant Attribute` - where attribute="Test Item Length"''' + """delete from `tabItem Variant Attribute` + where attribute='Test Item Length' """ ) frappe.flags.attribute_values = None diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index bd60cf0a5a..23e0f1efaf 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -611,7 +611,7 @@ def get_items_for_stock_reco(warehouse, company): select i.name as item_code, i.item_name, bin.warehouse as warehouse, i.has_serial_no, i.has_batch_no from - tabBin bin, tabItem i + `tabBin` bin, `tabItem` i where i.name = bin.item_code and IFNULL(i.disabled, 0) = 0 @@ -629,7 +629,7 @@ def get_items_for_stock_reco(warehouse, company): select i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no, i.has_batch_no from - tabItem i, `tabItem Default` id + `tabItem` i, `tabItem Default` id where i.name = id.parent and exists( diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index df16643d46..ab784ca107 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -161,8 +161,7 @@ def get_children(doctype, parent=None, company=None, is_root=False): fields = ["name as value", "is_group as expandable"] filters = [ - ["docstatus", "<", "2"], - ['ifnull(`parent_warehouse`, "")', "=", parent], + ["ifnull(`parent_warehouse`, '')", "=", parent], ["company", "in", (company, None, "")], ] diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 7cff85fb57..38ad662b6a 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -890,7 +890,7 @@ def get_item_price(args, item_code, ignore_party=False): return frappe.db.sql( """ select name, price_list_rate, uom from `tabItem Price` {conditions} - order by valid_from desc, batch_no desc, uom desc """.format( + order by valid_from desc, ifnull(batch_no, '') desc, uom desc """.format( conditions=conditions ), args, diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index f19c75f54e..136c78ff58 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -105,7 +105,7 @@ def get_item_warehouse_projected_qty(items_to_consider): for item_code, warehouse, projected_qty in frappe.db.sql( """select item_code, warehouse, projected_qty from tabBin where item_code in ({0}) - and (warehouse != "" and warehouse is not null)""".format( + and (warehouse != '' and warehouse is not null)""".format( ", ".join(["%s"] * len(items_to_consider)) ), items_to_consider, diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py index bcc213905d..b68db356ea 100644 --- a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py +++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py @@ -73,7 +73,7 @@ def get_stock_ledger_entries(report_filters): "Stock Ledger Entry", fields=fields, filters=filters, - order_by="timestamp(posting_date, posting_time) asc, creation asc", + order_by="posting_date asc, posting_time asc, creation asc", ) diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py index 78c6961623..39d84a7d5b 100644 --- a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py @@ -106,7 +106,7 @@ def get_stock_ledger_entries(report_filters): "Stock Ledger Entry", fields=fields, filters=filters, - order_by="timestamp(posting_date, posting_time) asc, creation asc", + order_by="posting_date asc, posting_time asc, creation asc", ) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 409e238657..ef1642e1f9 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -238,7 +238,7 @@ def get_stock_ledger_entries(filters, items): sl_entries = frappe.db.sql( """ SELECT - concat_ws(" ", posting_date, posting_time) AS date, + concat_ws(' ', posting_date, posting_time) AS date, item_code, warehouse, actual_qty, diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index e05d1c3a29..14cedd2e8a 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -118,7 +118,7 @@ def get_reserved_qty(item_code, warehouse): select qty, parent_detail_docname, parent, name from `tabPacked Item` dnpi_in where item_code = %s and warehouse = %s - and parenttype="Sales Order" + and parenttype='Sales Order' and item_code != parent_item and exists (select * from `tabSales Order` so where name = dnpi_in.parent and docstatus = 1 and status != 'Closed') @@ -194,7 +194,7 @@ def get_planned_qty(item_code, warehouse): planned_qty = frappe.db.sql( """ select sum(qty - produced_qty) from `tabWork Order` - where production_item = %s and fg_warehouse = %s and status not in ("Stopped", "Completed", "Closed") + where production_item = %s and fg_warehouse = %s and status not in ('Stopped', 'Completed', 'Closed') and docstatus=1 and qty > produced_qty""", (item_code, warehouse), ) From 05467ffce2dfdfed4d57c43bbc8a51d4ba362953 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 17 Jun 2022 17:23:29 +0530 Subject: [PATCH 089/133] test: use fixture for payment entry test cases (#31390) refactor: use fixture for payment entry test cases --- .../accounts/doctype/payment_entry/test_payment_entry.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index a8211c81f1..9aa1a18ad0 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -4,6 +4,7 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import flt, nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import ( @@ -24,7 +25,10 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde test_dependencies = ["Item"] -class TestPaymentEntry(unittest.TestCase): +class TestPaymentEntry(FrappeTestCase): + def tearDown(self): + frappe.db.rollback() + def test_payment_entry_against_order(self): so = make_sales_order() pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") From 7b383880c6ab802ca1d3b593bfa12e1fd3f6c5fa Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 25 May 2022 15:51:16 +0530 Subject: [PATCH 090/133] feat: helper class for quering Payment Ledger --- erpnext/accounts/utils.py | 193 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 41f3223b54..7ab4f4334f 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1486,3 +1486,196 @@ def delink_original_entry(pl_entry): ) ) query.run() + + +class QueryPaymentLedger(object): + """ + Helper Class for Querying Payment Ledger Entry + """ + + def __init__(self): + self.ple = qb.DocType("Payment Ledger Entry") + + # query result + self.voucher_outstandings = [] + + # query filters + self.vouchers = [] + self.common_filter = [] + self.min_outstanding = None + self.max_outstanding = None + + def reset(self): + # clear filters + self.vouchers.clear() + self.common_filter.clear() + self.min_outstanding = self.max_outstanding = None + + # clear result + self.voucher_outstandings.clear() + + def query_for_outstanding(self): + """ + Database query to fetch voucher amount and voucher outstanding using Common Table Expression + """ + + ple = self.ple + + filter_on_voucher_no = [] + filter_on_against_voucher_no = [] + if self.vouchers: + voucher_types = set([x.voucher_type for x in self.vouchers]) + voucher_nos = set([x.voucher_no for x in self.vouchers]) + + filter_on_voucher_no.append(ple.voucher_type.isin(voucher_types)) + filter_on_voucher_no.append(ple.voucher_no.isin(voucher_nos)) + + filter_on_against_voucher_no.append(ple.against_voucher_type.isin(voucher_types)) + filter_on_against_voucher_no.append(ple.against_voucher_no.isin(voucher_nos)) + + # build outstanding amount filter + filter_on_outstanding_amount = [] + if self.min_outstanding: + if self.min_outstanding > 0: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency >= self.min_outstanding + ) + else: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency <= self.min_outstanding + ) + if self.max_outstanding: + if self.max_outstanding > 0: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency <= self.max_outstanding + ) + else: + filter_on_outstanding_amount.append( + Table("outstanding").amount_in_account_currency >= self.max_outstanding + ) + + # build query for voucher amount + query_voucher_amount = ( + qb.from_(ple) + .select( + ple.account, + ple.voucher_type, + ple.voucher_no, + ple.party_type, + ple.party, + ple.posting_date, + ple.due_date, + ple.account_currency.as_("currency"), + Sum(ple.amount).as_("amount"), + Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"), + ) + .where(ple.delinked == 0) + .where(Criterion.all(filter_on_voucher_no)) + .where(Criterion.all(self.common_filter)) + .groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party) + ) + + # build query for voucher outstanding + query_voucher_outstanding = ( + qb.from_(ple) + .select( + ple.account, + ple.against_voucher_type.as_("voucher_type"), + ple.against_voucher_no.as_("voucher_no"), + ple.party_type, + ple.party, + ple.posting_date, + ple.due_date, + ple.account_currency.as_("currency"), + Sum(ple.amount).as_("amount"), + Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"), + ) + .where(ple.delinked == 0) + .where(Criterion.all(filter_on_against_voucher_no)) + .where(Criterion.all(self.common_filter)) + .groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party) + ) + + # build CTE for combining voucher amount and outstanding + self.cte_query_voucher_amount_and_outstanding = ( + qb.with_(query_voucher_amount, "vouchers") + .with_(query_voucher_outstanding, "outstanding") + .from_(AliasedQuery("vouchers")) + .left_join(AliasedQuery("outstanding")) + .on( + (AliasedQuery("vouchers").account == AliasedQuery("outstanding").account) + & (AliasedQuery("vouchers").voucher_type == AliasedQuery("outstanding").voucher_type) + & (AliasedQuery("vouchers").voucher_no == AliasedQuery("outstanding").voucher_no) + & (AliasedQuery("vouchers").party_type == AliasedQuery("outstanding").party_type) + & (AliasedQuery("vouchers").party == AliasedQuery("outstanding").party) + ) + .select( + Table("vouchers").account, + Table("vouchers").voucher_type, + Table("vouchers").voucher_no, + Table("vouchers").party_type, + Table("vouchers").party, + Table("vouchers").posting_date, + Table("vouchers").amount.as_("invoice_amount"), + Table("vouchers").amount_in_account_currency.as_("invoice_amount_in_account_currency"), + Table("outstanding").amount.as_("outstanding"), + Table("outstanding").amount_in_account_currency.as_("outstanding_in_account_currency"), + (Table("vouchers").amount - Table("outstanding").amount).as_("paid_amount"), + ( + Table("vouchers").amount_in_account_currency - Table("outstanding").amount_in_account_currency + ).as_("paid_amount_in_account_currency"), + Table("vouchers").due_date, + Table("vouchers").currency, + ) + .where(Criterion.all(filter_on_outstanding_amount)) + ) + + # build CTE filter + # only fetch invoices + if self.get_invoices: + self.cte_query_voucher_amount_and_outstanding = ( + self.cte_query_voucher_amount_and_outstanding.having( + qb.Field("outstanding_in_account_currency") > 0 + ) + ) + # only fetch payments + elif self.get_payments: + self.cte_query_voucher_amount_and_outstanding = ( + self.cte_query_voucher_amount_and_outstanding.having( + qb.Field("outstanding_in_account_currency") < 0 + ) + ) + + # execute SQL + self.voucher_outstandings = self.cte_query_voucher_amount_and_outstanding.run(as_dict=True) + + def get_voucher_outstandings( + self, + vouchers=None, + common_filter=None, + min_outstanding=None, + max_outstanding=None, + get_payments=False, + get_invoices=False, + ): + """ + Fetch voucher amount and outstanding amount from Payment Ledger using Database CTE + + vouchers - dict of vouchers to get + common_filter - array of criterions + min_outstanding - filter on minimum total outstanding amount + max_outstanding - filter on maximum total outstanding amount + get_invoices - only fetch vouchers(ledger entries with +ve outstanding) + get_payments - only fetch payments(ledger entries with -ve outstanding) + """ + + self.reset() + self.vouchers = vouchers + self.common_filter = common_filter or [] + self.min_outstanding = min_outstanding + self.max_outstanding = max_outstanding + self.get_payments = get_payments + self.get_invoices = get_invoices + self.query_for_outstanding() + + return self.voucher_outstandings From 65f47bca31611683f2aec89136ae8bb10fa63e49 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 24 May 2022 14:21:33 +0530 Subject: [PATCH 091/133] refactor: payment reconciliation tool PR uses payment ledger for outstanding invoice and unreconcilied cr/dr notes. --- .../payment_reconciliation.py | 228 ++++++++---------- 1 file changed, 107 insertions(+), 121 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index e5b942fb6e..5b2b526e59 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -3,16 +3,26 @@ import frappe -from frappe import _, msgprint +from frappe import _, msgprint, qb from frappe.model.document import Document +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import IfNull from frappe.utils import flt, getdate, nowdate, today import erpnext -from erpnext.accounts.utils import get_outstanding_invoices, reconcile_against_document +from erpnext.accounts.utils import ( + QueryPaymentLedger, + get_outstanding_invoices, + reconcile_against_document, +) from erpnext.controllers.accounts_controller import get_advance_payment_entries class PaymentReconciliation(Document): + def __init__(self, *args, **kwargs): + super(PaymentReconciliation, self).__init__(*args, **kwargs) + self.common_filter_conditions = [] + @frappe.whitelist() def get_unreconciled_entries(self): self.get_nonreconciled_payment_entries() @@ -108,54 +118,58 @@ class PaymentReconciliation(Document): return list(journal_entries) def get_dr_or_cr_notes(self): - condition = self.get_conditions(get_return_invoices=True) - dr_or_cr = ( - "credit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "debit_in_account_currency" - ) - reconciled_dr_or_cr = ( - "debit_in_account_currency" - if dr_or_cr == "credit_in_account_currency" - else "credit_in_account_currency" - ) + self.build_qb_filter_conditions(get_return_invoices=True) + ple = qb.DocType("Payment Ledger Entry") voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" - return frappe.db.sql( - """ SELECT doc.name as reference_name, %(voucher_type)s as reference_type, - (sum(gl.{dr_or_cr}) - sum(gl.{reconciled_dr_or_cr})) as amount, doc.posting_date, - account_currency as currency - FROM `tab{doc}` doc, `tabGL Entry` gl - WHERE - (doc.name = gl.against_voucher or doc.name = gl.voucher_no) - and doc.{party_type_field} = %(party)s - and doc.is_return = 1 and ifnull(doc.return_against, "") = "" - and gl.against_voucher_type = %(voucher_type)s - and doc.docstatus = 1 and gl.party = %(party)s - and gl.party_type = %(party_type)s and gl.account = %(account)s - and gl.is_cancelled = 0 {condition} - GROUP BY doc.name - Having - amount > 0 - ORDER BY doc.posting_date - """.format( - doc=voucher_type, - dr_or_cr=dr_or_cr, - reconciled_dr_or_cr=reconciled_dr_or_cr, - party_type_field=frappe.scrub(self.party_type), - condition=condition or "", - ), - { - "party": self.party, - "party_type": self.party_type, - "voucher_type": voucher_type, - "account": self.receivable_payable_account, - }, - as_dict=1, + if erpnext.get_party_account_type(self.party_type) == "Receivable": + self.common_filter_conditions.append(ple.account_type == "Receivable") + else: + self.common_filter_conditions.append(ple.account_type == "Payable") + self.common_filter_conditions.append(ple.account == self.receivable_payable_account) + + # get return invoices + doc = qb.DocType(voucher_type) + return_invoices = ( + qb.from_(doc) + .select(ConstantColumn(voucher_type).as_("voucher_type"), doc.name.as_("voucher_no")) + .where( + (doc.docstatus == 1) + & (doc[frappe.scrub(self.party_type)] == self.party) + & (doc.is_return == 1) + & (IfNull(doc.return_against, "") == "") + ) + .run(as_dict=True) ) + outstanding_dr_or_cr = [] + if return_invoices: + ple_query = QueryPaymentLedger() + return_outstanding = ple_query.get_voucher_outstandings( + vouchers=return_invoices, + common_filter=self.common_filter_conditions, + min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None, + max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None, + get_payments=True, + ) + + for inv in return_outstanding: + if inv.outstanding != 0: + outstanding_dr_or_cr.append( + frappe._dict( + { + "reference_type": inv.voucher_type, + "reference_name": inv.voucher_no, + "amount": -(inv.outstanding), + "posting_date": inv.posting_date, + "currency": inv.currency, + } + ) + ) + return outstanding_dr_or_cr + def add_payment_entries(self, non_reconciled_payments): self.set("payments", []) @@ -166,10 +180,15 @@ class PaymentReconciliation(Document): def get_invoice_entries(self): # Fetch JVs, Sales and Purchase Invoices for 'invoices' to reconcile against - condition = self.get_conditions(get_invoices=True) + self.build_qb_filter_conditions(get_invoices=True) non_reconciled_invoices = get_outstanding_invoices( - self.party_type, self.party, self.receivable_payable_account, condition=condition + self.party_type, + self.party, + self.receivable_payable_account, + common_filter=self.common_filter_conditions, + min_outstanding=self.minimum_invoice_amount if self.minimum_invoice_amount else None, + max_outstanding=self.maximum_invoice_amount if self.maximum_invoice_amount else None, ) if self.invoice_limit: @@ -329,89 +348,56 @@ class PaymentReconciliation(Document): if not invoices_to_reconcile: frappe.throw(_("No records found in Allocation table")) - def get_conditions(self, get_invoices=False, get_payments=False, get_return_invoices=False): - condition = " and company = '{0}' ".format(self.company) + def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False): + self.common_filter_conditions.clear() + ple = qb.DocType("Payment Ledger Entry") - if self.get("cost_center") and (get_invoices or get_payments or get_return_invoices): - condition = " and cost_center = '{0}' ".format(self.cost_center) + self.common_filter_conditions.append(ple.company == self.company) + + if self.get("cost_center") and (get_invoices or get_return_invoices): + self.common_filter_conditions.append(ple.cost_center == self.cost_center) if get_invoices: - condition += ( - " and posting_date >= {0}".format(frappe.db.escape(self.from_invoice_date)) - if self.from_invoice_date - else "" - ) - condition += ( - " and posting_date <= {0}".format(frappe.db.escape(self.to_invoice_date)) - if self.to_invoice_date - else "" - ) - dr_or_cr = ( - "debit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "credit_in_account_currency" - ) - - if self.minimum_invoice_amount: - condition += " and {dr_or_cr} >= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.minimum_invoice_amount) - ) - if self.maximum_invoice_amount: - condition += " and {dr_or_cr} <= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.maximum_invoice_amount) - ) + if self.from_invoice_date: + self.common_filter_conditions.append(ple.posting_date.gte(self.from_invoice_date)) + if self.to_invoice_date: + self.common_filter_conditions.append(ple.posting_date.lte(self.to_invoice_date)) elif get_return_invoices: - condition = " and doc.company = '{0}' ".format(self.company) - condition += ( - " and doc.posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) - if self.from_payment_date - else "" - ) - condition += ( - " and doc.posting_date <= {0}".format(frappe.db.escape(self.to_payment_date)) - if self.to_payment_date - else "" - ) - dr_or_cr = ( - "debit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "credit_in_account_currency" - ) + if self.from_payment_date: + self.common_filter_conditions.append(ple.posting_date.gte(self.from_payment_date)) + if self.to_payment_date: + self.common_filter_conditions.append(ple.posting_date.lte(self.to_payment_date)) - if self.minimum_invoice_amount: - condition += " and gl.{dr_or_cr} >= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.minimum_payment_amount) - ) - if self.maximum_invoice_amount: - condition += " and gl.{dr_or_cr} <= {amount}".format( - dr_or_cr=dr_or_cr, amount=flt(self.maximum_payment_amount) - ) + def get_conditions(self, get_payments=False): + condition = " and company = '{0}' ".format(self.company) - else: - condition += ( - " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) - if self.from_payment_date - else "" - ) - condition += ( - " and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date)) - if self.to_payment_date - else "" - ) + if self.get("cost_center") and get_payments: + condition = " and cost_center = '{0}' ".format(self.cost_center) - if self.minimum_payment_amount: - condition += ( - " and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount)) - if get_payments - else " and total_debit >= {0}".format(flt(self.minimum_payment_amount)) - ) - if self.maximum_payment_amount: - condition += ( - " and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount)) - if get_payments - else " and total_debit <= {0}".format(flt(self.maximum_payment_amount)) - ) + condition += ( + " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) + if self.from_payment_date + else "" + ) + condition += ( + " and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date)) + if self.to_payment_date + else "" + ) + + if self.minimum_payment_amount: + condition += ( + " and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount)) + if get_payments + else " and total_debit >= {0}".format(flt(self.minimum_payment_amount)) + ) + if self.maximum_payment_amount: + condition += ( + " and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount)) + if get_payments + else " and total_debit <= {0}".format(flt(self.maximum_payment_amount)) + ) return condition From 9cdc388c977ac5f58c109dcc46b136b438f3769a Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 25 May 2022 15:42:02 +0530 Subject: [PATCH 092/133] test: payment reconciliation tool unit test cases for partial reconciliation, return invoice against invoice, invoice against journals and journal against journal have been added --- .../test_payment_reconciliation.py | 510 +++++++++++++++--- 1 file changed, 435 insertions(+), 75 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index d2374b77a6..575ac74a4e 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -4,93 +4,453 @@ import unittest import frappe -from frappe.utils import add_days, getdate +from frappe import qb +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, nowdate +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.party import get_party_account +from erpnext.stock.doctype.item.test_item import create_item -class TestPaymentReconciliation(unittest.TestCase): - @classmethod - def setUpClass(cls): - make_customer() - make_invoice_and_payment() +class TestPaymentReconciliation(FrappeTestCase): + def setUp(self): + self.create_company() + self.create_item() + self.create_customer() + self.clear_old_entries() - def test_payment_reconciliation(self): - payment_reco = frappe.get_doc("Payment Reconciliation") - payment_reco.company = "_Test Company" - payment_reco.party_type = "Customer" - payment_reco.party = "_Test Payment Reco Customer" - payment_reco.receivable_payable_account = "Debtors - _TC" - payment_reco.from_invoice_date = add_days(getdate(), -1) - payment_reco.to_invoice_date = getdate() - payment_reco.from_payment_date = add_days(getdate(), -1) - payment_reco.to_payment_date = getdate() - payment_reco.maximum_invoice_amount = 1000 - payment_reco.maximum_payment_amount = 1000 - payment_reco.invoice_limit = 10 - payment_reco.payment_limit = 10 - payment_reco.bank_cash_account = "_Test Bank - _TC" - payment_reco.cost_center = "_Test Cost Center - _TC" - payment_reco.get_unreconciled_entries() + def tearDown(self): + frappe.db.rollback() - self.assertEqual(len(payment_reco.get("invoices")), 1) - self.assertEqual(len(payment_reco.get("payments")), 1) + def create_company(self): + company = None + if frappe.db.exists("Company", "_Test Payment Reconciliation"): + company = frappe.get_doc("Company", "_Test Payment Reconciliation") + else: + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": "_Test Payment Reconciliation", + "country": "India", + "default_currency": "INR", + "create_chart_of_accounts_based_on": "Standard Template", + "chart_of_accounts": "Standard", + } + ) + company = company.save() - payment_entry = payment_reco.get("payments")[0].reference_name - invoice = payment_reco.get("invoices")[0].invoice_number + self.company = company.name + self.cost_center = company.cost_center + self.warehouse = "All Warehouses - _PR" + self.income_account = "Sales - _PR" + self.expense_account = "Cost of Goods Sold - _PR" + self.debit_to = "Debtors - _PR" + self.creditors = "Creditors - _PR" - payment_reco.allocate_entries( - { - "payments": [payment_reco.get("payments")[0].as_dict()], - "invoices": [payment_reco.get("invoices")[0].as_dict()], - } + # create bank account + if frappe.db.exists("Account", "HDFC - _PR"): + self.bank = "HDFC - _PR" + else: + bank_acc = frappe.get_doc( + { + "doctype": "Account", + "account_name": "HDFC", + "parent_account": "Bank Accounts - _PR", + "company": self.company, + } + ) + bank_acc.save() + self.bank = bank_acc.name + + def create_item(self): + item = create_item( + item_code="_Test PR Item", is_stock_item=0, company=self.company, warehouse=self.warehouse ) - payment_reco.reconcile() + self.item = item if isinstance(item, str) else item.item_code - payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry) - self.assertEqual(payment_entry_doc.get("references")[0].reference_name, invoice) + def create_customer(self): + if frappe.db.exists("Customer", "_Test PR Customer"): + self.customer = "_Test PR Customer" + else: + customer = frappe.new_doc("Customer") + customer.customer_name = "_Test PR Customer" + customer.type = "Individual" + customer.save() + self.customer = customer.name + if frappe.db.exists("Customer", "_Test PR Customer 2"): + self.customer2 = "_Test PR Customer 2" + else: + customer = frappe.new_doc("Customer") + customer.customer_name = "_Test PR Customer 2" + customer.type = "Individual" + customer.save() + self.customer2 = customer.name -def make_customer(): - if not frappe.db.get_value("Customer", "_Test Payment Reco Customer"): - frappe.get_doc( - { - "doctype": "Customer", - "customer_name": "_Test Payment Reco Customer", - "customer_type": "Individual", - "customer_group": "_Test Customer Group", - "territory": "_Test Territory", - } - ).insert() + def create_sales_invoice( + self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False + ): + """ + Helper function to populate default values in sales invoice + """ + sinv = create_sales_invoice( + qty=qty, + rate=rate, + company=self.company, + customer=self.customer, + item_code=self.item, + item_name=self.item, + cost_center=self.cost_center, + warehouse=self.warehouse, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + update_stock=0, + currency="INR", + is_pos=0, + is_return=0, + return_against=None, + income_account=self.income_account, + expense_account=self.expense_account, + do_not_save=do_not_save, + do_not_submit=do_not_submit, + ) + return sinv + def create_payment_entry(self, amount=100, posting_date=nowdate()): + """ + Helper function to populate default values in payment entry + """ + payment = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.bank, + paid_amount=amount, + ) + payment.posting_date = posting_date + return payment -def make_invoice_and_payment(): - si = create_sales_invoice( - customer="_Test Payment Reco Customer", qty=1, rate=690, do_not_save=True - ) - si.cost_center = "_Test Cost Center - _TC" - si.save() - si.submit() + def clear_old_entries(self): + doctype_list = [ + "GL Entry", + "Payment Ledger Entry", + "Sales Invoice", + "Purchase Invoice", + "Payment Entry", + "Journal Entry", + ] + for doctype in doctype_list: + qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run() - pe = frappe.get_doc( - { - "doctype": "Payment Entry", - "payment_type": "Receive", - "party_type": "Customer", - "party": "_Test Payment Reco Customer", - "company": "_Test Company", - "paid_from_account_currency": "INR", - "paid_to_account_currency": "INR", - "source_exchange_rate": 1, - "target_exchange_rate": 1, - "reference_no": "1", - "reference_date": getdate(), - "received_amount": 690, - "paid_amount": 690, - "paid_from": "Debtors - _TC", - "paid_to": "_Test Bank - _TC", - "cost_center": "_Test Cost Center - _TC", - } - ) - pe.insert() - pe.submit() + def create_payment_reconciliation(self): + pr = frappe.new_doc("Payment Reconciliation") + pr.company = self.company + pr.party_type = "Customer" + pr.party = self.customer + pr.receivable_payable_account = get_party_account(pr.party_type, pr.party, pr.company) + pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate() + return pr + + def create_journal_entry( + self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None + ): + je = frappe.new_doc("Journal Entry") + je.posting_date = posting_date or nowdate() + je.company = self.company + je.user_remark = "test" + if not cost_center: + cost_center = self.cost_center + je.set( + "accounts", + [ + { + "account": acc1, + "cost_center": cost_center, + "debit_in_account_currency": amount if amount > 0 else 0, + "credit_in_account_currency": abs(amount) if amount < 0 else 0, + }, + { + "account": acc2, + "cost_center": cost_center, + "credit_in_account_currency": amount if amount > 0 else 0, + "debit_in_account_currency": abs(amount) if amount < 0 else 0, + }, + ], + ) + return je + + def test_filter_min_max(self): + # check filter condition minimum and maximum amount + self.create_sales_invoice(qty=1, rate=300) + self.create_sales_invoice(qty=1, rate=400) + self.create_sales_invoice(qty=1, rate=500) + self.create_payment_entry(amount=300).save().submit() + self.create_payment_entry(amount=400).save().submit() + self.create_payment_entry(amount=500).save().submit() + + pr = self.create_payment_reconciliation() + pr.minimum_invoice_amount = 400 + pr.maximum_invoice_amount = 500 + pr.minimum_payment_amount = 300 + pr.maximum_payment_amount = 600 + pr.get_unreconciled_entries() + self.assertEqual(len(pr.get("invoices")), 2) + self.assertEqual(len(pr.get("payments")), 3) + + pr.minimum_invoice_amount = 300 + pr.maximum_invoice_amount = 600 + pr.minimum_payment_amount = 400 + pr.maximum_payment_amount = 500 + pr.get_unreconciled_entries() + self.assertEqual(len(pr.get("invoices")), 3) + self.assertEqual(len(pr.get("payments")), 2) + + pr.minimum_invoice_amount = ( + pr.maximum_invoice_amount + ) = pr.minimum_payment_amount = pr.maximum_payment_amount = 0 + pr.get_unreconciled_entries() + self.assertEqual(len(pr.get("invoices")), 3) + self.assertEqual(len(pr.get("payments")), 3) + + def test_filter_posting_date(self): + # check filter condition using transaction date + date1 = nowdate() + date2 = add_days(nowdate(), -1) + amount = 100 + self.create_sales_invoice(qty=1, rate=amount, posting_date=date1) + si2 = self.create_sales_invoice( + qty=1, rate=amount, posting_date=date2, do_not_save=True, do_not_submit=True + ) + si2.set_posting_time = 1 + si2.posting_date = date2 + si2.save().submit() + self.create_payment_entry(amount=amount, posting_date=date1).save().submit() + self.create_payment_entry(amount=amount, posting_date=date2).save().submit() + + pr = self.create_payment_reconciliation() + pr.from_invoice_date = pr.to_invoice_date = date1 + pr.from_payment_date = pr.to_payment_date = date1 + + pr.get_unreconciled_entries() + # assert only si and pe are fetched + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 1) + + pr.from_invoice_date = date2 + pr.to_invoice_date = date1 + pr.from_payment_date = date2 + pr.to_payment_date = date1 + + pr.get_unreconciled_entries() + # assert only si and pe are fetched + self.assertEqual(len(pr.get("invoices")), 2) + self.assertEqual(len(pr.get("payments")), 2) + + def test_filter_invoice_limit(self): + # check filter condition - invoice limit + transaction_date = nowdate() + rate = 100 + invoices = [] + payments = [] + for i in range(5): + invoices.append(self.create_sales_invoice(qty=1, rate=rate, posting_date=transaction_date)) + pe = self.create_payment_entry(amount=rate, posting_date=transaction_date).save().submit() + payments.append(pe) + + pr = self.create_payment_reconciliation() + pr.from_invoice_date = pr.to_invoice_date = transaction_date + pr.from_payment_date = pr.to_payment_date = transaction_date + pr.invoice_limit = 2 + pr.payment_limit = 3 + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.get("invoices")), 2) + self.assertEqual(len(pr.get("payments")), 3) + + def test_payment_against_invoice(self): + si = self.create_sales_invoice(qty=1, rate=200) + pe = self.create_payment_entry(amount=55).save().submit() + # second payment entry + self.create_payment_entry(amount=35).save().submit() + + pr = self.create_payment_reconciliation() + + # reconcile multiple payments against invoice + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + si.reload() + self.assertEqual(si.status, "Partly Paid") + # check PR tool output post reconciliation + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 110) + self.assertEqual(pr.get("payments"), []) + + # cancel one PE + pe.reload() + pe.cancel() + pr.get_unreconciled_entries() + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 0) + self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 165) + + def test_payment_against_journal(self): + transaction_date = nowdate() + + sales = "Sales - _PR" + amount = 921 + # debit debtors account to record an invoice + je = self.create_journal_entry(self.debit_to, sales, amount, transaction_date) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je.save() + je.submit() + + self.create_payment_entry(amount=amount, posting_date=transaction_date).save().submit() + + pr = self.create_payment_reconciliation() + pr.minimum_invoice_amount = pr.maximum_invoice_amount = amount + pr.from_invoice_date = pr.to_invoice_date = transaction_date + pr.from_payment_date = pr.to_payment_date = transaction_date + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 0) + self.assertEqual(len(pr.get("payments")), 0) + + def test_journal_against_invoice(self): + transaction_date = nowdate() + amount = 100 + si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + + # credit debtors account to record a payment + je = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date) + je.accounts[1].party_type = "Customer" + je.accounts[1].party = self.customer + je.save() + je.submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + # assert outstanding + si.reload() + self.assertEqual(si.status, "Paid") + self.assertEqual(si.outstanding_amount, 0) + + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 0) + self.assertEqual(len(pr.get("payments")), 0) + + def test_journal_against_journal(self): + transaction_date = nowdate() + sales = "Sales - _PR" + amount = 100 + + # debit debtors account to simulate a invoice + je1 = self.create_journal_entry(self.debit_to, sales, amount, transaction_date) + je1.accounts[0].party_type = "Customer" + je1.accounts[0].party = self.customer + je1.save() + je1.submit() + + # credit debtors account to simulate a payment + je2 = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date) + je2.accounts[1].party_type = "Customer" + je2.accounts[1].party = self.customer + je2.save() + je2.submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + self.assertEqual(pr.get("invoices"), []) + self.assertEqual(pr.get("payments"), []) + + def test_cr_note_against_invoice(self): + transaction_date = nowdate() + amount = 100 + + si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + + cr_note = self.create_sales_invoice( + qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True + ) + cr_note.is_return = 1 + cr_note = cr_note.save().submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + + pr.get_unreconciled_entries() + # check reconciliation tool output + # reconciled invoice and credit note shouldn't show up in selection + self.assertEqual(pr.get("invoices"), []) + self.assertEqual(pr.get("payments"), []) + + # assert outstanding + si.reload() + self.assertEqual(si.status, "Paid") + self.assertEqual(si.outstanding_amount, 0) + + def test_cr_note_partial_against_invoice(self): + transaction_date = nowdate() + amount = 100 + allocated_amount = 80 + + si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + + cr_note = self.create_sales_invoice( + qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True + ) + cr_note.is_return = 1 + cr_note = cr_note.save().submit() + + pr = self.create_payment_reconciliation() + + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.get("invoices")] + payments = [x.as_dict() for x in pr.get("payments")] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.allocation[0].allocated_amount = allocated_amount + pr.reconcile() + + # assert outstanding + si.reload() + self.assertEqual(si.status, "Partly Paid") + self.assertEqual(si.outstanding_amount, 20) + + pr.get_unreconciled_entries() + # check reconciliation tool output + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(len(pr.get("payments")), 1) + self.assertEqual(pr.get("invoices")[0].outstanding_amount, 20) + self.assertEqual(pr.get("payments")[0].amount, 20) From 8c87674c62ea1511516ee10ef1297a2280f79ca6 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 14 Jun 2022 17:46:04 +0530 Subject: [PATCH 093/133] refactor: outstanding_invoice function and helper class outstanding invoice function has been refactored to use payment ledger --- erpnext/accounts/utils.py | 86 +++++++++++---------------------------- 1 file changed, 23 insertions(+), 63 deletions(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 7ab4f4334f..a18391a1eb 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -9,6 +9,8 @@ import frappe import frappe.defaults from frappe import _, qb, throw from frappe.model.meta import get_field_precision +from frappe.query_builder import AliasedQuery, Criterion, Table +from frappe.query_builder.functions import Sum from frappe.query_builder.utils import DocType from frappe.utils import ( cint, @@ -816,7 +818,11 @@ def get_held_invoices(party_type, party): return held_invoices -def get_outstanding_invoices(party_type, party, account, condition=None, filters=None): +def get_outstanding_invoices( + party_type, party, account, common_filter=None, min_outstanding=None, max_outstanding=None +): + + ple = qb.DocType("Payment Ledger Entry") outstanding_invoices = [] precision = frappe.get_precision("Sales Invoice", "outstanding_amount") or 2 @@ -829,76 +835,30 @@ def get_outstanding_invoices(party_type, party, account, condition=None, filters else: party_account_type = erpnext.get_party_account_type(party_type) - if party_account_type == "Receivable": - dr_or_cr = "debit_in_account_currency - credit_in_account_currency" - payment_dr_or_cr = "credit_in_account_currency - debit_in_account_currency" - else: - dr_or_cr = "credit_in_account_currency - debit_in_account_currency" - payment_dr_or_cr = "debit_in_account_currency - credit_in_account_currency" - held_invoices = get_held_invoices(party_type, party) - invoice_list = frappe.db.sql( - """ - select - voucher_no, voucher_type, posting_date, due_date, - ifnull(sum({dr_or_cr}), 0) as invoice_amount, - account_currency as currency - from - `tabGL Entry` - where - party_type = %(party_type)s and party = %(party)s - and account = %(account)s and {dr_or_cr} > 0 - and is_cancelled=0 - {condition} - and ((voucher_type = 'Journal Entry' - and (against_voucher = '' or against_voucher is null)) - or (voucher_type not in ('Journal Entry', 'Payment Entry'))) - group by voucher_type, voucher_no - order by posting_date, name""".format( - dr_or_cr=dr_or_cr, condition=condition or "" - ), - { - "party_type": party_type, - "party": party, - "account": account, - }, - as_dict=True, - ) + common_filter = common_filter or [] + common_filter.append(ple.account_type == party_account_type) + common_filter.append(ple.account == account) + common_filter.append(ple.party_type == party_type) + common_filter.append(ple.party == party) - payment_entries = frappe.db.sql( - """ - select against_voucher_type, against_voucher, - ifnull(sum({payment_dr_or_cr}), 0) as payment_amount - from `tabGL Entry` - where party_type = %(party_type)s and party = %(party)s - and account = %(account)s - and {payment_dr_or_cr} > 0 - and against_voucher is not null and against_voucher != '' - and is_cancelled=0 - group by against_voucher_type, against_voucher - """.format( - payment_dr_or_cr=payment_dr_or_cr - ), - {"party_type": party_type, "party": party, "account": account}, - as_dict=True, + ple_query = QueryPaymentLedger() + invoice_list = ple_query.get_voucher_outstandings( + common_filter=common_filter, + min_outstanding=min_outstanding, + max_outstanding=max_outstanding, + get_invoices=True, ) - pe_map = frappe._dict() - for d in payment_entries: - pe_map.setdefault((d.against_voucher_type, d.against_voucher), d.payment_amount) - for d in invoice_list: - payment_amount = pe_map.get((d.voucher_type, d.voucher_no), 0) - outstanding_amount = flt(d.invoice_amount - payment_amount, precision) + payment_amount = d.invoice_amount - d.outstanding + outstanding_amount = d.outstanding if outstanding_amount > 0.5 / (10**precision): if ( - filters - and filters.get("outstanding_amt_greater_than") - and not ( - outstanding_amount >= filters.get("outstanding_amt_greater_than") - and outstanding_amount <= filters.get("outstanding_amt_less_than") - ) + min_outstanding + and max_outstanding + and not (outstanding_amount >= min_outstanding and outstanding_amount <= max_outstanding) ): continue From ae8aa8f3e75236a59efecc1cc6aaeb1004a0a7b8 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 25 May 2022 16:54:40 +0530 Subject: [PATCH 094/133] refactor: 'get outstanding invoices' popup in payment entry Payment entry has option to select outstanding invoices using a popup form. This change refactors the pop to use payment ledger to fetch +ve outstanding invoices. --- .../doctype/payment_entry/payment_entry.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index f7a57bb96e..3f1d761827 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -6,7 +6,7 @@ import json from functools import reduce import frappe -from frappe import ValidationError, _, scrub, throw +from frappe import ValidationError, _, qb, scrub, throw from frappe.utils import cint, comma_or, flt, getdate, nowdate import erpnext @@ -1195,6 +1195,9 @@ def get_outstanding_reference_documents(args): if args.get("party_type") == "Member": return + ple = qb.DocType("Payment Ledger Entry") + common_filter = [] + # confirm that Supplier is not blocked if args.get("party_type") == "Supplier": supplier_status = get_supplier_block_status(args["party"]) @@ -1216,10 +1219,13 @@ def get_outstanding_reference_documents(args): condition = " and voucher_type={0} and voucher_no={1}".format( frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"]) ) + common_filter.append(ple.voucher_type == args["voucher_type"]) + common_filter.append(ple.voucher_no == args["voucher_no"]) # Add cost center condition if args.get("cost_center"): condition += " and cost_center='%s'" % args.get("cost_center") + common_filter.append(ple.cost_center == args.get("cost_center")) date_fields_dict = { "posting_date": ["from_posting_date", "to_posting_date"], @@ -1231,16 +1237,19 @@ def get_outstanding_reference_documents(args): condition += " and {0} between '{1}' and '{2}'".format( fieldname, args.get(date_fields[0]), args.get(date_fields[1]) ) + common_filter.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) if args.get("company"): condition += " and company = {0}".format(frappe.db.escape(args.get("company"))) + common_filter.append(ple.company == args.get("company")) outstanding_invoices = get_outstanding_invoices( args.get("party_type"), args.get("party"), args.get("party_account"), - filters=args, - condition=condition, + common_filter=common_filter, + min_outstanding=args.get("outstanding_amt_greater_than"), + max_outstanding=args.get("outstanding_amt_less_than"), ) outstanding_invoices = split_invoices_based_on_payment_terms(outstanding_invoices) From 524c175cf062912183a9de5da8af0a10a7bb6d1c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 26 May 2022 16:00:40 +0530 Subject: [PATCH 095/133] refactor: delink gl entry from reconciliation --- .../doctype/journal_entry/journal_entry.py | 9 ++++++--- .../doctype/payment_entry/payment_entry.py | 5 ++++- erpnext/accounts/utils.py | 14 +++++++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 2c16ca3275..787efd2a42 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -800,9 +800,7 @@ class JournalEntry(AccountsController): self.total_amount_in_words = money_in_words(amt, currency) - def make_gl_entries(self, cancel=0, adv_adj=0): - from erpnext.accounts.general_ledger import make_gl_entries - + def build_gl_map(self): gl_map = [] for d in self.get("accounts"): if d.debit or d.credit: @@ -838,7 +836,12 @@ class JournalEntry(AccountsController): item=d, ) ) + return gl_map + def make_gl_entries(self, cancel=0, adv_adj=0): + from erpnext.accounts.general_ledger import make_gl_entries + + gl_map = self.build_gl_map() if self.voucher_type in ("Deferred Revenue", "Deferred Expense"): update_outstanding = "No" else: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 3f1d761827..d8af9db077 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -785,7 +785,7 @@ class PaymentEntry(AccountsController): self.set("remarks", "\n".join(remarks)) - def make_gl_entries(self, cancel=0, adv_adj=0): + def build_gl_map(self): if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"): self.setup_party_account_field() @@ -794,7 +794,10 @@ class PaymentEntry(AccountsController): self.add_bank_gl_entries(gl_entries) self.add_deductions_gl_entries(gl_entries) self.add_tax_gl_entries(gl_entries) + return gl_entries + def make_gl_entries(self, cancel=0, adv_adj=0): + gl_entries = self.build_gl_map() gl_entries = process_gl_map(gl_entries) make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index a18391a1eb..42a748e1aa 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -439,7 +439,8 @@ def reconcile_against_document(args): # cancel advance entry doc = frappe.get_doc(voucher_type, voucher_no) frappe.flags.ignore_party_validation = True - doc.make_gl_entries(cancel=1, adv_adj=1) + gl_map = doc.build_gl_map() + create_payment_ledger_entry(gl_map, cancel=1, adv_adj=1) for entry in entries: check_if_advance_entry_modified(entry) @@ -454,7 +455,9 @@ def reconcile_against_document(args): doc.save(ignore_permissions=True) # re-submit advance entry doc = frappe.get_doc(entry.voucher_type, entry.voucher_no) - doc.make_gl_entries(cancel=0, adv_adj=1) + gl_map = doc.build_gl_map() + create_payment_ledger_entry(gl_map, cancel=0, adv_adj=1) + frappe.flags.ignore_party_validation = False if entry.voucher_type in ("Payment Entry", "Journal Entry"): @@ -1349,7 +1352,9 @@ def check_and_delete_linked_reports(report): frappe.delete_doc("Desktop Icon", icon) -def create_payment_ledger_entry(gl_entries, cancel=0): +def create_payment_ledger_entry( + gl_entries, cancel=0, adv_adj=0, update_outstanding="Yes", from_repost=0 +): if gl_entries: ple = None @@ -1422,6 +1427,9 @@ def create_payment_ledger_entry(gl_entries, cancel=0): if cancel: delink_original_entry(ple) ple.flags.ignore_permissions = 1 + ple.flags.adv_adj = adv_adj + ple.flags.from_repost = from_repost + ple.flags.update_outstanding = update_outstanding ple.submit() From 7312f22f35c66e51587120aa827ad0f144a927a9 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Sun, 29 May 2022 21:33:08 +0530 Subject: [PATCH 096/133] refactor: update voucher outstanding from payment ledger Outstanding amount is updated from payment ledger, only for receivable/payable accounts. For remaining account types, update happens from GL Entry. --- erpnext/accounts/doctype/gl_entry/gl_entry.py | 24 ++-- .../payment_ledger_entry.py | 127 ++++++++++++++++++ erpnext/accounts/general_ledger.py | 11 +- erpnext/accounts/utils.py | 30 +++++ 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index e5fa57df7f..9f716568cc 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -58,16 +58,20 @@ class GLEntry(Document): validate_balance_type(self.account, adv_adj) validate_frozen_account(self.account, adv_adj) - # Update outstanding amt on against voucher - if ( - self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] - and self.against_voucher - and self.flags.update_outstanding == "Yes" - and not frappe.flags.is_reverse_depr_entry - ): - update_outstanding_amt( - self.account, self.party_type, self.party, self.against_voucher_type, self.against_voucher - ) + if frappe.db.get_value("Account", self.account, "account_type") not in [ + "Receivable", + "Payable", + ]: + # Update outstanding amt on against voucher + if ( + self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] + and self.against_voucher + and self.flags.update_outstanding == "Yes" + and not frappe.flags.is_reverse_depr_entry + ): + update_outstanding_amt( + self.account, self.party_type, self.party, self.against_voucher_type, self.against_voucher + ) def check_mandatory(self): mandatory = ["account", "voucher_type", "voucher_no", "company"] diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py index 43e19f4ae7..52df9234e2 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py +++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py @@ -6,6 +6,19 @@ import frappe from frappe import _ from frappe.model.document import Document +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_checks_for_pl_and_bs_accounts, +) +from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import ( + get_dimension_filter_map, +) +from erpnext.accounts.doctype.gl_entry.gl_entry import ( + validate_balance_type, + validate_frozen_account, +) +from erpnext.accounts.utils import update_voucher_outstanding +from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError + class PaymentLedgerEntry(Document): def validate_account(self): @@ -18,5 +31,119 @@ class PaymentLedgerEntry(Document): if not valid_account: frappe.throw(_("{0} account is not of type {1}").format(self.account, self.account_type)) + def validate_account_details(self): + """Account must be ledger, active and not freezed""" + + ret = frappe.db.sql( + """select is_group, docstatus, company + from tabAccount where name=%s""", + self.account, + as_dict=1, + )[0] + + if ret.is_group == 1: + frappe.throw( + _( + """{0} {1}: Account {2} is a Group Account and group accounts cannot be used in transactions""" + ).format(self.voucher_type, self.voucher_no, self.account) + ) + + if ret.docstatus == 2: + frappe.throw( + _("{0} {1}: Account {2} is inactive").format(self.voucher_type, self.voucher_no, self.account) + ) + + if ret.company != self.company: + frappe.throw( + _("{0} {1}: Account {2} does not belong to Company {3}").format( + self.voucher_type, self.voucher_no, self.account, self.company + ) + ) + + def validate_allowed_dimensions(self): + dimension_filter_map = get_dimension_filter_map() + for key, value in dimension_filter_map.items(): + dimension = key[0] + account = key[1] + + if self.account == account: + if value["is_mandatory"] and not self.get(dimension): + frappe.throw( + _("{0} is mandatory for account {1}").format( + frappe.bold(frappe.unscrub(dimension)), frappe.bold(self.account) + ), + MandatoryAccountDimensionError, + ) + + if value["allow_or_restrict"] == "Allow": + if self.get(dimension) and self.get(dimension) not in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(self.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(self.account), + ), + InvalidAccountDimensionError, + ) + else: + if self.get(dimension) and self.get(dimension) in value["allowed_dimensions"]: + frappe.throw( + _("Invalid value {0} for {1} against account {2}").format( + frappe.bold(self.get(dimension)), + frappe.bold(frappe.unscrub(dimension)), + frappe.bold(self.account), + ), + InvalidAccountDimensionError, + ) + + def validate_dimensions_for_pl_and_bs(self): + account_type = frappe.db.get_value("Account", self.account, "report_type") + + for dimension in get_checks_for_pl_and_bs_accounts(): + if ( + account_type == "Profit and Loss" + and self.company == dimension.company + and dimension.mandatory_for_pl + and not dimension.disabled + ): + if not self.get(dimension.fieldname): + frappe.throw( + _("Accounting Dimension {0} is required for 'Profit and Loss' account {1}.").format( + dimension.label, self.account + ) + ) + + if ( + account_type == "Balance Sheet" + and self.company == dimension.company + and dimension.mandatory_for_bs + and not dimension.disabled + ): + if not self.get(dimension.fieldname): + frappe.throw( + _("Accounting Dimension {0} is required for 'Balance Sheet' account {1}.").format( + dimension.label, self.account + ) + ) + def validate(self): self.validate_account() + + def on_update(self): + adv_adj = self.flags.adv_adj + if not self.flags.from_repost: + self.validate_account_details() + self.validate_dimensions_for_pl_and_bs() + self.validate_allowed_dimensions() + validate_balance_type(self.account, adv_adj) + validate_frozen_account(self.account, adv_adj) + + # update outstanding amount + if ( + self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] + and self.flags.update_outstanding == "Yes" + and not frappe.flags.is_reverse_depr_entry + ): + update_voucher_outstanding( + self.against_voucher_type, self.against_voucher_no, self.account, self.party_type, self.party + ) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index b0513f16a5..8146804705 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -35,7 +35,13 @@ def make_gl_entries( validate_disabled_accounts(gl_map) gl_map = process_gl_map(gl_map, merge_entries) if gl_map and len(gl_map) > 1: - create_payment_ledger_entry(gl_map) + create_payment_ledger_entry( + gl_map, + cancel=0, + adv_adj=adv_adj, + update_outstanding=update_outstanding, + from_repost=from_repost, + ) save_entries(gl_map, adv_adj, update_outstanding, from_repost) # Post GL Map proccess there may no be any GL Entries elif gl_map: @@ -482,6 +488,9 @@ def make_reverse_gl_entries( if gl_entries: create_payment_ledger_entry(gl_entries, cancel=1) + create_payment_ledger_entry( + gl_entries, cancel=1, adv_adj=adv_adj, update_outstanding=update_outstanding + ) validate_accounting_period(gl_entries) check_freezing_date(gl_entries[0]["posting_date"], adv_adj) set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"]) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 42a748e1aa..8daff9d193 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1433,6 +1433,36 @@ def create_payment_ledger_entry( ple.submit() +def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, party): + ple = frappe.qb.DocType("Payment Ledger Entry") + vouchers = [frappe._dict({"voucher_type": voucher_type, "voucher_no": voucher_no})] + common_filter = [] + if account: + common_filter.append(ple.account == account) + + if party_type: + common_filter.append(ple.party_type == party_type) + + if party: + common_filter.append(ple.party == party) + + ple_query = QueryPaymentLedger() + + # on cancellation outstanding can be an empty list + voucher_outstanding = ple_query.get_voucher_outstandings(vouchers, common_filter=common_filter) + if voucher_type in ["Sales Invoice", "Purchase Invoice", "Fees"] and voucher_outstanding: + outstanding = voucher_outstanding[0] + ref_doc = frappe.get_doc(voucher_type, voucher_no) + + # Didn't use db_set for optimisation purpose + ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"] + frappe.db.set_value( + voucher_type, voucher_no, "outstanding_amount", outstanding["outstanding_in_account_currency"] + ) + + ref_doc.set_status(update=True) + + def delink_original_entry(pl_entry): if pl_entry: ple = qb.DocType("Payment Ledger Entry") From 3a238b4daa0441679bf5df43275ba770e651f14e Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 10 Jun 2022 06:55:02 +0530 Subject: [PATCH 097/133] docs: specification of payment ledger --- erpnext/accounts/README.md | 40 +++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/README.md b/erpnext/accounts/README.md index da1f201570..15f7039207 100644 --- a/erpnext/accounts/README.md +++ b/erpnext/accounts/README.md @@ -10,4 +10,42 @@ Entries are: - Sales Invoice (Itemised) - Purchase Invoice (Itemised) -All accounting entries are stored in the `General Ledger` \ No newline at end of file +All accounting entries are stored in the `General Ledger` + +## Payment Ledger +Transactions on Receivable and Payable Account types will also be stored in `Payment Ledger`. This is so that payment reconciliation process only requires update on this ledger. + +### Key Fields +| Field | Description | +|----------------------|----------------------------------| +| `account_type` | Receivable/Payable | +| `account` | Accounting head | +| `party` | Party Name | +| `voucher_no` | Voucher No | +| `against_voucher_no` | Linked voucher(secondary effect) | +| `amount` | can be +ve/-ve | + +### Design +`debit` and `credit` have been replaced with `account_type` and `amount`. `against_voucher_no` is populated for all entries. So, outstanding amount can be calculated by summing up amount only using `against_voucher_no`. + +Ex: +1. Consider an invoice for ₹100 and a partial payment of ₹80 against that invoice. Payment Ledger will have following entries. + +| voucher_no | against_voucher_no | amount | +|------------|--------------------|--------| +| SINV-01 | SINV-01 | 100 | +| PAY-01 | SINV-01 | -80 | + + +2. Reconcile a Credit Note against an invoice using a Journal Entry + +An invoice for ₹100 partially reconciled against a credit of ₹70 using a Journal Entry. Payment Ledger will have the following entries. + +| voucher_no | against_voucher_no | amount | +|------------|--------------------|--------| +| SINV-01 | SINV-01 | 100 | +| | | | +| CR-NOTE-01 | CR-NOTE-01 | -70 | +| | | | +| JE-01 | CR-NOTE-01 | +70 | +| JE-01 | SINV-01 | -70 | From 0097a2b60c9b0fbdc69a5f460a5edee5f8354578 Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <99652762+nihantra@users.noreply.github.com> Date: Fri, 17 Jun 2022 18:35:11 +0530 Subject: [PATCH 098/133] Update bank_clearance_summary.py --- .../report/bank_clearance_summary/bank_clearance_summary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py index 20f7643a1c..9d2deea523 100644 --- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py +++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py @@ -43,7 +43,7 @@ def get_columns(): "options": "Account", "width": 170, }, - {"label": _("Amount"), "fieldname": "amount", "width": 120}, + {"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120}, ] return columns From 02f9441e1ab74a83c355cd546d1c0669dc21ef7e Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 17 Jun 2022 18:54:42 +0530 Subject: [PATCH 099/133] fix: Quotation lost update --- erpnext/selling/doctype/quotation/quotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index f6877e90af..bad2d2b2f4 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -127,7 +127,7 @@ class Quotation(SellingController): @frappe.whitelist() def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None): - if not self.has_sales_order(): + if not self.is_fully_ordered() or self.is_partially_ordered(): get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"]) lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons] frappe.db.set(self, "status", "Lost") From e457288dbaceda121fb35eeeacc4189a15d0f9f6 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 17 Jun 2022 18:56:53 +0530 Subject: [PATCH 100/133] chore: fix condition --- erpnext/selling/doctype/quotation/quotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index bad2d2b2f4..4fa4515a0f 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -127,7 +127,7 @@ class Quotation(SellingController): @frappe.whitelist() def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None): - if not self.is_fully_ordered() or self.is_partially_ordered(): + if not (self.is_fully_ordered() or self.is_partially_ordered()): get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"]) lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons] frappe.db.set(self, "status", "Lost") From ea28ed1bb3cddbf16b1ac3cb3460cb16caf1eefd Mon Sep 17 00:00:00 2001 From: Conor Date: Fri, 17 Jun 2022 10:47:48 -0500 Subject: [PATCH 101/133] refactor: if() to CASE WHEN (#31360) * refactor: if() to CASE WHEN * fix: remove duplicate order by * fix: remove extraneous table * style: reformat to black spec Co-authored-by: Ankush Menat --- erpnext/controllers/queries.py | 36 +++++++++---------- erpnext/controllers/status_updater.py | 8 ++--- .../doctype/payroll_entry/payroll_entry.py | 4 +-- erpnext/projects/doctype/project/project.py | 4 +-- erpnext/stock/doctype/pick_list/pick_list.py | 2 +- erpnext/stock/utils.py | 2 +- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 5ba314ecf0..243ebb66e2 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -29,8 +29,8 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): or employee_name like %(txt)s) {fcond} {mcond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end), + (case when locate(%(_txt)s, employee_name) > 0 then locate(%(_txt)s, employee_name) else 99999 end), idx desc, name, employee_name limit %(page_len)s offset %(start)s""".format( @@ -60,9 +60,9 @@ def lead_query(doctype, txt, searchfield, start, page_len, filters): or company_name like %(txt)s) {mcond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, lead_name), locate(%(_txt)s, lead_name), 99999), - if(locate(%(_txt)s, company_name), locate(%(_txt)s, company_name), 99999), + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end), + (case when locate(%(_txt)s, lead_name) > 0 then locate(%(_txt)s, lead_name) else 99999 end), + (case when locate(%(_txt)s, company_name) > 0 then locate(%(_txt)s, company_name) else 99999 end), idx desc, name, lead_name limit %(page_len)s offset %(start)s""".format( @@ -96,8 +96,8 @@ def customer_query(doctype, txt, searchfield, start, page_len, filters): and ({scond}) and disabled=0 {fcond} {mcond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, customer_name), locate(%(_txt)s, customer_name), 99999), + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end), + (case when locate(%(_txt)s, customer_name) > 0 then locate(%(_txt)s, customer_name) else 99999 end), idx desc, name, customer_name limit %(page_len)s offset %(start)s""".format( @@ -130,11 +130,11 @@ def supplier_query(doctype, txt, searchfield, start, page_len, filters): where docstatus < 2 and ({key} like %(txt)s or supplier_name like %(txt)s) and disabled=0 - and (on_hold = 0 or (on_hold = 1 and CURDATE() > release_date)) + and (on_hold = 0 or (on_hold = 1 and CURRENT_DATE > release_date)) {mcond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999), + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end), + (case when locate(%(_txt)s, supplier_name) > 0 then locate(%(_txt)s, supplier_name) else 99999 end), idx desc, name, supplier_name limit %(page_len)s offset %(start)s""".format( @@ -305,15 +305,15 @@ def bom(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select {fields} - from tabBOM - where tabBOM.docstatus=1 - and tabBOM.is_active=1 - and tabBOM.`{key}` like %(txt)s + from `tabBOM` + where `tabBOM`.docstatus=1 + and `tabBOM`.is_active=1 + and `tabBOM`.`{key}` like %(txt)s {fcond} {mcond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end), idx desc, name - limit %(start)s, %(page_len)s """.format( + limit %(page_len)s offset %(start)s""".format( fields=", ".join(fields), fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"), mcond=get_match_cond(doctype).replace("%", "%%"), @@ -348,8 +348,8 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters): `tabProject`.status not in ('Completed', 'Cancelled') and {cond} {scond} {match_cond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - idx desc, + (case when locate(%(_txt)s, `tabProject`.name) > 0 then locate(%(_txt)s, `tabProject`.name) else 99999 end), + `tabProject`.idx desc, `tabProject`.name asc limit {page_len} offset {start}""".format( fields=", ".join(["`tabProject`.{0}".format(f) for f in fields]), diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 76a25a0de1..197d2ba2dc 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -443,7 +443,7 @@ class StatusUpdater(Document): """update `tab%(target_parent_dt)s` set %(target_parent_field)s = round( ifnull((select - ifnull(sum(if(abs(%(target_ref_field)s) > abs(%(target_field)s), abs(%(target_field)s), abs(%(target_ref_field)s))), 0) + ifnull(sum(case when abs(%(target_ref_field)s) > abs(%(target_field)s) then abs(%(target_field)s) else abs(%(target_ref_field)s) end), 0) / sum(abs(%(target_ref_field)s)) * 100 from `tab%(target_dt)s` where parent='%(name)s' having sum(abs(%(target_ref_field)s)) > 0), 0), 6) %(update_modified)s @@ -455,9 +455,9 @@ class StatusUpdater(Document): if args.get("status_field"): frappe.db.sql( """update `tab%(target_parent_dt)s` - set %(status_field)s = if(%(target_parent_field)s<0.001, - 'Not %(keyword)s', if(%(target_parent_field)s>=99.999999, - 'Fully %(keyword)s', 'Partly %(keyword)s')) + set %(status_field)s = (case when %(target_parent_field)s<0.001 then 'Not %(keyword)s' + else case when %(target_parent_field)s>=99.999999 then 'Fully %(keyword)s' + else 'Partly %(keyword)s' end end) where name='%(name)s'""" % args ) diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index a0cef7038a..86a8c12a58 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -1035,8 +1035,8 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): {emp_cond} {fcond} {mcond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end), + (case when locate(%(_txt)s, employee_name) > 0 then locate(%(_txt)s, employee_name) else 99999 end), idx desc, name, employee_name limit %(page_len)s offset %(start)s""".format( diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index c613fe633d..7aa56de1bc 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -387,8 +387,8 @@ def get_users_for_project(doctype, txt, searchfield, start, page_len, filters): or full_name like %(txt)s) {fcond} {mcond} order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, full_name), locate(%(_txt)s, full_name), 99999), + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end), + (case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end) idx desc, name, full_name limit %(page_len)s offset %(start)s""".format( diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 7dc3ba049c..d31d695c80 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -699,7 +699,7 @@ def get_pending_work_orders(doctype, txt, searchfield, start, page_length, filte AND `company` = %(company)s AND `name` like %(txt)s ORDER BY - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), name + (case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end) name LIMIT %(start)s, %(page_length)s""", { diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 6d8fdaa404..9fb3be5188 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -499,7 +499,7 @@ def add_additional_uom_columns(columns, result, include_uom, conversion_factors) def get_incoming_outgoing_rate_for_cancel(item_code, voucher_type, voucher_no, voucher_detail_no): outgoing_rate = frappe.db.sql( - """SELECT abs(stock_value_difference / actual_qty) + """SELECT CASE WHEN actual_qty = 0 THEN 0 ELSE abs(stock_value_difference / actual_qty) END FROM `tabStock Ledger Entry` WHERE voucher_type = %s and voucher_no = %s and item_code = %s and voucher_detail_no = %s From 0320b59ea615b372c9d627db54d272607b104878 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 20 Jun 2022 16:25:34 +0530 Subject: [PATCH 102/133] chore: Clear Progress section for completed logs & `on_submit` UX - Delete `BOM Update Batch` table on 'Completed' log, to save space - Hide Progress section on 'Completed' log - Enqueue `on_submit` for 'Update Cost' job, getting leaf boms could take time for huge DBs. Users have to wait for screen to unfreeze. - Add error handling to `process_boms_cost_level_wise` (Called via cron job and on submit, both in background) --- .../bom_update_log/bom_update_log.json | 10 ++-- .../doctype/bom_update_log/bom_update_log.py | 59 +++++++++++-------- 2 files changed, 42 insertions(+), 27 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 c32e383b08..a926e69ee6 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -7,11 +7,11 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "current_bom", - "new_bom", - "column_break_3", "update_type", "status", + "column_break_3", + "current_bom", + "new_bom", "error_log", "progress_section", "current_level", @@ -37,6 +37,7 @@ "options": "BOM" }, { + "depends_on": "eval:doc.update_type === \"Replace BOM\"", "fieldname": "column_break_3", "fieldtype": "Column Break" }, @@ -87,6 +88,7 @@ "options": "BOM Update Batch" }, { + "depends_on": "eval:doc.status !== \"Completed\"", "fieldname": "current_level", "fieldtype": "Int", "label": "Current Level" @@ -96,7 +98,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-06-06 15:15:23.883251", + "modified": "2022-06-20 15:43:55.696388", "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 9c9c24044a..af5ff8e1c2 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -77,7 +77,11 @@ class BOMUpdateLog(Document): now=frappe.flags.in_test, ) else: - process_boms_cost_level_wise(self) + frappe.enqueue( + method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.process_boms_cost_level_wise", + update_doc=self, + now=frappe.flags.in_test, + ) def run_replace_bom_job( @@ -112,28 +116,31 @@ def process_boms_cost_level_wise( current_boms = {} values = {} - if update_doc.status == "Queued": - # First level yet to process. On Submit. - current_level = 0 - current_boms = get_leaf_boms() - values = { - "processed_boms": json.dumps({}), - "status": "In Progress", - "current_level": current_level, - } - else: - # Resume next level. via Cron Job. - if not parent_boms: - return + try: + if update_doc.status == "Queued": + # First level yet to process. On Submit. + current_level = 0 + current_boms = get_leaf_boms() + values = { + "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 + current_level = cint(update_doc.current_level) + 1 - # Process the next level BOMs. Stage parents as current BOMs. - current_boms = parent_boms.copy() - values = {"current_level": current_level} + # Process the next level BOMs. Stage parents as current BOMs. + current_boms = parent_boms.copy() + 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) + set_values_in_log(update_doc.name, values, commit=True) + queue_bom_cost_jobs(current_boms, update_doc, current_level) + except Exception: + handle_exception(update_doc) def queue_bom_cost_jobs( @@ -199,16 +206,22 @@ 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 + # Unset processed BOMs (it is used for next level BOMs) & change status if log is complete + status = "Completed" if not parent_boms else "In Progress" + processed_boms = json.dumps([] if not parent_boms else processed_boms) set_values_in_log( log.name, values={ - "processed_boms": json.dumps([] if not parent_boms else processed_boms), - "status": "Completed" if not parent_boms else "In Progress", + "processed_boms": processed_boms, + "status": status, }, commit=True, ) + # clear progress section + if status == "Completed": + frappe.db.delete("BOM Update Batch", {"parent": log.name}) + 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), parent_boms=parent_boms From 8f373930448af79e7222eb78eb7d8c364a4b7fd0 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 20 Jun 2022 22:00:32 +0530 Subject: [PATCH 103/133] test: Add test case --- .../doctype/sales_invoice/test_sales_invoice.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index b8154dd1f9..1b20c29f94 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1583,6 +1583,17 @@ class TestSalesInvoice(unittest.TestCase): self.assertTrue(gle) + def test_invoice_exchange_rate(self): + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=1, + do_not_save=1, + ) + + self.assertRaises(frappe.ValidationError, si.save) + def test_invalid_currency(self): # Customer currency = USD From 4cf2225a29c0d1c1a1967de20c78b411f055bf53 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 21 Jun 2022 14:03:05 +0530 Subject: [PATCH 104/133] chore: Implement Log clearing interface in BOM Update Log - Implement Log clearing interface in BOM Update Log - Add additional info in sidebar: Log clearing only happens for 'Update Cost' type logs - 'Replace BOM' logs have important info and is used in BOM timeline, so we'll let users decide if they wanna keep or discard it --- .../doctype/bom_update_log/bom_update_log.py | 13 ++++++++++++ .../bom_update_log/bom_update_log_list.js | 21 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 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 af5ff8e1c2..c3f52d4583 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now from frappe.utils import cint, cstr from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( @@ -22,6 +24,17 @@ class BOMMissingError(frappe.ValidationError): class BOMUpdateLog(Document): + @staticmethod + def clear_old_logs(days=None): + days = days or 90 + table = DocType("BOM Update Log") + frappe.db.delete( + table, + filters=( + (table.modified < (Now() - Interval(days=days))) & (table.update_type == "Update Cost") + ), + ) + def validate(self): if self.update_type == "Replace BOM": self.validate_boms_are_specified() diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js index e39b5637c7..bc709d8fc4 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js @@ -1,6 +1,6 @@ frappe.listview_settings['BOM Update Log'] = { add_fields: ["status"], - get_indicator: function(doc) { + get_indicator: (doc) => { let status_map = { "Queued": "orange", "In Progress": "blue", @@ -9,5 +9,22 @@ frappe.listview_settings['BOM Update Log'] = { }; return [__(doc.status), status_map[doc.status], "status,=," + doc.status]; - } + }, + onload: () => { + if (!frappe.model.can_write("Log Settings")) { + return; + } + + let sidebar_entry = $( + '' + ).appendTo(cur_list.page.sidebar); + let message = __("Note: Automatic log deletion only applies to logs of type Update Cost"); + $(`
${message}
`).appendTo(sidebar_entry); + + frappe.require("logtypes.bundle.js", () => { + frappe.utils.logtypes.show_log_retention_message(cur_list.doctype); + }); + + + }, }; \ No newline at end of file From dc2830da4d35045377425a8e84fd8a19eb48134f Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 21 Jun 2022 13:58:00 +0530 Subject: [PATCH 105/133] fix: set default_bom for item --- erpnext/manufacturing/doctype/bom/bom.py | 1 + erpnext/manufacturing/doctype/bom/test_bom.py | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 4c88eca8f6..b29f6710e1 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -445,6 +445,7 @@ class BOM(WebsiteGenerator): and self.is_active ): frappe.db.set(self, "is_default", 1) + frappe.db.set_value("Item", self.item, "default_bom", self.name) else: frappe.db.set(self, "is_default", 0) item = frappe.get_doc("Item", self.item) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 182a20c6bb..860512c91d 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -559,6 +559,42 @@ class TestBOM(FrappeTestCase): bom.submit() self.assertEqual(bom.items[0].rate, 42) + def test_set_default_bom_for_item_having_single_bom(self): + from erpnext.stock.doctype.item.test_item import make_item + + fg_item = make_item(properties={"is_stock_item": 1}) + bom_item = make_item(properties={"is_stock_item": 1}) + + # Step 1: Create BOM + bom = frappe.new_doc("BOM") + bom.item = fg_item.item_code + bom.quantity = 1 + bom.append( + "items", + { + "item_code": bom_item.item_code, + "qty": 1, + "uom": bom_item.stock_uom, + "stock_uom": bom_item.stock_uom, + "rate": 100.0, + }, + ) + bom.save() + bom.submit() + self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name) + + # Step 2: Uncheck is_active field + bom.is_active = 0 + bom.save() + bom.reload() + self.assertIsNone(frappe.get_value("Item", fg_item.item_code, "default_bom")) + + # Step 3: Check is_active field + bom.is_active = 1 + bom.save() + bom.reload() + self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) From ce1b4e40a15eb88a715d0fd25089ceb8df5f3cde Mon Sep 17 00:00:00 2001 From: Vladislav Date: Tue, 21 Jun 2022 16:55:05 +0300 Subject: [PATCH 106/133] fix: update ru translate (#31404) * 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 * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv * Update ru.csv --- erpnext/translations/ru.csv | 84 ++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 743b29493c..a4bfb86c01 100644 --- a/erpnext/translations/ru.csv +++ b/erpnext/translations/ru.csv @@ -44,7 +44,7 @@ Accessable Value,Доступная стоимость, Account,Аккаунт, Account Number,Номер аккаунта, Account Number {0} already used in account {1},"Номер счета {0}, уже использованный в учетной записи {1}", -Account Pay Only,Счет Оплатить только, +Account Pay Only,Только оплатить счет, Account Type,Тип учетной записи, Account Type for {0} must be {1},Тип счета для {0} должен быть {1}, "Account balance already in Credit, you are not allowed to set 'Balance Must Be' as 'Debit'","Баланс счета в Кредите, запрещена установка 'Баланс должен быть' как 'Дебет'", @@ -117,7 +117,7 @@ Add Item,Добавить продукт, Add Items,Добавить продукты, Add Leads,Добавить лид, Add Multiple Tasks,Добавить несколько задач, -Add Row,Добавить ряд, +Add Row,Добавить строку, Add Sales Partners,Добавить партнеров по продажам, Add Serial No,Добавить серийный номер, Add Students,Добавить студентов, @@ -692,7 +692,7 @@ Created {0} scorecards for {1} between: ,Созданы {0} оценочные Creating Company and Importing Chart of Accounts,Создание компании и импорт плана счетов, Creating Fees,Создание сборов, Creating Payment Entries......,Создание платежных записей......, -Creating Salary Slips...,Создание зарплатных листков..., +Creating Salary Slips...,Создание зарплатных ведомостей..., Creating student groups,Создание групп студентов, Creating {0} Invoice,Создание {0} счета, Credit,Кредит, @@ -995,7 +995,7 @@ Expenses,Расходы, Expenses Included In Asset Valuation,"Расходы, включенные в оценку активов", Expenses Included In Valuation,"Затрат, включаемых в оценке", Expired Batches,Просроченные партии, -Expires On,Годен до, +Expires On,Актуален до, Expiring On,Срок действия, Expiry (In Days),Срок действия (в днях), Explore,Обзор, @@ -1411,7 +1411,7 @@ Lab Test UOM,Лабораторная проверка UOM, Lab Tests and Vital Signs,Лабораторные тесты и жизненные знаки, Lab result datetime cannot be before testing datetime,Лабораторный результат datetime не может быть до тестирования даты и времени, Lab testing datetime cannot be before collection datetime,Лабораторное тестирование datetime не может быть до даты сбора данных, -Label,Ярлык, +Label,Метка, Laboratory,Лаборатория, Language Name,Название языка, Large,Большой, @@ -2874,7 +2874,7 @@ Supplier Id,Id поставщика, Supplier Invoice Date cannot be greater than Posting Date,"Дата Поставщик Счет не может быть больше, чем Дата публикации", Supplier Invoice No,Поставщик Счет №, Supplier Invoice No exists in Purchase Invoice {0},Номер счета поставщика отсутствует в счете на покупку {0}, -Supplier Name,наименование поставщика, +Supplier Name,Наименование поставщика, Supplier Part No,Деталь поставщика №, Supplier Quotation,Предложение поставщика, Supplier Scorecard,Оценочная карта поставщика, @@ -3091,7 +3091,7 @@ Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total, Total Payments,Всего платежей, Total Present,Итого Текущая, Total Qty,Общее количество, -Total Quantity,Общая численность, +Total Quantity,Общее количество, Total Revenue,Общий доход, Total Student,Всего учеников, Total Target,Всего целей, @@ -3498,7 +3498,7 @@ Postal,Почтовый, Postal Code,Почтовый индекс, Previous,Предыдущая, Provider,Поставщик, -Read Only,Только чтения, +Read Only,Только чтение, Recipient,Сторона-реципиент, Reviews,Отзывы, Sender,Отправитель, @@ -3879,7 +3879,7 @@ 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 expired allocation can be cancelled,Отменить можно только просроченное распределение, Only users with the {0} role can create backdated leave applications,Только пользователи с ролью {0} могут создавать оставленные приложения с задним сроком действия, Open,Открыт, Open Contact,Открытый контакт, @@ -4046,7 +4046,7 @@ Server Error,Ошибка сервера, Service Level Agreement has been changed to {0}.,Соглашение об уровне обслуживания изменено на {0}., Service Level Agreement was reset.,Соглашение об уровне обслуживания было сброшено., Service Level Agreement with Entity Type {0} and Entity {1} already exists.,Соглашение об уровне обслуживания с типом объекта {0} и объектом {1} уже существует., -Set,Задать, +Set,Комплект, Set Meta Tags,Установить метатеги, Set {0} in company {1},Установить {0} в компании {1}, Setup,Настройки, @@ -4059,7 +4059,7 @@ Show Stock Ageing Data,Показать данные о старении зап Show Warehouse-wise Stock,Показать складской запас, Size,Размер, Something went wrong while evaluating the quiz.,Что-то пошло не так при оценке теста., -Sr,Sr, +Sr,№, Start,Начать, Start Date cannot be before the current date,Дата начала не может быть раньше текущей даты, Start Time,Время начала, @@ -4513,7 +4513,7 @@ Mandatory For Profit and Loss Account,Обязательно для счета Accounting Period,Период учета, Period Name,Название периода, Closed Documents,Закрытые документы, -Accounts Settings,Настройки аккаунта, +Accounts Settings,Настройка счетов, Settings for Accounts,Настройки для счетов, Make Accounting Entry For Every Stock Movement,Создавать бухгалтерские проводки при каждом перемещении запасов, Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts,"Пользователи с этой ролью могут замороживать счета, а также создавать / изменять бухгалтерские проводки замороженных счетов", @@ -5084,8 +5084,8 @@ Allow Zero Valuation Rate,Разрешить нулевую оценку, Item Tax Rate,Ставка налогов на продукт, Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges,Налоговый Подробная таблица выбирается из мастера элемента в виде строки и хранится в этой области.\n Используется по налогам и сборам, Purchase Order Item,Заказ товара, -Purchase Receipt Detail,Деталь квитанции о покупке, -Item Weight Details,Деталь Вес Подробности, +Purchase Receipt Detail,Сведения о квитанции о покупке, +Item Weight Details,Сведения о весе товара, Weight Per Unit,Вес на единицу, Total Weight,Общий вес, Weight UOM,Вес Единица измерения, @@ -5198,7 +5198,7 @@ Address and Contacts,Адрес и контакты, Contact List,Список контактов, Hidden list maintaining the list of contacts linked to Shareholder,"Скрытый список, поддерживающий список контактов, связанных с Акционером", Specify conditions to calculate shipping amount,Укажите условия для расчета суммы доставки, -Shipping Rule Label,Название правила доставки, +Shipping Rule Label,Метка правила доставки, example: Next Day Shipping,Пример: доставка на следующий день, Shipping Rule Type,Тип правила доставки, Shipping Account,Счет доставки, @@ -5236,7 +5236,7 @@ Billing Interval,Интервал выставления счетов, Billing Interval Count,Счет интервала фактурирования, "Number of intervals for the interval field e.g if Interval is 'Days' and Billing Interval Count is 3, invoices will be generated every 3 days","Количество интервалов для поля интервалов, например, если Interval является «Days», а количество интервалов фактурирования - 3, счета-фактуры будут генерироваться каждые 3 дня", Payment Plan,Платежный план, -Subscription Plan Detail,Деталь плана подписки, +Subscription Plan Detail,Сведения о плана подписки, Plan,План, Subscription Settings,Настройки подписки, Grace Period,Льготный период, @@ -5802,7 +5802,7 @@ Make Academic Term Mandatory,Сделать академический срок Skip User creation for new Student,Пропустить создание пользователя для нового студента, "By default, a new User is created for every new Student. If enabled, no new User will be created when a new Student is created.","По умолчанию для каждого нового Студента создается новый Пользователь. Если этот параметр включен, при создании нового Студента новый Пользователь не создается.", Instructor Records to be created by,Записи инструкторов должны быть созданы, -Employee Number,Общее число сотрудников, +Employee Number,Номер сотрудника, Fee Category,Категория платы, Fee Component,Компонент платы, Fees Category,Категория плат, @@ -6196,7 +6196,7 @@ Inpatient Occupancy,Стационарное размещение, Occupancy Status,Статус занятости, Vacant,Вакантно, Occupied,Занято, -Item Details,Детальная информация о товаре, +Item Details,Детальная информация о продукте, UOM Conversion in Hours,Преобразование UOM в часы, Rate / UOM,Скорость / UOM, Change in Item,Изменение продукта, @@ -6868,8 +6868,8 @@ Only Tax Impact (Cannot Claim But Part of Taxable Income),Только нало Create Separate Payment Entry Against Benefit Claim,Создать отдельную заявку на подачу заявки на получение пособия, Condition and Formula,Состояние и формула, Amount based on formula,Сумма на основе формулы, -Formula,формула, -Salary Detail,Заработная плата: Подробности, +Formula,Формула, +Salary Detail,Подробно об заработной плате, Component,Компонент, Do not include in total,Не включать в общей сложности, Default Amount,По умолчанию количество, @@ -6891,7 +6891,7 @@ Total Principal Amount,Общая сумма, Total Interest Amount,Общая сумма процентов, Total Loan Repayment,Общая сумма погашения кредита, net pay info,Чистая информация платить, -Gross Pay - Total Deduction - Loan Repayment,Gross Pay - Итого Вычет - Погашение кредита, +Gross Pay - Total Deduction - Loan Repayment,Валовая заработная плата - Общий вычет - Погашение кредита, Total in words,Всего в словах, Net Pay (in words) will be visible once you save the Salary Slip.,"Чистая плата (прописью) будет видна, как только вы сохраните зарплатную ведомость.", Salary Component for timesheet based payroll.,Компонент заработной платы для расчета зарплаты на основе расписания., @@ -6961,7 +6961,7 @@ Trainer Email,Электронная почта тренера, Attendees,Присутствующие, Employee Emails,Электронные почты сотрудников, Training Event Employee,Обучение сотрудников Событие, -Invited,приглашенный, +Invited,Приглашенный, Feedback Submitted,Отзыв отправлен, Optional,Необязательный, Training Result Employee,Результат обучения сотрудника, @@ -7185,7 +7185,7 @@ Ordered Quantity,Заказанное количество, Item to be manufactured or repacked,Продукт должен быть произведен или переупакован, Quantity of item obtained after manufacturing / repacking from given quantities of raw materials,Количество пункта получены после изготовления / переупаковка от заданных величин сырья, Set rate of sub-assembly item based on BOM,Установить скорость сборки на основе спецификации, -Allow Alternative Item,Разрешить альтернативный элемент, +Allow Alternative Item,Разрешить альтернативный продукт, Item UOM,Единиц продукта, Conversion Rate,Коэффициент конверсии, Rate Of Materials Based On,Оценить материалов на основе, @@ -7600,7 +7600,7 @@ Invoices with no Place Of Supply,Счета без места поставки, Import Supplier Invoice,Импортная накладная поставщика, Invoice Series,Серия счетов, Upload XML Invoices,Загрузить XML-счета, -Zip File,Zip-файл, +Zip File,Zip файл, Import Invoices,Импорт счетов, Click on Import Invoices button once the zip file has been attached to the document. Any errors related to processing will be shown in the Error Log.,"Нажмите кнопку «Импортировать счета-фактуры», когда файл zip прикреплен к документу. Любые ошибки, связанные с обработкой, будут отображаться в журнале ошибок.", Lower Deduction Certificate,Свидетельство о нижнем удержании, @@ -7635,7 +7635,7 @@ Restaurant Order Entry Item,Номер заказа заказа рестора Served,Подается, Restaurant Reservation,Бронирование ресторанов, Waitlisted,Лист ожидания, -No Show,Нет шоу, +No Show,Не показывать, No of People,Нет людей, Reservation Time,Время резервирования, Reservation End Time,Время окончания бронирования, @@ -7873,8 +7873,8 @@ Disable In Words,Отключить в словах, "If disable, 'In Words' field will not be visible in any transaction","Если отключить, "В словах" поле не будет видно в любой сделке", Item Classification,Продуктовая классификация, General Settings,Основные настройки, -Item Group Name,Пункт Название группы, -Parent Item Group,Родитель Пункт Группа, +Item Group Name,Название группы продуктов, +Parent Item Group,Родительская группа продукта, Item Group Defaults,Элемент группы по умолчанию, Item Tax,Налог на продукт, Check this if you want to show in website,"Проверьте это, если вы хотите показать в веб-сайт", @@ -7971,13 +7971,13 @@ Customs Tariff Number,Номер таможенного тарифа, Tariff Number,Тарифный номер, Delivery To,Доставка, MAT-DN-.YYYY.-,MAT-DN-.YYYY.-, -Is Return,Является Вернуться, +Is Return,Возврат, Issue Credit Note,Кредитная кредитная карта, -Return Against Delivery Note,Вернуться На накладной, -Customer's Purchase Order No,Клиентам Заказ Нет, +Return Against Delivery Note,Возврат по накладной, +Customer's Purchase Order No,Заказ клиента №, 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.","Если вы создали стандартный шаблон в шаблонах Налоги с налогами и сбором платежей, выберите его и нажмите кнопку ниже.", +"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,Информация для транспортировки, @@ -7991,8 +7991,8 @@ Installation Status,Состояние установки, Excise Page Number,Количество Акцизный Страница, Instructions,Инструкции, From Warehouse,Со склада, -Against Sales Order,По Сделке, -Against Sales Order Item,По Продукту Сделки, +Against Sales Order,По сделке, +Against Sales Order Item,По позиции сделки, Against Sales Invoice,Повторная накладная, Against Sales Invoice Item,Счет на продажу продукта, Available Batch Qty at From Warehouse,Доступные Пакетная Кол-во на со склада, @@ -8008,7 +8008,7 @@ Delivery Stop,Остановить доставку, Lock,Заблокировано, Visited,Посещен, Order Information,запросить информацию, -Contact Information,Контакты, +Contact Information,Контактная информация, Email sent to,Письмо отправлено, Dispatch Information,Информация о доставке, Estimated Arrival,Ожидаемое прибытие, @@ -8121,7 +8121,7 @@ Two-way,Двусторонний, Alternative Item Name,Альтернативное название продукта, Attribute Name,Название атрибута, Numeric Values,Числовые значения, -From Range,От хребта, +From Range,Из диапазона, Increment,Приращение, To Range,В диапазоне, Item Attribute Values,Пункт значений атрибутов, @@ -8143,7 +8143,7 @@ Default Supplier,Поставщик по умолчанию, Default Expense Account,Счет учета затрат по умолчанию, Sales Defaults,По умолчанию, Default Selling Cost Center,По умолчанию Продажа Стоимость центр, -Item Manufacturer,Пункт Производитель, +Item Manufacturer,Производитель товара, Item Price,Цена продукта, Packing Unit,Упаковочный блок, Quantity that must be bought or sold per UOM,"Количество, которое необходимо купить или продать за UOM", @@ -8177,7 +8177,7 @@ Purchase Receipts,Покупка Поступления, Purchase Receipt Items,Покупка продуктов, Get Items From Purchase Receipts,Получить продукты из покупки., Distribute Charges Based On,Распределите платежи на основе, -Landed Cost Help,Земельные Стоимость Помощь, +Landed Cost Help,Справка по стоимости доставки, Manufacturers used in Items,Производители использовали в пунктах, Limited to 12 characters,Ограничено до 12 символов, MAT-MR-.YYYY.-,МАТ-MR-.YYYY.-, @@ -8186,13 +8186,13 @@ Transferred,Переданы, % Ordered,% заказано, Terms and Conditions Content,Условия Содержимое, Quantity and Warehouse,Количество и Склад, -Lead Time Date,Время и Дата Лида, -Min Order Qty,Минимальный заказ Кол-во, +Lead Time Date,Дата выполнения заказа, +Min Order Qty,Минимальное количество для заказа, Packed Item,Упаковано, To Warehouse (Optional),На склад (Необязательно), Actual Batch Quantity,Фактическое количество партий, Prevdoc DocType,Prevdoc DocType, -Parent Detail docname,Родитель Деталь DOCNAME, +Parent Detail docname,Сведения о родителе docname, "Generate packing slips for packages to be delivered. Used to notify package number, package contents and its weight.","Создаёт упаковочные листы к упаковкам для доставки. Содержит номер упаковки, перечень содержимого и вес.", Indicates that the package is a part of this delivery (Only Draft),"Указывает, что пакет является частью этой поставки (только проект)", MAT-PAC-.YYYY.-,MAT-PAC-.YYYY.-, @@ -8353,7 +8353,7 @@ Automatically Set Serial Nos based on FIFO,Автоматически устан Auto Material Request,Автоматический запрос материалов, Inter Warehouse Transfer Settings,Настройки передачи между складами, Freeze Stock Entries,Замораживание поступления запасов, -Stock Frozen Upto,остатки заморожены до, +Stock Frozen Upto,Остатки заморожены до, Batch Identification,Идентификация партии, Use Naming Series,Использовать серийный номер, Naming Series Prefix,Префикс Идентификации по Имени, @@ -8372,7 +8372,7 @@ Issue Split From,Выпуск Сплит От, Service Level,Уровень обслуживания, Response By,Ответ от, Response By Variance,Ответ по отклонениям, -Ongoing,постоянный, +Ongoing,Постоянный, Resolution By,Разрешение по, Resolution By Variance,Разрешение по отклонениям, Service Level Agreement Creation,Создание соглашения об уровне обслуживания, From 5826b7b0718a1838746eb9755bb001308ed9a642 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 21 Jun 2022 19:50:50 +0530 Subject: [PATCH 107/133] fix: identify empty values "" in against_voucher columns --- erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py index 1e0d20d059..e15aa4a1f4 100644 --- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py +++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py @@ -1,6 +1,6 @@ import frappe from frappe import qb -from frappe.query_builder import Case +from frappe.query_builder import Case, CustomFunction from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import IfNull @@ -87,6 +87,7 @@ def execute(): gl = qb.DocType("GL Entry") account = qb.DocType("Account") + ifelse = CustomFunction("IF", ["condition", "then", "else"]) gl_entries = ( qb.from_(gl) @@ -96,8 +97,12 @@ def execute(): gl.star, ConstantColumn(1).as_("docstatus"), account.account_type.as_("account_type"), - IfNull(gl.against_voucher_type, gl.voucher_type).as_("against_voucher_type"), - IfNull(gl.against_voucher, gl.voucher_no).as_("against_voucher_no"), + IfNull( + ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type + ).as_("against_voucher_type"), + IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_( + "against_voucher_no" + ), # convert debit/credit to amount Case() .when(account.account_type == "Receivable", gl.debit - gl.credit) From 8b1ff96e30e88f6a8c9dabed097efeac310645c1 Mon Sep 17 00:00:00 2001 From: hrzzz Date: Tue, 21 Jun 2022 15:10:19 -0300 Subject: [PATCH 108/133] fix: translation for filter status on report --- .../report/purchase_order_analysis/purchase_order_analysis.js | 1 + .../selling/report/sales_order_analysis/sales_order_analysis.js | 1 + 2 files changed, 2 insertions(+) diff --git a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js index ca3be03da6..721e54e46f 100644 --- a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js +++ b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js @@ -59,6 +59,7 @@ frappe.query_reports["Purchase Order Analysis"] = { for (let option of status){ options.push({ "value": option, + "label": __(option), "description": "" }) } diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js index 76a5bb51ca..91748bc7be 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js @@ -55,6 +55,7 @@ frappe.query_reports["Sales Order Analysis"] = { for (let option of status){ options.push({ "value": option, + "label": __(option), "description": "" }) } From 00807abe314344fec1ec636ac5f281473589f7f1 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Wed, 22 Jun 2022 10:48:49 +0530 Subject: [PATCH 109/133] fix: add UOM validation for planned-qty --- .../production_plan/production_plan.py | 2 ++ .../production_plan/test_production_plan.py | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 8a28454af2..a73b9bcc69 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -25,6 +25,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.utilities.transaction_base import validate_uom_is_integer class ProductionPlan(Document): @@ -33,6 +34,7 @@ class ProductionPlan(Document): self.calculate_total_planned_qty() self.set_status() self._rename_temporary_references() + validate_uom_is_integer(self, "stock_uom", "planned_qty") def set_pending_qty_in_row_without_reference(self): "Set Pending Qty in independent rows (not from SO or MR)." diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index e88049d810..040e791e00 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -679,15 +679,23 @@ class TestProductionPlan(FrappeTestCase): self.assertFalse(pp.all_items_completed()) def test_production_plan_planned_qty(self): - pln = create_production_plan(item_code="_Test FG Item", planned_qty=0.55) - pln.make_work_order() - work_order = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name") - wo_doc = frappe.get_doc("Work Order", work_order) - wo_doc.update( - {"wip_warehouse": "Work In Progress - _TC", "fg_warehouse": "Finished Goods - _TC"} + # Case 1: When Planned Qty is non-integer and UOM is integer. + from erpnext.utilities.transaction_base import UOMMustBeIntegerError + + self.assertRaises( + UOMMustBeIntegerError, create_production_plan, item_code="_Test FG Item", planned_qty=0.55 ) - wo_doc.submit() - self.assertEqual(wo_doc.qty, 0.55) + + # Case 2: When Planned Qty is non-integer and UOM is also non-integer. + from erpnext.stock.doctype.item.test_item import make_item + + fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name + bom_item = make_item().name + + make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC") + + pln = create_production_plan(item_code=fg_item, planned_qty=0.55, stock_uom="_Test UOM 1") + self.assertEqual(pln.po_items[0].planned_qty, 0.55) def test_temporary_name_relinking(self): @@ -751,6 +759,7 @@ def create_production_plan(**args): "bom_no": frappe.db.get_value("Item", args.item_code, "default_bom"), "planned_qty": args.planned_qty or 1, "planned_start_date": args.planned_start_date or now_datetime(), + "stock_uom": args.stock_uom or "Nos", }, ) From ab13a178b55218c6a9295cb793e6f6deee5c07a2 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:24:22 +0530 Subject: [PATCH 110/133] fix: Replace asset life with total no of depreciations --- erpnext/assets/doctype/asset/asset.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 257488dfc3..650411c3aa 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -849,14 +849,10 @@ class Asset(AccountsController): if args.get("rate_of_depreciation") and on_validate: return args.get("rate_of_depreciation") - no_of_years = ( - flt(args.get("total_number_of_depreciations") * flt(args.get("frequency_of_depreciation"))) - / 12 - ) value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount) # square root of flt(salvage_value) / flt(asset_cost) - depreciation_rate = math.pow(value, 1.0 / flt(no_of_years, 2)) + depreciation_rate = math.pow(value, 1.0 / flt(args.get("total_number_of_depreciations"), 2)) return 100 * (1 - flt(depreciation_rate, float_precision)) From 2d9153ea3058abb849527dd5c34aa056e2a25d3a Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:24:46 +0530 Subject: [PATCH 111/133] fix: Remove misleading comment --- erpnext/assets/doctype/asset/asset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 650411c3aa..cf556a659c 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -851,7 +851,6 @@ class Asset(AccountsController): value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount) - # square root of flt(salvage_value) / flt(asset_cost) depreciation_rate = math.pow(value, 1.0 / flt(args.get("total_number_of_depreciations"), 2)) return 100 * (1 - flt(depreciation_rate, float_precision)) From b07aae4da5f186e6c4b9714e13bf9ef3a7a53ec7 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:33:17 +0530 Subject: [PATCH 112/133] fix: Correct pro-rata amount calculation --- erpnext/assets/doctype/asset/asset.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index cf556a659c..88c462e1df 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -860,6 +860,14 @@ class Asset(AccountsController): months = month_diff(to_date, from_date) total_days = get_total_days(to_date, row.frequency_of_depreciation) + print("\n"*10) + print("from_date: ", from_date) + print("to_date: ", to_date) + print("\n") + print("days: ", days) + print("total_days: ", total_days) + print("\n"*10) + return (depreciation_amount * flt(days)) / flt(total_days), days, months @@ -1100,8 +1108,16 @@ def is_cwip_accounting_enabled(asset_category): def get_total_days(date, frequency): period_start_date = add_months(date, cint(frequency) * -1) + if is_last_day_of_the_month(date): + period_start_date = get_last_day(period_start_date) + return date_diff(date, period_start_date) +def is_last_day_of_the_month(date): + last_day_of_the_month = get_last_day(date) + + return getdate(last_day_of_the_month) == getdate(date) + @erpnext.allow_regional def get_depreciation_amount(asset, depreciable_value, row): From 154e258ad097e750512592fbccac0d15d4667f59 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 21:39:39 +0530 Subject: [PATCH 113/133] fix: Get last day of the monthif depr posting date is the last day of its month --- erpnext/assets/doctype/asset/asset.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 88c462e1df..fc1e7afd3a 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -252,6 +252,7 @@ class Asset(AccountsController): number_of_pending_depreciations += 1 skip_row = False + should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date) for n in range(start[finance_book.idx - 1], number_of_pending_depreciations): # If depreciation is already completed (for double declining balance) @@ -265,6 +266,9 @@ class Asset(AccountsController): finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation) ) + if should_get_last_day: + schedule_date = get_last_day(schedule_date) + # schedule date will be a year later from start date # so monthly schedule date is calculated by removing 11 months from it monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1) @@ -860,14 +864,6 @@ class Asset(AccountsController): months = month_diff(to_date, from_date) total_days = get_total_days(to_date, row.frequency_of_depreciation) - print("\n"*10) - print("from_date: ", from_date) - print("to_date: ", to_date) - print("\n") - print("days: ", days) - print("total_days: ", total_days) - print("\n"*10) - return (depreciation_amount * flt(days)) / flt(total_days), days, months From e62beaefc1cb5db9656be6cd0b354bd368e5f971 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 22:23:01 +0530 Subject: [PATCH 114/133] test: Test if final day of the month is taken if depr_start_date is the last day of its month --- erpnext/assets/doctype/asset/test_asset.py | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index e759ad0719..7ef47d280c 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -1333,6 +1333,32 @@ class TestDepreciationBasics(AssetSetup): asset.cost_center = "Main - _TC" asset.submit() + def test_depreciation_on_final_day_of_the_month(self): + """Tests if final day of the month is picked each time, if the depreciation start date is the last day of the month.""" + + asset = create_asset( + item_code="Macbook Pro", + calculate_depreciation=1, + purchase_date="2020-01-30", + available_for_use_date="2020-02-15", + depreciation_start_date="2020-02-29", + frequency_of_depreciation=1, + total_number_of_depreciations=5, + submit=1, + ) + + expected_dates = [ + "2020-02-29", + "2020-03-31", + "2020-04-30", + "2020-05-31", + "2020-06-30", + "2020-07-15", + ] + + for i, schedule in enumerate(asset.schedules): + self.assertEqual(getdate(expected_dates[i]), getdate(schedule.schedule_date)) + def create_asset_data(): if not frappe.db.exists("Asset Category", "Computers"): From 2b7ab72a72fc8482f569a7a3edc303d78e7e762e Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 22:46:48 +0530 Subject: [PATCH 115/133] fix: Convert string to datetime object --- erpnext/assets/doctype/asset/test_asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 7ef47d280c..f1be08cb7f 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -838,7 +838,7 @@ class TestDepreciationBasics(AssetSetup): expected_values = [["2020-12-31", 30000.0], ["2021-12-31", 30000.0], ["2022-12-31", 30000.0]] for i, schedule in enumerate(asset.schedules): - self.assertEqual(expected_values[i][0], schedule.schedule_date) + self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) def test_set_accumulated_depreciation(self): From 1b1786532afb9298db6a8c5f8770d4a5b9fba71b Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 22:53:25 +0530 Subject: [PATCH 116/133] test: Test monthly depreciation by Written Down Value method --- erpnext/assets/doctype/asset/test_asset.py | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index f1be08cb7f..58fd40088f 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -707,6 +707,39 @@ class TestDepreciationMethods(AssetSetup): self.assertEqual(schedules, expected_schedules) + def test_monthly_depreciation_by_wdv_method(self): + asset = create_asset( + calculate_depreciation=1, + available_for_use_date="2022-02-15", + purchase_date="2022-02-15", + depreciation_method="Written Down Value", + gross_purchase_amount=10000, + expected_value_after_useful_life=5000, + depreciation_start_date="2022-02-28", + total_number_of_depreciations=5, + frequency_of_depreciation=1, + ) + + expected_schedules = [ + ["2022-02-28", 645.0, 645.0], + ["2022-03-31", 1206.8, 1851.8], + ["2022-04-30", 1051.12, 2902.92], + ["2022-05-31", 915.52, 3818.44], + ["2022-06-30", 797.42, 4615.86], + ["2022-07-15", 384.14, 5000.0] + ] + + schedules = [ + [ + cstr(d.schedule_date), + flt(d.depreciation_amount, 2), + flt(d.accumulated_depreciation_amount, 2), + ] + for d in asset.get("schedules") + ] + + self.assertEqual(schedules, expected_schedules) + def test_discounted_wdv_depreciation_rate_for_indian_region(self): # set indian company company_flag = frappe.flags.company From 416d578290d8c9c1b2e387954164306266bf8ebc Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 22 Jun 2022 23:29:03 +0530 Subject: [PATCH 117/133] fix: Add missing comma --- erpnext/assets/doctype/asset/asset.py | 1 + erpnext/assets/doctype/asset/test_asset.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index fc1e7afd3a..a880c2f391 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -1109,6 +1109,7 @@ def get_total_days(date, frequency): return date_diff(date, period_start_date) + def is_last_day_of_the_month(date): last_day_of_the_month = get_last_day(date) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 58fd40088f..f8a8fc551d 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -726,7 +726,7 @@ class TestDepreciationMethods(AssetSetup): ["2022-04-30", 1051.12, 2902.92], ["2022-05-31", 915.52, 3818.44], ["2022-06-30", 797.42, 4615.86], - ["2022-07-15", 384.14, 5000.0] + ["2022-07-15", 384.14, 5000.0], ] schedules = [ From 71f6f78d94b4a98b7f268fdde7fd307d95db9a82 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 24 Jun 2022 13:36:15 +0530 Subject: [PATCH 118/133] fix: incorrect outstanding for invoice --- erpnext/accounts/general_ledger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 8146804705..76ef3abb6f 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -132,7 +132,7 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None): for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items(): gle = copy.deepcopy(d) gle.cost_center = sub_cost_center - for field in ("debit", "credit", "debit_in_account_currency", "credit_in_company_currency"): + for field in ("debit", "credit", "debit_in_account_currency", "credit_in_account_currency"): gle[field] = flt(flt(d.get(field)) * percentage / 100, precision) new_gl_map.append(gle) else: From 321fea322cf205749c60da2a71f572cfc272e56a Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 24 Jun 2022 17:36:37 +0530 Subject: [PATCH 119/133] test: invoice outstanding when gl's are split on cost center allocat --- .../sales_invoice/test_sales_invoice.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index b8154dd1f9..adac77f69d 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -792,6 +792,54 @@ class TestSalesInvoice(unittest.TestCase): jv.cancel() self.assertEqual(frappe.db.get_value("Sales Invoice", w.name, "outstanding_amount"), 562.0) + def test_outstanding_on_cost_center_allocation(self): + # setup cost centers + from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center + from erpnext.accounts.doctype.cost_center_allocation.test_cost_center_allocation import ( + create_cost_center_allocation, + ) + + cost_centers = [ + "Main Cost Center 1", + "Sub Cost Center 1", + "Sub Cost Center 2", + ] + for cc in cost_centers: + create_cost_center(cost_center_name=cc, company="_Test Company") + + cca = create_cost_center_allocation( + "_Test Company", + "Main Cost Center 1 - _TC", + {"Sub Cost Center 1 - _TC": 60, "Sub Cost Center 2 - _TC": 40}, + ) + + # make invoice + si = frappe.copy_doc(test_records[0]) + si.is_pos = 0 + si.insert() + si.submit() + + from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry + + # make payment - fully paid + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC") + pe.reference_no = "1" + pe.reference_date = nowdate() + pe.paid_from_account_currency = si.currency + pe.paid_to_account_currency = si.currency + pe.source_exchange_rate = 1 + pe.target_exchange_rate = 1 + pe.paid_amount = si.outstanding_amount + pe.cost_center = cca.main_cost_center + pe.insert() + pe.submit() + + # cancel cost center allocation + cca.cancel() + + si.reload() + self.assertEqual(si.outstanding_amount, 0) + def test_sales_invoice_gl_entry_without_perpetual_inventory(self): si = frappe.copy_doc(test_records[1]) si.insert() From 58fe220479d18e8b974f438b7ebfe0a2753ae5bd Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 24 Jun 2022 19:43:50 +0530 Subject: [PATCH 120/133] fix: Quotation and Sales Order item sync --- erpnext/selling/doctype/quotation/quotation.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 4fa4515a0f..d775fa93be 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -218,6 +218,15 @@ def make_sales_order(source_name, target_doc=None): def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): customer = _make_customer(source_name, ignore_permissions) + ordered_items = frappe._dict( + frappe.db.get_all( + "Sales Order Item", + {"prevdoc_docname": source_name, "docstatus": 1}, + ["item_code", "sum(qty)"], + group_by="item_code", + as_list=1, + ) + ) def set_missing_values(source, target): if customer: @@ -233,7 +242,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): target.run_method("calculate_taxes_and_totals") def update_item(obj, target, source_parent): - target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor) + balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0) + target.qty = balance_qty if balance_qty > 0 else 0 + target.stock_qty = flt(target.qty) * flt(obj.conversion_factor) if obj.against_blanket_order: target.against_blanket_order = obj.against_blanket_order @@ -249,6 +260,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): "doctype": "Sales Order Item", "field_map": {"parent": "prevdoc_docname"}, "postprocess": update_item, + "condition": lambda doc: doc.qty > 0, }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, From 6acd0325be034ddb28153b0fbd3f1163a10ce8f6 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 23 Jun 2022 22:03:09 +0530 Subject: [PATCH 121/133] fix: General Ledger and TB opening entries mismatch issues --- .../doctype/journal_entry/journal_entry.js | 16 ---------------- .../doctype/journal_entry/journal_entry.json | 5 +++-- .../doctype/journal_entry/journal_entry.py | 18 ------------------ .../report/general_ledger/general_ledger.py | 2 +- 4 files changed, 4 insertions(+), 37 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 3cc28a3dc8..4e7a653e39 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -149,22 +149,6 @@ frappe.ui.form.on("Journal Entry", { } }); } - else if(frm.doc.voucher_type=="Opening Entry") { - return frappe.call({ - type:"GET", - method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_opening_accounts", - args: { - "company": frm.doc.company - }, - callback: function(r) { - frappe.model.clear_table(frm.doc, "accounts"); - if(r.message) { - update_jv_details(frm.doc, r.message); - } - cur_frm.set_value("is_opening", "Yes"); - } - }); - } } }, diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 4493c72254..8e5ba3718f 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -137,7 +137,8 @@ "fieldname": "finance_book", "fieldtype": "Link", "label": "Finance Book", - "options": "Finance Book" + "options": "Finance Book", + "read_only": 1 }, { "fieldname": "2_add_edit_gl_entries", @@ -538,7 +539,7 @@ "idx": 176, "is_submittable": 1, "links": [], - "modified": "2022-04-06 17:18:46.865259", + "modified": "2022-06-23 22:01:32.348337", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 787efd2a42..50df65b183 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1204,24 +1204,6 @@ def get_payment_entry(ref_doc, args): return je if args.get("journal_entry") else je.as_dict() -@frappe.whitelist() -def get_opening_accounts(company): - """get all balance sheet accounts for opening entry""" - accounts = frappe.db.sql_list( - """select - name from tabAccount - where - is_group=0 and report_type='Balance Sheet' and company={0} and - name not in (select distinct account from tabWarehouse where - account is not null and account != '') - order by name asc""".format( - frappe.db.escape(company) - ) - ) - - return [{"account": a, "balance": get_balance_on(a)} for a in accounts] - - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_against_jv(doctype, txt, searchfield, start, page_len, filters): diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index e4b561e5f6..e77e828e16 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -425,7 +425,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map): update_value_in_dict(totals, "opening", gle) update_value_in_dict(totals, "closing", gle) - elif gle.posting_date <= to_date: + elif gle.posting_date <= to_date or (cstr(gle.is_opening) == "Yes" and show_opening_entries): if not group_by_voucher_consolidated: update_value_in_dict(gle_map[group_by_value].totals, "total", gle) update_value_in_dict(gle_map[group_by_value].totals, "closing", gle) From f904ac599ef12bc505d7c3c9540896bce644203b Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 10 Jun 2022 11:15:22 +0530 Subject: [PATCH 122/133] fix: merge conflicts and sider issues --- erpnext/crm/doctype/crm_note/__init__.py | 0 erpnext/crm/doctype/crm_note/crm_note.json | 48 +++ erpnext/crm/doctype/crm_note/crm_note.py | 9 + .../doctype/crm_settings/crm_settings.json | 17 +- erpnext/crm/doctype/lead/lead.js | 157 +++++++-- erpnext/crm/doctype/lead/lead.json | 318 +++++++++-------- erpnext/crm/doctype/lead/lead.py | 304 +++++++++------- erpnext/crm/doctype/lead/lead_list.js | 2 +- erpnext/crm/doctype/lead/test_lead.py | 105 +++++- .../crm/doctype/opportunity/opportunity.js | 52 ++- .../crm/doctype/opportunity/opportunity.json | 328 ++++++++++++------ .../crm/doctype/opportunity/opportunity.py | 169 ++++----- .../opportunity/opportunity_calendar.js | 19 - erpnext/crm/doctype/prospect/prospect.js | 21 ++ erpnext/crm/doctype/prospect/prospect.json | 144 +++++--- erpnext/crm/doctype/prospect/prospect.py | 63 ++-- erpnext/crm/doctype/prospect/test_prospect.py | 2 +- .../doctype/prospect_lead/prospect_lead.json | 33 +- .../doctype/prospect_opportunity/__init__.py | 0 .../prospect_opportunity.json | 101 ++++++ .../prospect_opportunity.py | 9 + .../lost_opportunity/lost_opportunity.js | 6 - .../lost_opportunity/lost_opportunity.json | 4 +- .../lost_opportunity/lost_opportunity.py | 11 - erpnext/crm/utils.py | 139 +++++++- erpnext/hooks.py | 7 +- erpnext/patches.txt | 1 + erpnext/patches/v14_0/crm_ux_cleanup.py | 84 +++++ erpnext/public/js/erpnext.bundle.js | 3 + .../public/js/templates/crm_activities.html | 172 +++++++++ erpnext/public/js/templates/crm_notes.html | 74 ++++ erpnext/public/js/utils/crm_activities.js | 234 +++++++++++++ .../selling/doctype/quotation/quotation.py | 11 - erpnext/templates/utils.py | 1 - erpnext/utilities/transaction_base.py | 60 +--- 35 files changed, 1974 insertions(+), 734 deletions(-) create mode 100644 erpnext/crm/doctype/crm_note/__init__.py create mode 100644 erpnext/crm/doctype/crm_note/crm_note.json create mode 100644 erpnext/crm/doctype/crm_note/crm_note.py delete mode 100644 erpnext/crm/doctype/opportunity/opportunity_calendar.js create mode 100644 erpnext/crm/doctype/prospect_opportunity/__init__.py create mode 100644 erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json create mode 100644 erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py create mode 100644 erpnext/patches/v14_0/crm_ux_cleanup.py create mode 100644 erpnext/public/js/templates/crm_activities.html create mode 100644 erpnext/public/js/templates/crm_notes.html create mode 100644 erpnext/public/js/utils/crm_activities.js diff --git a/erpnext/crm/doctype/crm_note/__init__.py b/erpnext/crm/doctype/crm_note/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/crm_note/crm_note.json b/erpnext/crm/doctype/crm_note/crm_note.json new file mode 100644 index 0000000000..fc2a4d1192 --- /dev/null +++ b/erpnext/crm/doctype/crm_note/crm_note.json @@ -0,0 +1,48 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2022-06-04 15:49:23.416644", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "note", + "added_by", + "added_on" + ], + "fields": [ + { + "columns": 5, + "fieldname": "note", + "fieldtype": "Text Editor", + "in_list_view": 1, + "label": "Note" + }, + { + "fieldname": "added_by", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Added By", + "options": "User" + }, + { + "fieldname": "added_on", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Added On" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-06-04 16:29:07.807252", + "modified_by": "Administrator", + "module": "CRM", + "name": "CRM Note", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/crm/doctype/crm_note/crm_note.py b/erpnext/crm/doctype/crm_note/crm_note.py new file mode 100644 index 0000000000..6c7eeb4c7e --- /dev/null +++ b/erpnext/crm/doctype/crm_note/crm_note.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 CRMNote(Document): + pass diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json index a2a19b9e79..26a07d2e85 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.json +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -10,12 +10,10 @@ "campaign_naming_by", "allow_lead_duplication_based_on_emails", "column_break_4", - "create_event_on_next_contact_date", "auto_creation_of_contact", "opportunity_section", "close_opportunity_after_days", "column_break_9", - "create_event_on_next_contact_date_opportunity", "quotation_section", "default_valid_till", "section_break_13", @@ -55,12 +53,6 @@ "fieldtype": "Check", "label": "Auto Creation of Contact" }, - { - "default": "1", - "fieldname": "create_event_on_next_contact_date", - "fieldtype": "Check", - "label": "Create Event on Next Contact Date" - }, { "fieldname": "opportunity_section", "fieldtype": "Section Break", @@ -73,12 +65,6 @@ "fieldtype": "Int", "label": "Close Replied Opportunity After Days" }, - { - "default": "1", - "fieldname": "create_event_on_next_contact_date_opportunity", - "fieldtype": "Check", - "label": "Create Event on Next Contact Date" - }, { "fieldname": "column_break_4", "fieldtype": "Column Break" @@ -105,7 +91,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-12-20 12:51:38.894252", + "modified": "2022-06-06 11:22:08.464253", "modified_by": "Administrator", "module": "CRM", "name": "CRM Settings", @@ -143,5 +129,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js index 999599ce95..4814311558 100644 --- a/erpnext/crm/doctype/lead/lead.js +++ b/erpnext/crm/doctype/lead/lead.js @@ -24,31 +24,39 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller this.frm.set_query("lead_owner", function (doc, cdt, cdn) { return { query: "frappe.core.doctype.user.user.user_query" } }); - - this.frm.set_query("contact_by", function (doc, cdt, cdn) { - return { query: "frappe.core.doctype.user.user.user_query" } - }); } refresh () { + var me = this; let doc = this.frm.doc; erpnext.toggle_naming_series(); - frappe.dynamic_link = { doc: doc, fieldname: 'name', doctype: 'Lead' } + frappe.dynamic_link = { + doc: doc, + fieldname: 'name', + doctype: 'Lead' + }; if (!this.frm.is_new() && doc.__onload && !doc.__onload.is_customer) { this.frm.add_custom_button(__("Customer"), this.make_customer, __("Create")); - this.frm.add_custom_button(__("Opportunity"), this.make_opportunity, __("Create")); + this.frm.add_custom_button(__("Opportunity"), function() { + me.frm.trigger("make_opportunity"); + }, __("Create")); this.frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); - this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("Create")); - this.frm.add_custom_button(__('Add to Prospect'), this.add_lead_to_prospect, __('Action')); + if (!doc.__onload.linked_prospects.length) { + this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("Create")); + this.frm.add_custom_button(__('Add to Prospect'), this.add_lead_to_prospect, __('Action')); + } } if (!this.frm.is_new()) { frappe.contacts.render_address_and_contact(this.frm); - cur_frm.trigger('render_contact_day_html'); } else { frappe.contacts.clear_address_and_contact(this.frm); } + + this.frm.dashboard.links_area.hide(); + this.show_notes(); + this.show_activities(); } add_lead_to_prospect () { @@ -74,7 +82,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller } }, freeze: true, - freeze_message: __('...Adding Lead to Prospect') + freeze_message: __('Adding Lead to Prospect...') }); }, __('Add Lead to Prospect'), __('Add')); } @@ -86,13 +94,6 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller }) } - make_opportunity () { - frappe.model.open_mapped_doc({ - method: "erpnext.crm.doctype.lead.lead.make_opportunity", - frm: cur_frm - }) - } - make_quotation () { frappe.model.open_mapped_doc({ method: "erpnext.crm.doctype.lead.lead.make_quotation", @@ -111,9 +112,10 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller prospect.fax = cur_frm.doc.fax; prospect.website = cur_frm.doc.website; prospect.prospect_owner = cur_frm.doc.lead_owner; + prospect.notes = cur_frm.doc.notes; - let lead_prospect_row = frappe.model.add_child(prospect, 'prospect_lead'); - lead_prospect_row.lead = cur_frm.doc.name; + let leads_row = frappe.model.add_child(prospect, 'leads'); + leads_row.lead = cur_frm.doc.name; frappe.set_route("Form", "Prospect", prospect.name); }); @@ -125,26 +127,109 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller } } - contact_date () { - if (this.frm.doc.contact_date) { - let d = moment(this.frm.doc.contact_date); - d.add(1, "day"); - this.frm.set_value("ends_on", d.format(frappe.defaultDatetimeFormat)); - } + show_notes() { + if (this.frm.doc.docstatus == 1) return; + + const crm_notes = new erpnext.utils.CRMNotes({ + frm: this.frm, + notes_wrapper: $(this.frm.fields_dict.notes_html.wrapper), + }); + crm_notes.refresh(); } - render_contact_day_html() { - if (cur_frm.doc.contact_date) { - let contact_date = frappe.datetime.obj_to_str(cur_frm.doc.contact_date); - let diff_days = frappe.datetime.get_day_diff(contact_date, frappe.datetime.get_today()); - let color = diff_days > 0 ? "orange" : "green"; - let message = diff_days > 0 ? __("Next Contact Date") : __("Last Contact Date"); - let html = `
- ${message} : ${frappe.datetime.global_date_format(contact_date)} -
` ; - cur_frm.dashboard.set_headline_alert(html); - } + show_activities() { + if (this.frm.doc.docstatus == 1) return; + + const crm_activities = new erpnext.utils.CRMActivities({ + frm: this.frm, + open_activities_wrapper: $(this.frm.fields_dict.open_activities_html.wrapper), + all_activities_wrapper: $(this.frm.fields_dict.all_activities_html.wrapper), + form_wrapper: $(this.frm.wrapper), + }); + crm_activities.refresh(); } }; + extend_cscript(cur_frm.cscript, new erpnext.LeadController({ frm: cur_frm })); + +frappe.ui.form.on("Lead", { + make_opportunity: async function(frm) { + let existing_prospect = (await frappe.db.get_value("Prospect Lead", + { + "lead": frm.doc.name + }, + "name", null, "Prospect" + )).message.name; + + if (!existing_prospect) { + var fields = [ + { + "label": "Create Prospect", + "fieldname": "create_prospect", + "fieldtype": "Check", + "default": 1 + }, + { + "label": "Prospect Name", + "fieldname": "prospect_name", + "fieldtype": "Data", + "default": frm.doc.company_name, + "depends_on": "create_prospect" + } + ]; + } + let existing_contact = (await frappe.db.get_value("Contact", + { + "first_name": frm.doc.first_name || frm.doc.lead_name, + "last_name": frm.doc.last_name + }, + "name" + )).message.name; + + if (!existing_contact) { + fields.push( + { + "label": "Create Contact", + "fieldname": "create_contact", + "fieldtype": "Check", + "default": "1" + } + ); + } + + if (fields) { + var d = new frappe.ui.Dialog({ + title: __('Create Opportunity'), + fields: fields, + primary_action: function() { + var data = d.get_values(); + frappe.call({ + method: 'create_prospect_and_contact', + doc: frm.doc, + args: { + data: data, + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + frappe.model.open_mapped_doc({ + method: "erpnext.crm.doctype.lead.lead.make_opportunity", + frm: frm + }); + } + d.hide(); + } + }); + }, + primary_action_label: __('Create') + }); + d.show(); + } else { + frappe.model.open_mapped_doc({ + method: "erpnext.crm.doctype.lead.lead.make_opportunity", + frm: frm + }); + } + } +}) \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index 542977e689..df6ff56986 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -3,78 +3,76 @@ "allow_events_in_timeline": 1, "allow_import": 1, "autoname": "naming_series:", - "creation": "2013-04-10 11:45:37", + "creation": "2022-02-08 13:14:41.083327", "doctype": "DocType", "document_type": "Document", "email_append_to": 1, "engine": "InnoDB", "field_order": [ - "lead_details", "naming_series", "salutation", "first_name", "middle_name", "last_name", + "column_break_1", "lead_name", - "col_break123", - "status", - "company_name", - "designation", + "job_title", "gender", - "contact_details_section", + "source", + "col_break123", + "lead_owner", + "status", + "customer", + "type", + "request_type", + "contact_info_tab", "email_id", + "website", + "column_break_20", "mobile_no", "whatsapp_no", "column_break_16", "phone", "phone_ext", - "additional_information_section", + "organization_section", + "company_name", "no_of_employees", + "column_break_28", + "annual_revenue", "industry", "market_segment", - "column_break_22", + "column_break_31", + "territory", "fax", - "website", - "type", - "request_type", "address_section", "address_html", - "city", - "pincode", - "county", "column_break2", "contact_html", - "state", - "country", - "section_break_12", - "lead_owner", - "ends_on", - "column_break_14", - "contact_by", - "contact_date", - "lead_source_details_section", - "company", - "territory", - "language", - "column_break_50", - "source", + "qualification_tab", + "qualification_status", + "column_break_64", + "qualified_by", + "qualified_on", + "other_info_tab", "campaign_name", + "company", + "column_break_22", + "language", + "image", + "title", + "column_break_50", + "disabled", "unsubscribed", "blog_subscriber", - "notes_section", - "notes", - "other_information_section", - "customer", - "image", - "title" + "activities_tab", + "open_activities_html", + "all_activities_section", + "all_activities_html", + "notes_tab", + "notes_html", + "notes" ], "fields": [ - { - "fieldname": "lead_details", - "fieldtype": "Section Break", - "label": "Lead Details", - "options": "fa fa-user" - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -86,6 +84,7 @@ "set_only_once": 1 }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "lead_name", "fieldtype": "Data", "in_global_search": 1, @@ -108,7 +107,7 @@ { "fieldname": "email_id", "fieldtype": "Data", - "label": "Email Address", + "label": "Email", "oldfieldname": "email_id", "oldfieldtype": "Data", "options": "Email", @@ -189,50 +188,9 @@ "print_hide": 1 }, { - "fieldname": "section_break_12", + "fieldname": "contact_info_tab", "fieldtype": "Section Break", - "label": "Follow Up" - }, - { - "fieldname": "contact_by", - "fieldtype": "Link", - "label": "Next Contact By", - "oldfieldname": "contact_by", - "oldfieldtype": "Link", - "options": "User", - "width": "100px" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" - }, - { - "bold": 1, - "fieldname": "contact_date", - "fieldtype": "Datetime", - "label": "Next Contact Date", - "no_copy": 1, - "oldfieldname": "contact_date", - "oldfieldtype": "Date", - "width": "100px" - }, - { - "bold": 1, - "fieldname": "ends_on", - "fieldtype": "Datetime", - "label": "Ends On", - "no_copy": 1 - }, - { - "collapsible": 1, - "fieldname": "notes_section", - "fieldtype": "Section Break", - "label": "Notes" - }, - { - "fieldname": "notes", - "fieldtype": "Text Editor", - "label": "Notes" + "label": "Contact Info" }, { "fieldname": "address_html", @@ -240,34 +198,6 @@ "label": "Address HTML", "read_only": 1 }, - { - "fieldname": "city", - "fieldtype": "Data", - "label": "City/Town", - "mandatory_depends_on": "eval: doc.address_title && doc.address_type" - }, - { - "fieldname": "county", - "fieldtype": "Data", - "label": "County" - }, - { - "fieldname": "state", - "fieldtype": "Data", - "label": "State" - }, - { - "fieldname": "country", - "fieldtype": "Link", - "label": "Country", - "mandatory_depends_on": "eval: doc.address_title && doc.address_type", - "options": "Country" - }, - { - "fieldname": "pincode", - "fieldtype": "Data", - "label": "Postal Code" - }, { "fieldname": "column_break2", "fieldtype": "Column Break" @@ -289,7 +219,7 @@ { "fieldname": "mobile_no", "fieldtype": "Data", - "label": "Mobile No.", + "label": "Mobile No", "oldfieldname": "mobile_no", "oldfieldtype": "Data", "options": "Phone" @@ -380,14 +310,6 @@ "label": "Title", "print_hide": 1 }, - { - "fieldname": "designation", - "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Designation", - "options": "Designation" - }, { "fieldname": "language", "fieldtype": "Link", @@ -410,12 +332,6 @@ "fieldtype": "Data", "label": "Last Name" }, - { - "collapsible": 1, - "fieldname": "additional_information_section", - "fieldtype": "Section Break", - "label": "Additional Information" - }, { "fieldname": "no_of_employees", "fieldtype": "Int", @@ -428,35 +344,13 @@ { "fieldname": "whatsapp_no", "fieldtype": "Data", - "label": "WhatsApp No.", + "label": "WhatsApp", "options": "Phone" }, - { - "collapsible": 1, - "depends_on": "eval: !doc.__islocal", - "fieldname": "address_section", - "fieldtype": "Section Break", - "label": "Address" - }, - { - "fieldname": "lead_source_details_section", - "fieldtype": "Section Break", - "label": "Lead Source Details" - }, { "fieldname": "column_break_50", "fieldtype": "Column Break" }, - { - "fieldname": "other_information_section", - "fieldtype": "Section Break", - "label": "Other Information" - }, - { - "fieldname": "contact_details_section", - "fieldtype": "Section Break", - "label": "Contact Details" - }, { "fieldname": "column_break_16", "fieldtype": "Column Break" @@ -465,17 +359,136 @@ "fieldname": "phone_ext", "fieldtype": "Data", "label": "Phone Ext." + }, + { + "collapsible": 1, + "fieldname": "qualification_tab", + "fieldtype": "Section Break", + "label": "Qualification" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "notes_tab", + "fieldtype": "Tab Break", + "label": "Notes" + }, + { + "collapsible": 1, + "fieldname": "other_info_tab", + "fieldtype": "Section Break", + "label": "Additional Information" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "qualified_by", + "fieldtype": "Link", + "label": "Qualified By", + "options": "User" + }, + { + "fieldname": "qualified_on", + "fieldtype": "Date", + "label": "Qualified on" + }, + { + "fieldname": "qualification_status", + "fieldtype": "Select", + "label": "Qualification Status", + "options": "Unqualified\nIn Process\nQualified" + }, + { + "collapsible": 1, + "fieldname": "address_section", + "fieldtype": "Section Break", + "label": "Address & Contacts" + }, + { + "fieldname": "column_break_64", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, + { + "fieldname": "job_title", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Job Title" + }, + { + "fieldname": "annual_revenue", + "fieldtype": "Currency", + "label": "Annual Revenue" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "activities_tab", + "fieldtype": "Tab Break", + "label": "Activities" + }, + { + "fieldname": "organization_section", + "fieldtype": "Section Break", + "label": "Organization" + }, + { + "fieldname": "column_break_28", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_31", + "fieldtype": "Column Break" + }, + { + "fieldname": "notes_html", + "fieldtype": "HTML", + "label": "Notes HTML" + }, + { + "fieldname": "open_activities_html", + "fieldtype": "HTML", + "label": "Open Activities HTML" + }, + { + "fieldname": "all_activities_section", + "fieldtype": "Section Break", + "label": "All Activities" + }, + { + "fieldname": "all_activities_html", + "fieldtype": "HTML", + "label": "All Activities HTML" + }, + { + "fieldname": "notes", + "fieldtype": "Table", + "hidden": 1, + "label": "Notes", + "no_copy": 1, + "options": "CRM Note" + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" } ], "icon": "fa fa-user", "idx": 5, "image_field": "image", "links": [], - "modified": "2021-08-04 00:24:57.208590", + "modified": "2022-06-06 18:10:14.494424", "modified_by": "Administrator", "module": "CRM", "name": "Lead", "name_case": "Title Case", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -535,6 +548,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "subject_field": "title", "title_field": "title" } \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index c9a64ff8e6..0d12499771 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -1,27 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import frappe from frappe import _ from frappe.contacts.address_and_contact import load_address_and_contact from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc -from frappe.utils import ( - comma_and, - cstr, - get_link_to_form, - getdate, - has_gravatar, - nowdate, - validate_email_address, -) +from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address from erpnext.accounts.party import set_taxes from erpnext.controllers.selling_controller import SellingController +from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events -class Lead(SellingController): +class Lead(SellingController, CRMNote): def get_feed(self): return "{0}: {1}".format(_(self.status), self.lead_name) @@ -29,6 +21,7 @@ class Lead(SellingController): customer = frappe.db.get_value("Customer", {"lead_name": self.name}) self.get("__onload").is_customer = customer load_address_and_contact(self) + self.set_onload("linked_prospects", self.get_linked_prospects()) def validate(self): self.set_full_name() @@ -37,79 +30,42 @@ class Lead(SellingController): self.set_status() self.check_email_id_is_unique() self.validate_email_id() - self.validate_contact_date() - self.set_prev() + + def before_insert(self): + self.contact_doc = None + if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"): + self.contact_doc = self.create_contact() + + def after_insert(self): + self.link_to_contact() + + def on_update(self): + self.update_prospect() + + def on_trash(self): + frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name) + + self.unlink_dynamic_links() + self.remove_link_from_prospect() def set_full_name(self): if self.first_name: - self.lead_name = " ".join(filter(None, [self.first_name, self.middle_name, self.last_name])) - - def validate_email_id(self): - if self.email_id: - if not self.flags.ignore_email_validation: - validate_email_address(self.email_id, throw=True) - - if self.email_id == self.lead_owner: - frappe.throw(_("Lead Owner cannot be same as the Lead")) - - if self.email_id == self.contact_by: - frappe.throw(_("Next Contact By cannot be same as the Lead Email Address")) - - if self.is_new() or not self.image: - self.image = has_gravatar(self.email_id) - - def validate_contact_date(self): - if self.contact_date and getdate(self.contact_date) < getdate(nowdate()): - frappe.throw(_("Next Contact Date cannot be in the past")) - - if self.ends_on and self.contact_date and (getdate(self.ends_on) < getdate(self.contact_date)): - frappe.throw(_("Ends On date cannot be before Next Contact Date.")) - - def on_update(self): - self.add_calendar_event() - self.update_prospects() - - def set_prev(self): - if self.is_new(): - self._prev = frappe._dict({"contact_date": None, "ends_on": None, "contact_by": None}) - else: - self._prev = frappe.db.get_value( - "Lead", self.name, ["contact_date", "ends_on", "contact_by"], as_dict=1 + self.lead_name = " ".join( + filter(None, [self.salutation, self.first_name, self.middle_name, self.last_name]) ) - def before_insert(self): - self.contact_doc = self.create_contact() + def set_lead_name(self): + if not self.lead_name: + # Check for leads being created through data import + if not self.company_name and not self.email_id and not self.flags.ignore_mandatory: + frappe.throw(_("A Lead requires either a person's name or an organization's name")) + elif self.company_name: + self.lead_name = self.company_name + else: + self.lead_name = self.email_id.split("@")[0] - def after_insert(self): - self.update_links() - - def update_links(self): - # update contact links - if self.contact_doc: - self.contact_doc.append( - "links", {"link_doctype": "Lead", "link_name": self.name, "link_title": self.lead_name} - ) - self.contact_doc.save() - - def add_calendar_event(self, opts=None, force=False): - if frappe.db.get_single_value("CRM Settings", "create_event_on_next_contact_date"): - super(Lead, self).add_calendar_event( - { - "owner": self.lead_owner, - "starts_on": self.contact_date, - "ends_on": self.ends_on or "", - "subject": ("Contact " + cstr(self.lead_name)), - "description": ("Contact " + cstr(self.lead_name)) - + (self.contact_by and (". By : " + cstr(self.contact_by)) or ""), - }, - force, - ) - - def update_prospects(self): - prospects = frappe.get_all("Prospect Lead", filters={"lead": self.name}, fields=["parent"]) - for row in prospects: - prospect = frappe.get_doc("Prospect", row.parent) - prospect.save(ignore_permissions=True) + def set_title(self): + self.title = self.company_name or self.lead_name def check_email_id_is_unique(self): if self.email_id: @@ -124,15 +80,47 @@ class Lead(SellingController): if duplicate_leads: frappe.throw( - _("Email Address must be unique, already exists for {0}").format(comma_and(duplicate_leads)), + _("Email Address must be unique, it is already used in {0}").format( + comma_and(duplicate_leads) + ), frappe.DuplicateEntryError, ) - def on_trash(self): - frappe.db.sql("""update `tabIssue` set lead='' where lead=%s""", self.name) + def validate_email_id(self): + if self.email_id: + if not self.flags.ignore_email_validation: + validate_email_address(self.email_id, throw=True) - self.unlink_dynamic_links() - self.delete_events() + if self.email_id == self.lead_owner: + frappe.throw(_("Lead Owner cannot be same as the Lead Email Address")) + + if self.is_new() or not self.image: + self.image = has_gravatar(self.email_id) + + def link_to_contact(self): + # update contact links + if self.contact_doc: + self.contact_doc.append( + "links", {"link_doctype": "Lead", "link_name": self.name, "link_title": self.lead_name} + ) + self.contact_doc.save() + + def update_prospect(self): + lead_row_name = frappe.db.get_value( + "Prospect Lead", filters={"lead": self.name}, fieldname="name" + ) + if lead_row_name: + lead_row = frappe.get_doc("Prospect Lead", lead_row_name) + lead_row.update( + { + "lead_name": self.lead_name, + "email": self.email_id, + "mobile_no": self.mobile_no, + "lead_owner": self.lead_owner, + "status": self.status, + } + ) + lead_row.db_update() def unlink_dynamic_links(self): links = frappe.get_all( @@ -155,6 +143,30 @@ class Lead(SellingController): linked_doc.remove(to_remove) linked_doc.save(ignore_permissions=True) + def remove_link_from_prospect(self): + prospects = self.get_linked_prospects() + + for d in prospects: + prospect = frappe.get_doc("Prospect", d.parent) + if len(prospect.get("leads")) == 1: + prospect.delete(ignore_permissions=True) + else: + to_remove = None + for d in prospect.get("leads"): + if d.lead == self.name: + to_remove = d + + if to_remove: + prospect.remove(to_remove) + prospect.save(ignore_permissions=True) + + def get_linked_prospects(self): + return frappe.get_all( + "Prospect Lead", + filters={"lead": self.name}, + fields=["parent"], + ) + def has_customer(self): return frappe.db.get_value("Customer", {"lead_name": self.name}) @@ -171,50 +183,78 @@ class Lead(SellingController): "Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"} ) - def set_lead_name(self): - if not self.lead_name: - # Check for leads being created through data import - if not self.company_name and not self.email_id and not self.flags.ignore_mandatory: - frappe.throw(_("A Lead requires either a person's name or an organization's name")) - elif self.company_name: - self.lead_name = self.company_name - else: - self.lead_name = self.email_id.split("@")[0] + @frappe.whitelist() + def create_prospect_and_contact(self, data): + data = frappe._dict(data) + if data.create_contact: + self.create_contact() - def set_title(self): - self.title = self.company_name or self.lead_name + if data.create_prospect: + self.create_prospect(data.prospect_name) def create_contact(self): - if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"): - if not self.lead_name: - self.set_full_name() - self.set_lead_name() + if not self.lead_name: + self.set_full_name() + self.set_lead_name() - contact = frappe.new_doc("Contact") - contact.update( + contact = frappe.new_doc("Contact") + contact.update( + { + "first_name": self.first_name or self.lead_name, + "last_name": self.last_name, + "salutation": self.salutation, + "gender": self.gender, + "job_title": self.job_title, + "company_name": self.company_name, + } + ) + + if self.email_id: + contact.append("email_ids", {"email_id": self.email_id, "is_primary": 1}) + + if self.phone: + contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1}) + + if self.mobile_no: + contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1}) + + contact.insert(ignore_permissions=True) + contact.reload() # load changes by hooks on contact + + return contact + + def create_prospect(self, company_name): + try: + prospect = frappe.new_doc("Prospect") + + prospect.company_name = company_name or self.company_name + prospect.no_of_employees = self.no_of_employees + prospect.industry = self.industry + prospect.market_segment = self.market_segment + prospect.annual_revenue = self.annual_revenue + prospect.territory = self.territory + prospect.fax = self.fax + prospect.website = self.website + prospect.prospect_owner = self.lead_owner + prospect.company = self.company + prospect.notes = self.notes + + prospect.append( + "leads", { - "first_name": self.first_name or self.lead_name, - "last_name": self.last_name, - "salutation": self.salutation, - "gender": self.gender, - "designation": self.designation, - "company_name": self.company_name, - } + "lead": self.name, + "lead_name": self.lead_name, + "email": self.email_id, + "mobile_no": self.mobile_no, + "lead_owner": self.lead_owner, + "status": self.status, + }, ) - - if self.email_id: - contact.append("email_ids", {"email_id": self.email_id, "is_primary": 1}) - - if self.phone: - contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1}) - - if self.mobile_no: - contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1}) - - contact.insert(ignore_permissions=True) - contact.reload() # load changes by hooks on contact - - return contact + prospect.flags.ignore_permissions = True + prospect.flags.ignore_mandatory = True + prospect.save() + except frappe.DuplicateEntryError: + frappe.throw(_("Prospect {0} already exists").format(company_name or self.company_name)) @frappe.whitelist() @@ -274,6 +314,8 @@ def make_opportunity(source_name, target_doc=None): "company_name": "customer_name", "email_id": "contact_email", "mobile_no": "contact_mobile", + "lead_owner": "opportunity_owner", + "notes": "notes", }, } }, @@ -422,21 +464,25 @@ def get_lead_with_phone_number(number): return lead -def daily_open_lead(): - leads = frappe.get_all("Lead", filters=[["contact_date", "Between", [nowdate(), nowdate()]]]) - for lead in leads: - frappe.db.set_value("Lead", lead.name, "status", "Open") - - @frappe.whitelist() def add_lead_to_prospect(lead, prospect): prospect = frappe.get_doc("Prospect", prospect) - prospect.append("prospect_lead", {"lead": lead}) + prospect.append("leads", {"lead": lead}) prospect.save(ignore_permissions=True) + + carry_forward_communication_and_comments = frappe.db.get_single_value( + "CRM Settings", "carry_forward_communication_and_comments" + ) + + if carry_forward_communication_and_comments: + copy_comments("Lead", lead, prospect) + link_communications("Lead", lead, prospect) + link_open_events("Lead", lead, prospect) + frappe.msgprint( _("Lead {0} has been added to prospect {1}.").format( frappe.bold(lead), frappe.bold(prospect.name) ), - title=_("Lead Added"), + title=_("Lead -> Prospect"), indicator="green", ) diff --git a/erpnext/crm/doctype/lead/lead_list.js b/erpnext/crm/doctype/lead/lead_list.js index 75208fa64b..dbeaf608ff 100644 --- a/erpnext/crm/doctype/lead/lead_list.js +++ b/erpnext/crm/doctype/lead/lead_list.js @@ -16,7 +16,7 @@ frappe.listview_settings['Lead'] = { prospect.prospect_owner = r.lead_owner; leads.forEach(function(lead) { - let lead_prospect_row = frappe.model.add_child(prospect, 'prospect_lead'); + let lead_prospect_row = frappe.model.add_child(prospect, 'leads'); lead_prospect_row.lead = lead.name; }); frappe.set_route("Form", "Prospect", prospect.name); diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py index 166ae2c353..8fe688de46 100644 --- a/erpnext/crm/doctype/lead/test_lead.py +++ b/erpnext/crm/doctype/lead/test_lead.py @@ -5,7 +5,10 @@ import unittest import frappe -from frappe.utils import random_string +from frappe.utils import random_string, today + +from erpnext.crm.doctype.lead.lead import make_opportunity +from erpnext.crm.utils import get_linked_prospect test_records = frappe.get_test_records("Lead") @@ -83,6 +86,105 @@ class TestLead(unittest.TestCase): self.assertEqual(frappe.db.exists("Lead", lead_doc.name), None) self.assertEqual(len(address_1.get("links")), 1) + def test_prospect_creation_from_lead(self): + frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") + frappe.db.sql("delete from `tabProspect` where name='Prospect Company'") + + lead = make_lead( + first_name="Rahul", + last_name="Tripathi", + email_id="rahul@gmail.com", + company_name="Prospect Company", + ) + + event = create_event("Meeting 1", today(), "Lead", lead.name) + + lead.create_prospect(lead.company_name) + + prospect = get_linked_prospect("Lead", lead.name) + self.assertEqual(prospect, "Prospect Company") + + event.reload() + self.assertEqual(event.event_participants[1].reference_doctype, "Prospect") + self.assertEqual(event.event_participants[1].reference_docname, prospect) + + def test_opportunity_from_lead(self): + frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") + frappe.db.sql("delete from `tabOpportunity` where party_name='Rahul Tripathi'") + + lead = make_lead( + first_name="Rahul", + last_name="Tripathi", + email_id="rahul@gmail.com", + company_name="Prospect Company", + ) + + lead.add_note("test note") + event = create_event("Meeting 1", today(), "Lead", lead.name) + create_todo("followup", "Lead", lead.name) + + opportunity = make_opportunity(lead.name) + opportunity.save() + + self.assertEqual(opportunity.get("party_name"), lead.name) + self.assertEqual(opportunity.notes[0].note, "test note") + + event.reload() + self.assertEqual(event.event_participants[1].reference_doctype, "Opportunity") + self.assertEqual(event.event_participants[1].reference_docname, opportunity.name) + + self.assertTrue( + frappe.db.get_value( + "ToDo", {"reference_type": "Opportunity", "reference_name": opportunity.name} + ) + ) + + def test_copy_events_from_lead_to_prospect(self): + frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") + frappe.db.sql("delete from `tabProspect` where name='Prospect Company'") + + lead = make_lead( + first_name="Rahul", + last_name="Tripathi", + email_id="rahul@gmail.com", + company_name="Prospect Company", + ) + + lead.create_prospect(lead.company_name) + prospect = get_linked_prospect("Lead", lead.name) + + event = create_event("Meeting", today(), "Lead", lead.name) + + self.assertEqual(len(event.event_participants), 2) + self.assertEqual(event.event_participants[1].reference_doctype, "Prospect") + self.assertEqual(event.event_participants[1].reference_docname, prospect) + + +def create_event(subject, starts_on, reference_type, reference_name): + event = frappe.new_doc("Event") + event.subject = subject + event.starts_on = starts_on + event.event_type = "Private" + event.all_day = 1 + event.owner = "Administrator" + event.append( + "event_participants", {"reference_doctype": reference_type, "reference_docname": reference_name} + ) + event.reference_type = reference_type + event.reference_name = reference_name + event.insert() + return event + + +def create_todo(description, reference_type, reference_name): + todo = frappe.new_doc("ToDo") + todo.description = description + todo.owner = "Administrator" + todo.reference_type = reference_type + todo.reference_name = reference_name + todo.insert() + return todo + def make_lead(**args): args = frappe._dict(args) @@ -93,6 +195,7 @@ def make_lead(**args): "first_name": args.first_name or "_Test", "last_name": args.last_name or "Lead", "email_id": args.email_id or "new_lead_{}@example.com".format(random_string(5)), + "company_name": args.company_name or "_Test Company", } ).insert() diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js index 8e7d67e057..c53ea9d5c3 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.js +++ b/erpnext/crm/doctype/opportunity/opportunity.js @@ -32,13 +32,6 @@ frappe.ui.form.on("Opportunity", { } }, - contact_date: function(frm) { - if(frm.doc.contact_date < frappe.datetime.now_datetime()){ - frm.set_value("contact_date", ""); - frappe.throw(__("Next follow up date should be greater than now.")) - } - }, - onload_post_render: function(frm) { frm.get_field("items").grid.set_multiple_add("item_code", "qty"); }, @@ -130,6 +123,13 @@ frappe.ui.form.on("Opportunity", { }); } } + + if (!frm.is_new()) { + frappe.contacts.render_address_and_contact(frm); + // frm.trigger('render_contact_day_html'); + } else { + frappe.contacts.clear_address_and_contact(frm); + } }, set_contact_link: function(frm) { @@ -227,8 +227,7 @@ frappe.ui.form.on("Opportunity", { 'total': flt(total), 'base_total': flt(base_total) }); - } - + }, }); frappe.ui.form.on("Opportunity Item", { calculate: function(frm, cdt, cdn) { @@ -264,13 +263,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { this.frm.trigger('currency'); } + refresh() { + this.show_notes(); + this.show_activities(); + } + setup_queries() { var me = this; - if(this.frm.fields_dict.contact_by.df.options.match(/^User/)) { - this.frm.set_query("contact_by", erpnext.queries.user); - } - me.frm.set_query('customer_address', erpnext.queries.address_query); this.frm.set_query("item_code", "items", function() { @@ -287,6 +287,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { } else if (me.frm.doc.opportunity_from == "Customer") { me.frm.set_query('party_name', erpnext.queries['customer']); + } else if (me.frm.doc.opportunity_from == "Prospect") { + me.frm.set_query('party_name', function() { + return { + filters: { + "company": me.frm.doc.company + } + }; + }); } } @@ -303,6 +311,24 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller { frm: cur_frm }) } + + show_notes() { + const crm_notes = new erpnext.utils.CRMNotes({ + frm: this.frm, + notes_wrapper: $(this.frm.fields_dict.notes_html.wrapper), + }); + crm_notes.refresh(); + } + + show_activities() { + const crm_activities = new erpnext.utils.CRMActivities({ + frm: this.frm, + open_activities_wrapper: $(this.frm.fields_dict.open_activities_html.wrapper), + all_activities_wrapper: $(this.frm.fields_dict.all_activities_html.wrapper), + form_wrapper: $(this.frm.wrapper), + }); + crm_activities.refresh(); + } }; extend_cscript(cur_frm.cscript, new erpnext.crm.Opportunity({frm: cur_frm})); diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 089f2d2faa..7854007919 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_events_in_timeline": 1, "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", @@ -11,68 +12,84 @@ "email_append_to": 1, "engine": "InnoDB", "field_order": [ - "from_section", "naming_series", "opportunity_from", "party_name", "customer_name", - "source", "column_break0", - "title", "opportunity_type", + "source", + "opportunity_owner", + "column_break_10", "status", - "converted_by", "sales_stage", - "first_response_time", "expected_closing", - "next_contact", - "contact_by", - "contact_date", - "column_break2", - "to_discuss", + "probability", + "organization_details_section", + "no_of_employees", + "annual_revenue", + "column_break_23", + "customer_group", + "industry", + "column_break_31", + "market_segment", + "territory", + "website", "section_break_14", "currency", + "column_break_36", "conversion_rate", - "base_opportunity_amount", - "with_items", "column_break_17", - "probability", "opportunity_amount", + "base_opportunity_amount", + "more_info", + "company", + "campaign", + "transaction_date", + "column_break1", + "language", + "amended_from", + "title", + "first_response_time", + "lost_detail_section", + "lost_reasons", + "order_lost_reason", + "column_break_56", + "competitors", + "contact_info", + "primary_contact_section", + "contact_person", + "job_title", + "column_break_54", + "contact_email", + "contact_mobile", + "column_break_22", + "whatsapp", + "phone", + "phone_ext", + "address_contact_section", + "address_html", + "customer_address", + "address_display", + "column_break3", + "contact_html", + "contact_display", "items_section", "items", "section_break_32", "base_total", "column_break_33", "total", - "contact_info", - "customer_address", - "address_display", - "territory", - "customer_group", - "column_break3", - "contact_person", - "contact_display", - "contact_email", - "contact_mobile", - "more_info", - "company", - "campaign", - "column_break1", - "transaction_date", - "language", - "amended_from", - "lost_detail_section", - "lost_reasons", - "order_lost_reason", - "column_break_56", - "competitors" + "activities_tab", + "open_activities_html", + "all_activities_section", + "all_activities_html", + "notes_tab", + "notes_html", + "notes", + "dashboard_tab" ], "fields": [ - { - "fieldname": "from_section", - "fieldtype": "Section Break", - "options": "fa fa-user" - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -113,8 +130,9 @@ "bold": 1, "fieldname": "customer_name", "fieldtype": "Data", + "hidden": 1, "in_global_search": 1, - "label": "Customer / Lead Name", + "label": "Customer Name", "read_only": 1 }, { @@ -166,48 +184,10 @@ "fieldtype": "Date", "label": "Expected Closing Date" }, - { - "collapsible": 1, - "collapsible_depends_on": "contact_by", - "fieldname": "next_contact", - "fieldtype": "Section Break", - "label": "Follow Up" - }, - { - "fieldname": "contact_by", - "fieldtype": "Link", - "in_standard_filter": 1, - "label": "Next Contact By", - "oldfieldname": "contact_by", - "oldfieldtype": "Link", - "options": "User", - "width": "75px" - }, - { - "fieldname": "contact_date", - "fieldtype": "Datetime", - "label": "Next Contact Date", - "oldfieldname": "contact_date", - "oldfieldtype": "Date" - }, - { - "fieldname": "column_break2", - "fieldtype": "Column Break", - "oldfieldtype": "Column Break", - "width": "50%" - }, - { - "fieldname": "to_discuss", - "fieldtype": "Small Text", - "label": "To Discuss", - "no_copy": 1, - "oldfieldname": "to_discuss", - "oldfieldtype": "Small Text" - }, { "fieldname": "section_break_14", "fieldtype": "Section Break", - "label": "Sales" + "label": "Opportunity Value" }, { "fieldname": "currency", @@ -221,12 +201,6 @@ "label": "Opportunity Amount", "options": "currency" }, - { - "default": "0", - "fieldname": "with_items", - "fieldtype": "Check", - "label": "With Items" - }, { "fieldname": "column_break_17", "fieldtype": "Column Break" @@ -245,9 +219,8 @@ "label": "Probability (%)" }, { - "depends_on": "with_items", "fieldname": "items_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Items", "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" @@ -262,18 +235,16 @@ "options": "Opportunity Item" }, { - "collapsible": 1, - "collapsible_depends_on": "next_contact_by", - "depends_on": "eval:doc.party_name", "fieldname": "contact_info", - "fieldtype": "Section Break", - "label": "Contact Info", + "fieldtype": "Tab Break", + "label": "Contacts", "options": "fa fa-bullhorn" }, { "depends_on": "eval:doc.party_name", "fieldname": "customer_address", "fieldtype": "Link", + "hidden": 1, "label": "Customer / Lead Address", "options": "Address", "print_hide": 1 @@ -327,19 +298,16 @@ "read_only": 1 }, { - "depends_on": "eval:doc.party_name", "fieldname": "contact_email", "fieldtype": "Data", "label": "Contact Email", - "options": "Email", - "read_only": 1 + "options": "Email" }, { - "depends_on": "eval:doc.party_name", "fieldname": "contact_mobile", - "fieldtype": "Small Text", - "label": "Contact Mobile No", - "read_only": 1 + "fieldtype": "Data", + "label": "Contact Mobile", + "options": "Phone" }, { "collapsible": 1, @@ -416,12 +384,6 @@ "options": "Opportunity Lost Reason Detail", "read_only": 1 }, - { - "fieldname": "converted_by", - "fieldtype": "Link", - "label": "Converted By", - "options": "User" - }, { "bold": 1, "fieldname": "first_response_time", @@ -474,6 +436,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.status===\"Lost\"", "fieldname": "lost_detail_section", "fieldtype": "Section Break", "label": "Lost Reasons" @@ -488,12 +451,163 @@ "label": "Competitors", "options": "Competitor Detail", "read_only": 1 + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "organization_details_section", + "fieldtype": "Section Break", + "label": "Organization" + }, + { + "fieldname": "no_of_employees", + "fieldtype": "Int", + "label": "No of Employees" + }, + { + "fieldname": "annual_revenue", + "fieldtype": "Currency", + "label": "Annual Revenue" + }, + { + "fieldname": "industry", + "fieldtype": "Link", + "label": "Industry", + "options": "Industry Type" + }, + { + "fieldname": "market_segment", + "fieldtype": "Link", + "label": "Market Segment", + "options": "Market Segment" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "fieldname": "address_contact_section", + "fieldtype": "Section Break", + "label": "Address & Contact" + }, + { + "fieldname": "column_break_36", + "fieldtype": "Column Break" + }, + { + "fieldname": "opportunity_owner", + "fieldtype": "Link", + "label": "Opportunity Owner", + "options": "User" + }, + { + "fieldname": "website", + "fieldtype": "Data", + "label": "Website" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "whatsapp", + "fieldtype": "Data", + "label": "WhatsApp", + "options": "Phone" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "options": "Phone" + }, + { + "fieldname": "phone_ext", + "fieldtype": "Data", + "label": "Phone Ext." + }, + { + "fieldname": "column_break_31", + "fieldtype": "Column Break" + }, + { + "fieldname": "primary_contact_section", + "fieldtype": "Section Break", + "label": "Primary Contact" + }, + { + "fieldname": "column_break_54", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "dashboard_tab", + "fieldtype": "Tab Break", + "label": "Dashboard", + "show_dashboard": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "notes_tab", + "fieldtype": "Tab Break", + "label": "Notes" + }, + { + "fieldname": "notes_html", + "fieldtype": "HTML", + "label": "Notes HTML" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "activities_tab", + "fieldtype": "Tab Break", + "label": "Activities" + }, + { + "fieldname": "job_title", + "fieldtype": "Data", + "label": "Job Title" + }, + { + "fieldname": "address_html", + "fieldtype": "HTML", + "label": "Address HTML" + }, + { + "fieldname": "contact_html", + "fieldtype": "HTML", + "label": "Contact HTML" + }, + { + "fieldname": "open_activities_html", + "fieldtype": "HTML", + "label": "Open Activities HTML" + }, + { + "fieldname": "all_activities_section", + "fieldtype": "Section Break", + "label": "All Activities" + }, + { + "fieldname": "all_activities_html", + "fieldtype": "HTML", + "label": "All Activities HTML" + }, + { + "fieldname": "notes", + "fieldtype": "Table", + "hidden": 1, + "label": "Notes", + "no_copy": 1, + "options": "CRM Note" } ], "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-01-29 19:32:26.382896", + "modified": "2022-06-10 10:37:25.537444", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index c70a4f61b8..0dc0cd3762 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -6,39 +6,42 @@ import json import frappe from frappe import _ +from frappe.contacts.address_and_contact import load_address_and_contact from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import DocType, Interval from frappe.query_builder.functions import Now -from frappe.utils import cint, flt, get_fullname +from frappe.utils import flt, get_fullname -from erpnext.crm.utils import add_link_in_communication, copy_comments +from erpnext.crm.utils import ( + CRMNote, + copy_comments, + link_communications, + link_open_events, + link_open_tasks, +) from erpnext.setup.utils import get_exchange_rate from erpnext.utilities.transaction_base import TransactionBase -class Opportunity(TransactionBase): +class Opportunity(TransactionBase, CRMNote): + def onload(self): + ref_doc = frappe.get_doc(self.opportunity_from, self.party_name) + load_address_and_contact(ref_doc) + self.set("__onload", ref_doc.get("__onload")) + def after_insert(self): if self.opportunity_from == "Lead": frappe.get_doc("Lead", self.party_name).set_status(update=True) + self.disable_lead() - if self.opportunity_from in ["Lead", "Prospect"]: + link_open_tasks(self.opportunity_from, self.party_name, self) + link_open_events(self.opportunity_from, self.party_name, self) if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"): copy_comments(self.opportunity_from, self.party_name, self) - add_link_in_communication(self.opportunity_from, self.party_name, self) + link_communications(self.opportunity_from, self.party_name, self) def validate(self): - self._prev = frappe._dict( - { - "contact_date": frappe.db.get_value("Opportunity", self.name, "contact_date") - if (not cint(self.get("__islocal"))) - else None, - "contact_by": frappe.db.get_value("Opportunity", self.name, "contact_by") - if (not cint(self.get("__islocal"))) - else None, - } - ) - self.make_new_lead_if_required() self.validate_item_details() self.validate_uom_is_integer("uom", "qty") @@ -48,11 +51,8 @@ class Opportunity(TransactionBase): if not self.title: self.title = self.customer_name - if not self.with_items: - self.items = [] - - else: - self.calculate_totals() + self.calculate_totals() + self.update_prospect() def map_fields(self): for field in self.meta.get_valid_columns(): @@ -75,6 +75,44 @@ class Opportunity(TransactionBase): self.total = flt(total) self.base_total = flt(base_total) + def update_prospect(self): + prospect_name = None + if self.opportunity_from == "Prospect" and self.party_name: + prospect_name = self.party_name + elif self.opportunity_from == "Lead": + prospect_name = frappe.db.get_value("Prospect Lead", {"lead": self.party_name}, "parent") + + if prospect_name: + prospect = frappe.get_doc("Prospect", prospect_name) + + opportunity_values = { + "opportunity": self.name, + "amount": self.opportunity_amount, + "stage": self.sales_stage, + "deal_owner": self.opportunity_owner, + "probability": self.probability, + "expected_closing": self.expected_closing, + "currency": self.currency, + "contact_person": self.contact_person, + } + + opportunity_already_added = False + for d in prospect.get("opportunities", []): + if d.opportunity == self.name: + opportunity_already_added = True + d.update(opportunity_values) + d.db_update() + + if not opportunity_already_added: + prospect.append("opportunities", opportunity_values) + prospect.flags.ignore_permissions = True + prospect.flags.ignore_mandatory = True + prospect.save() + + def disable_lead(self): + if self.opportunity_from == "Lead": + frappe.db.set_value("Lead", self.party_name, {"disabled": 1, "docstatus": 1}) + def make_new_lead_if_required(self): """Set lead against new opportunity""" if (not self.get("party_name")) and self.contact_email: @@ -144,11 +182,8 @@ class Opportunity(TransactionBase): else: frappe.throw(_("Cannot declare as lost, because Quotation has been made.")) - def on_trash(self): - self.delete_events() - def has_active_quotation(self): - if not self.with_items: + if not self.get("items", []): return frappe.get_all( "Quotation", {"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1}, @@ -165,7 +200,7 @@ class Opportunity(TransactionBase): ) def has_ordered_quotation(self): - if not self.with_items: + if not self.get("items", []): return frappe.get_all( "Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name" ) @@ -195,43 +230,20 @@ class Opportunity(TransactionBase): return True def validate_cust_name(self): - if self.party_name and self.opportunity_from == "Customer": - self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name") - elif self.party_name and self.opportunity_from == "Lead": - lead_name, company_name = frappe.db.get_value( - "Lead", self.party_name, ["lead_name", "company_name"] - ) - self.customer_name = company_name or lead_name + if self.party_name: + if self.opportunity_from == "Customer": + self.customer_name = frappe.db.get_value("Customer", self.party_name, "customer_name") + elif self.opportunity_from == "Lead": + customer_name = frappe.db.get_value("Prospect Lead", {"lead": self.party_name}, "parent") + if not customer_name: + lead_name, company_name = frappe.db.get_value( + "Lead", self.party_name, ["lead_name", "company_name"] + ) + customer_name = company_name or lead_name - def on_update(self): - self.add_calendar_event() - - def add_calendar_event(self, opts=None, force=False): - if frappe.db.get_single_value("CRM Settings", "create_event_on_next_contact_date_opportunity"): - if not opts: - opts = frappe._dict() - - opts.description = "" - opts.contact_date = self.contact_date - - if self.party_name and self.opportunity_from == "Customer": - if self.contact_person: - opts.description = f"Contact {self.contact_person}" - else: - opts.description = f"Contact customer {self.party_name}" - elif self.party_name and self.opportunity_from == "Lead": - if self.contact_display: - opts.description = f"Contact {self.contact_display}" - else: - opts.description = f"Contact lead {self.party_name}" - - opts.subject = opts.description - opts.description += f". By : {self.contact_by}" - - if self.to_discuss: - opts.description += f" To Discuss : {frappe.render_template(self.to_discuss, {'doc': self})}" - - super(Opportunity, self).add_calendar_event(opts, force) + self.customer_name = customer_name + elif self.opportunity_from == "Prospect": + self.customer_name = self.party_name def validate_item_details(self): if not self.get("items"): @@ -295,7 +307,7 @@ def make_quotation(source_name, target_doc=None): quotation.run_method("set_missing_values") quotation.run_method("calculate_taxes_and_totals") - if not source.with_items: + if not source.get("items", []): quotation.opportunity = source.name doclist = get_mapped_doc( @@ -440,34 +452,3 @@ def make_opportunity_from_communication(communication, company, ignore_communica link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links) return opportunity.name - - -@frappe.whitelist() -def get_events(start, end, filters=None): - """Returns events for Gantt / Calendar view rendering. - :param start: Start date-time. - :param end: End date-time. - :param filters: Filters (JSON). - """ - from frappe.desk.calendar import get_event_conditions - - conditions = get_event_conditions("Opportunity", filters) - - data = frappe.db.sql( - """ - select - distinct `tabOpportunity`.name, `tabOpportunity`.customer_name, `tabOpportunity`.opportunity_amount, - `tabOpportunity`.title, `tabOpportunity`.contact_date - from - `tabOpportunity` - where - (`tabOpportunity`.contact_date between %(start)s and %(end)s) - {conditions} - """.format( - conditions=conditions - ), - {"start": start, "end": end}, - as_dict=True, - update={"allDay": 0}, - ) - return data diff --git a/erpnext/crm/doctype/opportunity/opportunity_calendar.js b/erpnext/crm/doctype/opportunity/opportunity_calendar.js deleted file mode 100644 index 58fa2b8cd8..0000000000 --- a/erpnext/crm/doctype/opportunity/opportunity_calendar.js +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt -frappe.views.calendar["Opportunity"] = { - field_map: { - "start": "contact_date", - "end": "contact_date", - "id": "name", - "title": "customer_name", - "allDay": "allDay" - }, - options: { - header: { - left: 'prev,next today', - center: 'title', - right: 'month' - } - }, - get_events_method: 'erpnext.crm.doctype.opportunity.opportunity.get_events' -} diff --git a/erpnext/crm/doctype/prospect/prospect.js b/erpnext/crm/doctype/prospect/prospect.js index 8721a5b42d..495ed291ae 100644 --- a/erpnext/crm/doctype/prospect/prospect.js +++ b/erpnext/crm/doctype/prospect/prospect.js @@ -27,5 +27,26 @@ frappe.ui.form.on('Prospect', { } else { frappe.contacts.clear_address_and_contact(frm); } + frm.trigger("show_notes"); + frm.trigger("show_activities"); + }, + + show_notes (frm) { + const crm_notes = new erpnext.utils.CRMNotes({ + frm: frm, + notes_wrapper: $(frm.fields_dict.notes_html.wrapper), + }); + crm_notes.refresh(); + }, + + show_activities (frm) { + const crm_activities = new erpnext.utils.CRMActivities({ + frm: frm, + open_activities_wrapper: $(frm.fields_dict.open_activities_html.wrapper), + all_activities_wrapper: $(frm.fields_dict.all_activities_html.wrapper), + form_wrapper: $(frm.wrapper), + }); + crm_activities.refresh(); } + }); diff --git a/erpnext/crm/doctype/prospect/prospect.json b/erpnext/crm/doctype/prospect/prospect.json index c9554ba31a..2750e5b421 100644 --- a/erpnext/crm/doctype/prospect/prospect.json +++ b/erpnext/crm/doctype/prospect/prospect.json @@ -1,33 +1,42 @@ { "actions": [], + "allow_events_in_timeline": 1, "autoname": "field:company_name", "creation": "2021-08-19 00:21:06.995448", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "overview_tab", "company_name", - "industry", - "market_segment", "customer_group", + "no_of_employees", + "annual_revenue", + "column_break_4", + "market_segment", + "industry", "territory", "column_break_6", - "no_of_employees", - "currency", - "annual_revenue", - "more_details_section", - "fax", - "website", - "column_break_13", "prospect_owner", + "website", + "fax", "company", - "leads_section", - "prospect_lead", "address_and_contact_section", + "column_break_16", + "contacts_tab", "address_html", - "column_break_17", + "column_break_18", "contact_html", + "leads_section", + "leads", + "opportunities_tab", + "opportunities", + "activities_tab", + "open_activities_html", + "all_activities_section", + "all_activities_html", "notes_section", + "notes_html", "notes" ], "fields": [ @@ -74,13 +83,6 @@ "fieldtype": "Int", "label": "No. of Employees" }, - { - "fieldname": "currency", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Currency", - "options": "Currency" - }, { "fieldname": "annual_revenue", "fieldtype": "Currency", @@ -108,23 +110,14 @@ }, { "fieldname": "leads_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Leads" }, - { - "fieldname": "prospect_lead", - "fieldtype": "Table", - "options": "Prospect Lead" - }, { "fieldname": "address_html", "fieldtype": "HTML", "label": "Address HTML" }, - { - "fieldname": "column_break_17", - "fieldtype": "Column Break" - }, { "fieldname": "contact_html", "fieldtype": "HTML", @@ -132,28 +125,16 @@ }, { "collapsible": 1, + "depends_on": "eval:!doc.__islocal", "fieldname": "notes_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Notes" }, - { - "fieldname": "notes", - "fieldtype": "Text Editor" - }, - { - "fieldname": "more_details_section", - "fieldtype": "Section Break", - "label": "More Details" - }, - { - "fieldname": "column_break_13", - "fieldtype": "Column Break" - }, { "depends_on": "eval: !doc.__islocal", "fieldname": "address_and_contact_section", "fieldtype": "Section Break", - "label": "Address and Contact" + "label": "Address" }, { "fieldname": "company", @@ -161,11 +142,83 @@ "label": "Company", "options": "Company", "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "opportunities_tab", + "fieldtype": "Tab Break", + "label": "Opportunities" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "activities_tab", + "fieldtype": "Tab Break", + "label": "Activities" + }, + { + "fieldname": "notes_html", + "fieldtype": "HTML", + "label": "Notes HTML" + }, + { + "fieldname": "opportunities", + "fieldtype": "Table", + "label": "Opportunities", + "options": "Prospect Opportunity" + }, + { + "fieldname": "contacts_tab", + "fieldtype": "Tab Break", + "label": "Address & Contact" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "leads", + "fieldtype": "Table", + "options": "Prospect Lead" + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "overview_tab", + "fieldtype": "Tab Break", + "label": "Overview" + }, + { + "fieldname": "open_activities_html", + "fieldtype": "HTML", + "label": "Open Activities HTML" + }, + { + "fieldname": "all_activities_section", + "fieldtype": "Section Break", + "label": "All Activities" + }, + { + "fieldname": "all_activities_html", + "fieldtype": "HTML", + "label": "All Activities HTML" + }, + { + "fieldname": "notes", + "fieldtype": "Table", + "hidden": 1, + "label": "Notes", + "no_copy": 1, + "options": "CRM Note" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-11-01 13:10:36.759249", + "modified": "2022-06-09 16:58:45.100244", "modified_by": "Administrator", "module": "CRM", "name": "Prospect", @@ -207,6 +260,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "company_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/prospect/prospect.py b/erpnext/crm/doctype/prospect/prospect.py index 39436f5918..fbb115883f 100644 --- a/erpnext/crm/doctype/prospect/prospect.py +++ b/erpnext/crm/doctype/prospect/prospect.py @@ -3,19 +3,15 @@ import frappe from frappe.contacts.address_and_contact import load_address_and_contact -from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from erpnext.crm.utils import add_link_in_communication, copy_comments +from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events -class Prospect(Document): +class Prospect(CRMNote): def onload(self): load_address_and_contact(self) - def validate(self): - self.update_lead_details() - def on_update(self): self.link_with_lead_contact_and_address() @@ -23,23 +19,24 @@ class Prospect(Document): self.unlink_dynamic_links() def after_insert(self): - if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"): - for row in self.get("prospect_lead"): - copy_comments("Lead", row.lead, self) - add_link_in_communication("Lead", row.lead, self) + carry_forward_communication_and_comments = frappe.db.get_single_value( + "CRM Settings", "carry_forward_communication_and_comments" + ) - def update_lead_details(self): - for row in self.get("prospect_lead"): - lead = frappe.get_value( - "Lead", row.lead, ["lead_name", "status", "email_id", "mobile_no"], as_dict=True - ) - row.lead_name = lead.lead_name - row.status = lead.status - row.email = lead.email_id - row.mobile_no = lead.mobile_no + for row in self.get("leads"): + if carry_forward_communication_and_comments: + copy_comments("Lead", row.lead, self) + link_communications("Lead", row.lead, self) + link_open_events("Lead", row.lead, self) + + for row in self.get("opportunities"): + if carry_forward_communication_and_comments: + copy_comments("Opportunity", row.opportunity, self) + link_communications("Opportunity", row.opportunity, self) + link_open_events("Opportunity", row.opportunity, self) def link_with_lead_contact_and_address(self): - for row in self.prospect_lead: + for row in self.leads: links = frappe.get_all( "Dynamic Link", filters={"link_doctype": "Lead", "link_name": row.lead}, @@ -116,9 +113,7 @@ def make_opportunity(source_name, target_doc=None): { "Prospect": { "doctype": "Opportunity", - "field_map": { - "name": "party_name", - }, + "field_map": {"name": "party_name", "prospect_owner": "opportunity_owner"}, } }, target_doc, @@ -127,3 +122,25 @@ def make_opportunity(source_name, target_doc=None): ) return doclist + + +@frappe.whitelist() +def get_opportunities(prospect): + return frappe.get_all( + "Opportunity", + filters={"opportunity_from": "Prospect", "party_name": prospect}, + fields=[ + "opportunity_owner", + "sales_stage", + "status", + "expected_closing", + "probability", + "opportunity_amount", + "currency", + "contact_person", + "contact_email", + "contact_mobile", + "creation", + "name", + ], + ) diff --git a/erpnext/crm/doctype/prospect/test_prospect.py b/erpnext/crm/doctype/prospect/test_prospect.py index ddd7b932aa..874f84ca84 100644 --- a/erpnext/crm/doctype/prospect/test_prospect.py +++ b/erpnext/crm/doctype/prospect/test_prospect.py @@ -20,7 +20,7 @@ class TestProspect(unittest.TestCase): add_lead_to_prospect(lead_doc.name, prospect_doc.name) prospect_doc.reload() lead_exists_in_prosoect = False - for rec in prospect_doc.get("prospect_lead"): + for rec in prospect_doc.get("leads"): if rec.lead == lead_doc.name: lead_exists_in_prosoect = True self.assertEqual(lead_exists_in_prosoect, True) diff --git a/erpnext/crm/doctype/prospect_lead/prospect_lead.json b/erpnext/crm/doctype/prospect_lead/prospect_lead.json index 3c160d9e80..075c0f9be5 100644 --- a/erpnext/crm/doctype/prospect_lead/prospect_lead.json +++ b/erpnext/crm/doctype/prospect_lead/prospect_lead.json @@ -7,12 +7,15 @@ "field_order": [ "lead", "lead_name", - "status", "email", - "mobile_no" + "column_break_4", + "mobile_no", + "lead_owner", + "status" ], "fields": [ { + "columns": 2, "fieldname": "lead", "fieldtype": "Link", "in_list_view": 1, @@ -21,6 +24,8 @@ "reqd": 1 }, { + "columns": 2, + "fetch_from": "lead.lead_name", "fieldname": "lead_name", "fieldtype": "Data", "in_list_view": 1, @@ -28,14 +33,17 @@ "read_only": 1 }, { + "columns": 1, + "fetch_from": "lead.status", "fieldname": "status", - "fieldtype": "Select", + "fieldtype": "Data", "in_list_view": 1, "label": "Status", - "options": "Lead\nOpen\nReplied\nOpportunity\nQuotation\nLost Quotation\nInterested\nConverted\nDo Not Contact", "read_only": 1 }, { + "columns": 2, + "fetch_from": "lead.email_id", "fieldname": "email", "fieldtype": "Data", "in_list_view": 1, @@ -44,18 +52,32 @@ "read_only": 1 }, { + "columns": 2, + "fetch_from": "lead.mobile_no", "fieldname": "mobile_no", "fieldtype": "Data", "in_list_view": 1, "label": "Mobile No", "options": "Phone", "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "fetch_from": "lead.lead_owner", + "fieldname": "lead_owner", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Lead Owner" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-08-25 12:58:24.638054", + "modified": "2022-04-28 20:27:58.805970", "modified_by": "Administrator", "module": "CRM", "name": "Prospect Lead", @@ -63,5 +85,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/prospect_opportunity/__init__.py b/erpnext/crm/doctype/prospect_opportunity/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json new file mode 100644 index 0000000000..d8c2520176 --- /dev/null +++ b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2022-04-27 17:40:37.965161", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "opportunity", + "amount", + "stage", + "deal_owner", + "column_break_4", + "probability", + "expected_closing", + "currency", + "contact_person" + ], + "fields": [ + { + "columns": 2, + "fieldname": "opportunity", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Opportunity", + "options": "Opportunity" + }, + { + "columns": 2, + "fetch_from": "opportunity.opportunity_amount", + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "currency" + }, + { + "columns": 2, + "fetch_from": "opportunity.sales_stage", + "fieldname": "stage", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Stage" + }, + { + "columns": 1, + "fetch_from": "opportunity.probability", + "fieldname": "probability", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "Probability" + }, + { + "columns": 1, + "fetch_from": "opportunity.expected_closing", + "fieldname": "expected_closing", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Closing" + }, + { + "fetch_from": "opportunity.currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "columns": 2, + "fetch_from": "opportunity.opportunity_owner", + "fieldname": "deal_owner", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Deal Owner" + }, + { + "fetch_from": "opportunity.contact_person", + "fieldname": "contact_person", + "fieldtype": "Link", + "label": "Contact Person", + "options": "Contact" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-04-28 10:05:38.730368", + "modified_by": "Administrator", + "module": "CRM", + "name": "Prospect Opportunity", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.py new file mode 100644 index 0000000000..8f5d19aaf2 --- /dev/null +++ b/erpnext/crm/doctype/prospect_opportunity/prospect_opportunity.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 ProspectOpportunity(Document): + pass diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.js b/erpnext/crm/report/lost_opportunity/lost_opportunity.js index 97c56f8c43..927c54df07 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.js +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.js @@ -57,11 +57,5 @@ frappe.query_reports["Lost Opportunity"] = { "fieldtype": "Dynamic Link", "options": "opportunity_from" }, - { - "fieldname":"contact_by", - "label": __("Next Contact By"), - "fieldtype": "Link", - "options": "User" - }, ] }; diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.json b/erpnext/crm/report/lost_opportunity/lost_opportunity.json index e7a8e12ba7..f6f36bd2b5 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.json +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.json @@ -7,8 +7,8 @@ "doctype": "Report", "idx": 0, "is_standard": "Yes", - "json": "{\"order_by\": \"`tabOpportunity`.`modified` desc\", \"filters\": [[\"Opportunity\", \"status\", \"=\", \"Lost\"]], \"fields\": [[\"name\", \"Opportunity\"], [\"opportunity_from\", \"Opportunity\"], [\"party_name\", \"Opportunity\"], [\"customer_name\", \"Opportunity\"], [\"opportunity_type\", \"Opportunity\"], [\"status\", \"Opportunity\"], [\"contact_by\", \"Opportunity\"], [\"docstatus\", \"Opportunity\"], [\"lost_reason\", \"Lost Reason Detail\"]], \"add_totals_row\": 0, \"add_total_row\": 0, \"page_length\": 20}", - "modified": "2020-07-29 15:49:02.848845", + "json": "{\"order_by\": \"`tabOpportunity`.`modified` desc\", \"filters\": [[\"Opportunity\", \"status\", \"=\", \"Lost\"]], \"fields\": [[\"name\", \"Opportunity\"], [\"opportunity_from\", \"Opportunity\"], [\"party_name\", \"Opportunity\"], [\"customer_name\", \"Opportunity\"], [\"opportunity_type\", \"Opportunity\"], [\"status\", \"Opportunity\"], [\"docstatus\", \"Opportunity\"], [\"lost_reason\", \"Lost Reason Detail\"]], \"add_totals_row\": 0, \"add_total_row\": 0, \"page_length\": 20}", + "modified": "2022-06-04 15:49:02.848845", "modified_by": "Administrator", "module": "CRM", "name": "Lost Opportunity", diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.py b/erpnext/crm/report/lost_opportunity/lost_opportunity.py index a57b44be47..254511c92f 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.py +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.py @@ -61,13 +61,6 @@ def get_columns(): "options": "Territory", "width": 150, }, - { - "label": _("Next Contact By"), - "fieldname": "contact_by", - "fieldtype": "Link", - "options": "User", - "width": 150, - }, ] return columns @@ -81,7 +74,6 @@ def get_data(filters): `tabOpportunity`.party_name, `tabOpportunity`.customer_name, `tabOpportunity`.opportunity_type, - `tabOpportunity`.contact_by, GROUP_CONCAT(`tabOpportunity Lost Reason Detail`.lost_reason separator ', ') lost_reason, `tabOpportunity`.sales_stage, `tabOpportunity`.territory @@ -115,9 +107,6 @@ def get_conditions(filters): if filters.get("party_name"): conditions.append(" and `tabOpportunity`.party_name=%(party_name)s") - if filters.get("contact_by"): - conditions.append(" and `tabOpportunity`.contact_by=%(contact_by)s") - return " ".join(conditions) if conditions else "" diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 5783b2c661..33441b166d 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -1,4 +1,6 @@ import frappe +from frappe.model.document import Document +from frappe.utils import cstr, now def update_lead_phone_numbers(contact, method): @@ -41,7 +43,7 @@ def copy_comments(doctype, docname, doc): comment.insert() -def add_link_in_communication(doctype, docname, doc): +def link_communications(doctype, docname, doc): communication_list = get_linked_communication_list(doctype, docname) for communication in communication_list: @@ -60,3 +62,138 @@ def get_linked_communication_list(doctype, docname): ) return communications + communication_links + + +def link_communications_with_prospect(communication, method): + prospect = get_linked_prospect(communication.reference_doctype, communication.reference_name) + + if prospect: + already_linked = any( + [ + d.name + for d in communication.get("timeline_links") + if d.link_doctype == "Prospect" and d.link_name == prospect + ] + ) + if not already_linked: + row = communication.append("timeline_links") + row.link_doctype = "Prospect" + row.link_name = prospect + row.db_update() + + +def get_linked_prospect(reference_doctype, reference_name): + prospect = None + if reference_doctype == "Lead": + prospect = frappe.db.get_value("Prospect Lead", {"lead": reference_name}, "parent") + + elif reference_doctype == "Opportunity": + opportunity_from, party_name = frappe.db.get_value( + "Opportunity", reference_name, ["opportunity_from", "party_name"] + ) + if opportunity_from == "Lead": + prospect = frappe.db.get_value( + "Prospect Opportunity", {"opportunity": reference_name}, "parent" + ) + if opportunity_from == "Prospect": + prospect = party_name + + return prospect + + +def link_events_with_prospect(event, method): + if event.event_participants: + ref_doctype = event.event_participants[0].reference_doctype + ref_docname = event.event_participants[0].reference_docname + prospect = get_linked_prospect(ref_doctype, ref_docname) + if prospect: + event.add_participant("Prospect", prospect) + event.save() + + +def link_open_tasks(ref_doctype, ref_docname, doc): + todos = get_open_todos(ref_doctype, ref_docname) + + for todo in todos: + todo_doc = frappe.get_doc("ToDo", todo.name) + todo_doc.reference_type = doc.doctype + todo_doc.reference_name = doc.name + todo_doc.db_update() + + +def link_open_events(ref_doctype, ref_docname, doc): + events = get_open_events(ref_doctype, ref_docname) + for event in events: + event_doc = frappe.get_doc("Event", event.name) + event_doc.add_participant(doc.doctype, doc.name) + event_doc.save() + + +@frappe.whitelist() +def get_open_activities(ref_doctype, ref_docname): + tasks = get_open_todos(ref_doctype, ref_docname) + events = get_open_events(ref_doctype, ref_docname) + + return {"tasks": tasks, "events": events} + + +def get_open_todos(ref_doctype, ref_docname): + return frappe.get_all( + "ToDo", + filters={"reference_type": ref_doctype, "reference_name": ref_docname, "status": "Open"}, + fields=[ + "name", + "description", + "allocated_to", + "date", + ], + ) + + +def get_open_events(ref_doctype, ref_docname): + event = frappe.qb.DocType("Event") + event_link = frappe.qb.DocType("Event Participants") + + query = ( + frappe.qb.from_(event) + .join(event_link) + .on(event_link.parent == event.name) + .select( + event.name, + event.subject, + event.event_category, + event.starts_on, + event.ends_on, + event.description, + ) + .where( + (event_link.reference_doctype == ref_doctype) + & (event_link.reference_docname == ref_docname) + & (event.status == "Open") + ) + ) + data = query.run(as_dict=True) + + return data + + +class CRMNote(Document): + @frappe.whitelist() + def add_note(self, note): + self.append("notes", {"note": note, "added_by": frappe.session.user, "added_on": now()}) + self.save() + + @frappe.whitelist() + def edit_note(self, note, row_id): + for d in self.notes: + if cstr(d.name) == row_id: + d.note = note + d.db_update() + + @frappe.whitelist() + def delete_note(self, row_id): + for d in self.notes: + if cstr(d.name) == row_id: + self.remove(d) + break + self.save() diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 7d7f65dfd7..b3c35cfe0b 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -299,7 +299,11 @@ doc_events = { "on_update": [ "erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update", "erpnext.support.doctype.issue.issue.set_first_response_time", - ] + ], + "after_insert": "erpnext.crm.utils.link_communications_with_prospect", + }, + "Event": { + "after_insert": "erpnext.crm.utils.link_events_with_prospect", }, "Sales Taxes and Charges Template": { "on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings" @@ -453,7 +457,6 @@ scheduler_events = { "erpnext.hr.utils.allocate_earned_leaves", "erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", - "erpnext.crm.doctype.lead.lead.daily_open_lead", ], "weekly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly"], "monthly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly"], diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 318875d2a4..2addf91976 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -375,3 +375,4 @@ execute:frappe.delete_doc("DocType", "Naming Series") erpnext.patches.v13_0.set_payroll_entry_status erpnext.patches.v13_0.job_card_status_on_hold erpnext.patches.v14_0.migrate_gl_to_payment_ledger +erpnext.patches.v14_0.crm_ux_cleanup diff --git a/erpnext/patches/v14_0/crm_ux_cleanup.py b/erpnext/patches/v14_0/crm_ux_cleanup.py new file mode 100644 index 0000000000..923daee604 --- /dev/null +++ b/erpnext/patches/v14_0/crm_ux_cleanup.py @@ -0,0 +1,84 @@ +import frappe +from frappe.model.utils.rename_field import rename_field +from frappe.utils import add_months, cstr, today + + +def execute(): + for doctype in ("Lead", "Opportunity", "Prospect", "Prospect Lead"): + frappe.reload_doc("crm", "doctype", doctype) + + try: + rename_field("Lead", "designation", "job_title") + rename_field("Opportunity", "converted_by", "opportunity_owner") + rename_field("Prospect", "prospect_lead", "leads") + except Exception as e: + if e.args[0] != 1054: + raise + + add_calendar_event_for_leads() + add_calendar_event_for_opportunities() + + +def add_calendar_event_for_leads(): + # create events based on next contact date + leads = frappe.get_all( + "Lead", + {"contact_date": [">=", add_months(today(), -1)]}, + ["name", "contact_date", "contact_by", "ends_on", "lead_name", "lead_owner"], + ) + for d in leads: + event = frappe.get_doc( + { + "doctype": "Event", + "owner": d.lead_owner, + "subject": ("Contact " + cstr(d.lead_name)), + "description": ( + ("Contact " + cstr(d.lead_name)) + (("
By: " + cstr(d.contact_by)) if d.contact_by else "") + ), + "starts_on": d.contact_date, + "ends_on": d.ends_on, + "event_type": "Private", + } + ) + + event.append("event_participants", {"reference_doctype": "Lead", "reference_docname": d.name}) + + event.insert(ignore_permissions=True) + + +def add_calendar_event_for_opportunities(): + # create events based on next contact date + opportunities = frappe.get_all( + "Opportunity", + {"contact_date": [">=", add_months(today(), -1)]}, + [ + "name", + "contact_date", + "contact_by", + "to_discuss", + "party_name", + "opportunity_owner", + "contact_person", + ], + ) + for d in opportunities: + event = frappe.get_doc( + { + "doctype": "Event", + "owner": d.opportunity_owner, + "subject": ("Contact " + cstr(d.contact_person or d.party_name)), + "description": ( + ("Contact " + cstr(d.contact_person or d.party_name)) + + (("
By: " + cstr(d.contact_by)) if d.contact_by else "") + + (("
Agenda: " + cstr(d.to_discuss)) if d.to_discuss else "") + ), + "starts_on": d.contact_date, + "event_type": "Private", + } + ) + + event.append( + "event_participants", {"reference_doctype": "Opportunity", "reference_docname": d.name} + ) + + event.insert(ignore_permissions=True) diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 3dae6d407b..d545929ce5 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -22,5 +22,8 @@ import "./utils/barcode_scanner"; import "./telephony"; import "./templates/call_link.html"; import "./bulk_transaction_processing"; +import "./utils/crm_activities"; +import "./templates/crm_activities.html"; +import "./templates/crm_notes.html"; // import { sum } from 'frappe/public/utils/util.js' diff --git a/erpnext/public/js/templates/crm_activities.html b/erpnext/public/js/templates/crm_activities.html new file mode 100644 index 0000000000..e0c237cde9 --- /dev/null +++ b/erpnext/public/js/templates/crm_activities.html @@ -0,0 +1,172 @@ +
+
+ + + + +
+
+
+
+ {{ __("Open Tasks") }} +
+ {% if (tasks.length) { %} + {% for(var i=0, l=tasks.length; i +
+ +
+ +
+
+
{%= frappe.datetime.global_date_format(tasks[i].date) %}
+ {% if(tasks[i].allocated_to) { %} +
+ {{ __("Allocated To:") }} + {%= tasks[i].allocated_to %} +
+ {% } %} +
+ {% } %} + {% } else { %} +
+ {{ __("No open task") }} +
+ {% } %} +
+
+
+ {{ __("Open Events") }} +
+ {% if (events.length) { %} + {% let icon_set = {"Sent/Received Email": "mail", "Call": "call", "Meeting": "share-people"}; %} + {% for(var i=0, l=events.length; i +
+ +
+ +
+
+
+ {%= frappe.datetime.global_date_format(events[i].starts_on) %} + + {% if (events[i].ends_on) { %} + {% if (frappe.datetime.obj_to_user(events[i].starts_on) != frappe.datetime.obj_to_user(events[i].ends_on)) %} + - + {%= frappe.datetime.global_date_format(frappe.datetime.obj_to_user(events[i].ends_on)) %} + {%= frappe.datetime.get_time(events[i].ends_on) %} + {% } else if (events[i].ends_on) { %} + - + {%= frappe.datetime.get_time(events[i].ends_on) %} + {% } %} + {% } %} + +
+
+ {% } %} + {% } else { %} +
+ {{ __("No open event") }} +
+ {% } %} +
+ + + + + \ No newline at end of file diff --git a/erpnext/public/js/templates/crm_notes.html b/erpnext/public/js/templates/crm_notes.html new file mode 100644 index 0000000000..fddeb1c1cc --- /dev/null +++ b/erpnext/public/js/templates/crm_notes.html @@ -0,0 +1,74 @@ +
+
+ +
+
+ {% if (notes.length) { %} + {% for(var i=0, l=notes.length; i +
+
+
+ {{ frappe.avatar(notes[i].added_by) }} +
+
+
+ {{ strip_html(notes[i].added_by) }} +
+
+ {{ frappe.datetime.global_date_format(notes[i].added_on) }} +
+
+
+
+
+ {{ notes[i].note }} +
+
+ + + + + + +
+
+ {% } %} + {% } else { %} +
+ {{ __("No Notes") }} +
+ {% } %} +
+ + + \ No newline at end of file diff --git a/erpnext/public/js/utils/crm_activities.js b/erpnext/public/js/utils/crm_activities.js new file mode 100644 index 0000000000..bbd9ded8c9 --- /dev/null +++ b/erpnext/public/js/utils/crm_activities.js @@ -0,0 +1,234 @@ +erpnext.utils.CRMActivities = class CRMActivities { + constructor(opts) { + $.extend(this, opts); + } + + refresh() { + var me = this; + $(this.open_activities_wrapper).empty(); + let cur_form_footer = this.form_wrapper.find('.form-footer'); + + // all activities + if (!$(this.all_activities_wrapper).find('.form-footer').length) { + this.all_activities_wrapper.empty(); + $(cur_form_footer).appendTo(this.all_activities_wrapper); + + // remove frappe-control class to avoid absolute position for action-btn + $(this.all_activities_wrapper).removeClass('frappe-control'); + // hide new event button + $('.timeline-actions').find('.btn-default').hide(); + // hide new comment box + $(".comment-box").hide(); + // show only communications by default + $($('.timeline-content').find('.nav-link')[0]).tab('show'); + } + + // open activities + frappe.call({ + method: "erpnext.crm.utils.get_open_activities", + args: { + ref_doctype: this.frm.doc.doctype, + ref_docname: this.frm.doc.name + }, + callback: (r) => { + if (!r.exc) { + var activities_html = frappe.render_template('crm_activities', { + tasks: r.message.tasks, + events: r.message.events + }); + + $(activities_html).appendTo(me.open_activities_wrapper); + + $(".open-tasks").find(".completion-checkbox").on("click", function() { + me.update_status(this, "ToDo"); + }); + + $(".open-events").find(".completion-checkbox").on("click", function() { + me.update_status(this, "Event"); + }); + + me.create_task(); + me.create_event(); + } + } + }); + } + + create_task () { + let me = this; + let _create_task = () => { + const args = { + doc: me.frm.doc, + frm: me.frm, + title: __("New Task") + }; + let composer = new frappe.views.InteractionComposer(args); + composer.dialog.get_field('interaction_type').set_value("ToDo"); + // hide column having interaction type field + $(composer.dialog.get_field('interaction_type').wrapper).closest('.form-column').hide(); + // hide summary field + $(composer.dialog.get_field('summary').wrapper).closest('.form-section').hide(); + }; + $(".new-task-btn").click(_create_task); + } + + create_event () { + let me = this; + let _create_event = () => { + const args = { + doc: me.frm.doc, + frm: me.frm, + title: __("New Event") + }; + let composer = new frappe.views.InteractionComposer(args); + composer.dialog.get_field('interaction_type').set_value("Event"); + $(composer.dialog.get_field('interaction_type').wrapper).hide(); + }; + $(".new-event-btn").click(_create_event); + } + + async update_status (input_field, doctype) { + let completed = $(input_field).prop("checked") ? 1 : 0; + let docname = $(input_field).attr("name"); + if (completed) { + await frappe.db.set_value(doctype, docname, "status", "Closed"); + this.refresh(); + } + } +}; + +erpnext.utils.CRMNotes = class CRMNotes { + constructor(opts) { + $.extend(this, opts); + } + + refresh() { + var me = this; + this.notes_wrapper.find('.notes-section').remove(); + + let notes = this.frm.doc.notes || []; + notes.sort( + function(a, b) { + return new Date(b.added_on) - new Date(a.added_on); + } + ); + + let notes_html = frappe.render_template( + 'crm_notes', + { + notes: notes + } + ); + $(notes_html).appendTo(this.notes_wrapper); + + this.add_note(); + + $(".notes-section").find(".edit-note-btn").on("click", function() { + me.edit_note(this); + }); + + $(".notes-section").find(".delete-note-btn").on("click", function() { + me.delete_note(this); + }); + } + + + add_note () { + let me = this; + let _add_note = () => { + var d = new frappe.ui.Dialog({ + title: __('Add a Note'), + fields: [ + { + "label": "Note", + "fieldname": "note", + "fieldtype": "Text Editor", + "reqd": 1 + } + ], + primary_action: function() { + var data = d.get_values(); + frappe.call({ + method: "add_note", + doc: me.frm.doc, + args: { + note: data.note + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + me.frm.refresh_field("notes"); + me.refresh(); + } + d.hide(); + } + }); + }, + primary_action_label: __('Add') + }); + d.show(); + }; + $(".new-note-btn").click(_add_note); + } + + edit_note (edit_btn) { + var me = this; + let row = $(edit_btn).closest('.comment-content'); + let row_id = row.attr("name"); + let row_content = $(row).find(".content").html(); + if (row_content) { + var d = new frappe.ui.Dialog({ + title: __('Edit Note'), + fields: [ + { + "label": "Note", + "fieldname": "note", + "fieldtype": "Text Editor", + "default": row_content + } + ], + primary_action: function() { + var data = d.get_values(); + frappe.call({ + method: "edit_note", + doc: me.frm.doc, + args: { + note: data.note, + row_id: row_id + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + me.frm.refresh_field("notes"); + me.refresh(); + d.hide(); + } + + } + }); + }, + primary_action_label: __('Done') + }); + d.show(); + } + } + + delete_note (delete_btn) { + var me = this; + let row_id = $(delete_btn).closest('.comment-content').attr("name"); + frappe.call({ + method: "delete_note", + doc: me.frm.doc, + args: { + row_id: row_id + }, + freeze: true, + callback: function(r) { + if (!r.exc) { + me.frm.refresh_field("notes"); + me.refresh(); + } + } + }); + } +}; diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 4fa4515a0f..a513b8fc88 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -8,7 +8,6 @@ from frappe.model.mapper import get_mapped_doc from frappe.utils import flt, getdate, nowdate from erpnext.controllers.selling_controller import SellingController -from erpnext.crm.utils import add_link_in_communication, copy_comments form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -36,16 +35,6 @@ class Quotation(SellingController): make_packing_list(self) - def after_insert(self): - if frappe.db.get_single_value("CRM Settings", "carry_forward_communication_and_comments"): - if self.opportunity: - copy_comments("Opportunity", self.opportunity, self) - add_link_in_communication("Opportunity", self.opportunity, self) - - elif self.quotation_to == "Lead" and self.party_name: - copy_comments("Lead", self.party_name, self) - add_link_in_communication("Lead", self.party_name, self) - def validate_valid_till(self): if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date): frappe.throw(_("Valid till date cannot be before transaction date")) diff --git a/erpnext/templates/utils.py b/erpnext/templates/utils.py index 4295188dc0..48b44802a8 100644 --- a/erpnext/templates/utils.py +++ b/erpnext/templates/utils.py @@ -34,7 +34,6 @@ def send_message(subject="Website Query", message="", sender="", status="Open"): status="Open", title=subject, contact_email=sender, - to_discuss=message, ) ) diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 73cbcd4094..cd1bf9f321 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -5,7 +5,7 @@ import frappe import frappe.share from frappe import _ -from frappe.utils import cint, cstr, flt, get_time, now_datetime +from frappe.utils import cint, flt, get_time, now_datetime from erpnext.controllers.status_updater import StatusUpdater @@ -30,64 +30,6 @@ class TransactionBase(StatusUpdater): except ValueError: frappe.throw(_("Invalid Posting Time")) - def add_calendar_event(self, opts, force=False): - if ( - cstr(self.contact_by) != cstr(self._prev.contact_by) - or cstr(self.contact_date) != cstr(self._prev.contact_date) - or force - or (hasattr(self, "ends_on") and cstr(self.ends_on) != cstr(self._prev.ends_on)) - ): - - self.delete_events() - self._add_calendar_event(opts) - - def delete_events(self): - participations = frappe.get_all( - "Event Participants", - filters={ - "reference_doctype": self.doctype, - "reference_docname": self.name, - "parenttype": "Event", - }, - fields=["name", "parent"], - ) - - if participations: - for participation in participations: - total_participants = frappe.get_all( - "Event Participants", filters={"parenttype": "Event", "parent": participation.parent} - ) - - if len(total_participants) <= 1: - frappe.db.sql("delete from `tabEvent` where name='%s'" % participation.parent) - - frappe.db.sql("delete from `tabEvent Participants` where name='%s'" % participation.name) - - def _add_calendar_event(self, opts): - opts = frappe._dict(opts) - - if self.contact_date: - event = frappe.get_doc( - { - "doctype": "Event", - "owner": opts.owner or self.owner, - "subject": opts.subject, - "description": opts.description, - "starts_on": self.contact_date, - "ends_on": opts.ends_on, - "event_type": "Private", - } - ) - - event.append( - "event_participants", {"reference_doctype": self.doctype, "reference_docname": self.name} - ) - - event.insert(ignore_permissions=True) - - if frappe.db.exists("User", self.contact_by): - frappe.share.add("Event", event.name, self.contact_by, flags={"ignore_share_permission": True}) - def validate_uom_is_integer(self, uom_field, qty_fields): validate_uom_is_integer(self, uom_field, qty_fields) From d8163f3e47707336a79fee81f6aa788c62c146f2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 10 Jun 2022 15:31:31 +0530 Subject: [PATCH 123/133] fix: set exchange rate --- erpnext/crm/doctype/lead/lead.js | 2 +- erpnext/crm/doctype/opportunity/opportunity.py | 14 ++++++++++++-- erpnext/patches/v14_0/crm_ux_cleanup.py | 9 ++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js index 4814311558..37fb3509ce 100644 --- a/erpnext/crm/doctype/lead/lead.js +++ b/erpnext/crm/doctype/lead/lead.js @@ -232,4 +232,4 @@ frappe.ui.form.on("Lead", { }); } } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 0dc0cd3762..08eb472bb9 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -47,6 +47,7 @@ class Opportunity(TransactionBase, CRMNote): self.validate_uom_is_integer("uom", "qty") self.validate_cust_name() self.map_fields() + self.set_exchange_rate() if not self.title: self.title = self.customer_name @@ -63,12 +64,21 @@ class Opportunity(TransactionBase, CRMNote): except Exception: continue + def set_exchange_rate(self): + company_currency = frappe.get_cached_value("Company", self.company, "default_currency") + if self.currency == company_currency: + self.conversion_rate = 1.0 + return + + if not self.conversion_rate or self.conversion_rate == 1.0: + self.conversion_rate = get_exchange_rate(self.currency, company_currency, self.transaction_date) + def calculate_totals(self): total = base_total = 0 for item in self.get("items"): item.amount = flt(item.rate) * flt(item.qty) - item.base_rate = flt(self.conversion_rate * item.rate) - item.base_amount = flt(self.conversion_rate * item.amount) + item.base_rate = flt(self.conversion_rate) * flt(item.rate) + item.base_amount = flt(self.conversion_rate) * flt(item.amount) total += item.amount base_total += item.base_amount diff --git a/erpnext/patches/v14_0/crm_ux_cleanup.py b/erpnext/patches/v14_0/crm_ux_cleanup.py index 923daee604..c1bc6b265d 100644 --- a/erpnext/patches/v14_0/crm_ux_cleanup.py +++ b/erpnext/patches/v14_0/crm_ux_cleanup.py @@ -10,7 +10,14 @@ def execute(): try: rename_field("Lead", "designation", "job_title") rename_field("Opportunity", "converted_by", "opportunity_owner") - rename_field("Prospect", "prospect_lead", "leads") + + frappe.db.sql( + """ + update `tabProspect Lead` + set parentfield='leads' + where parentfield='partner_lead' + """ + ) except Exception as e: if e.args[0] != 1054: raise From 82bf59e2a3a5bd30e35deb70bde0143152f299dd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 15 Jun 2022 13:07:54 +0530 Subject: [PATCH 124/133] fix: test case --- .../crm/doctype/opportunity/opportunity.json | 8 ++++---- .../crm/doctype/opportunity/test_opportunity.py | 17 ----------------- .../crm/doctype/opportunity/test_records.json | 4 +++- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 7854007919..98c8d32307 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -16,23 +16,23 @@ "opportunity_from", "party_name", "customer_name", + "status", "column_break0", "opportunity_type", "source", "opportunity_owner", "column_break_10", - "status", "sales_stage", "expected_closing", "probability", "organization_details_section", "no_of_employees", "annual_revenue", - "column_break_23", "customer_group", + "column_break_23", "industry", - "column_break_31", "market_segment", + "column_break_31", "territory", "website", "section_break_14", @@ -607,7 +607,7 @@ "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-06-10 10:37:25.537444", + "modified": "2022-06-10 16:19:33.310006", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 4a18e940bc..3ca701670f 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -97,22 +97,6 @@ class TestOpportunity(unittest.TestCase): self.assertEqual(quotation_comment_count, 4) self.assertEqual(quotation_communication_count, 4) - def test_render_template_for_to_discuss(self): - doc = make_opportunity(with_items=0, opportunity_from="Lead") - doc.contact_by = "test@example.com" - doc.contact_date = add_days(today(), days=2) - doc.to_discuss = "{{ doc.name }} test data" - doc.save() - - event = frappe.get_all( - "Event Participants", - fields=["parent"], - filters={"reference_doctype": doc.doctype, "reference_docname": doc.name}, - ) - - event_description = frappe.db.get_value("Event", event[0].parent, "description") - self.assertTrue(doc.name in event_description) - def make_opportunity_from_lead(): new_lead_email_id = "new{}@example.com".format(random_string(5)) @@ -139,7 +123,6 @@ def make_opportunity(**args): "opportunity_from": args.opportunity_from or "Customer", "opportunity_type": "Sales", "conversion_rate": 1.0, - "with_items": args.with_items or 0, "transaction_date": today(), } ) diff --git a/erpnext/crm/doctype/opportunity/test_records.json b/erpnext/crm/doctype/opportunity/test_records.json index a1e0ad921b..f7e8350f30 100644 --- a/erpnext/crm/doctype/opportunity/test_records.json +++ b/erpnext/crm/doctype/opportunity/test_records.json @@ -8,7 +8,9 @@ "transaction_date": "2013-12-12", "items": [{ "item_name": "Test Item", - "description": "Some description" + "description": "Some description", + "qty": 5, + "rate": 100 }] } ] From 6a0d0a338db71dd2eb101d6349447d36663ebcdd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 15 Jun 2022 15:29:39 +0530 Subject: [PATCH 125/133] fix: Test cases removed related to copying comments from opportunity to quotation --- .../doctype/opportunity/test_opportunity.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 3ca701670f..1ff3267e71 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -77,26 +77,6 @@ class TestOpportunity(unittest.TestCase): create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email) create_communication(opp_doc.doctype, opp_doc.name, opp_doc.contact_email) - quotation_doc = make_quotation(opp_doc.name) - quotation_doc.append("items", {"item_code": "_Test Item", "qty": 1}) - quotation_doc.run_method("set_missing_values") - quotation_doc.run_method("calculate_taxes_and_totals") - quotation_doc.save() - - quotation_comment_count = frappe.db.count( - "Comment", - { - "reference_doctype": quotation_doc.doctype, - "reference_name": quotation_doc.name, - "comment_type": "Comment", - }, - ) - quotation_communication_count = len( - get_linked_communication_list(quotation_doc.doctype, quotation_doc.name) - ) - self.assertEqual(quotation_comment_count, 4) - self.assertEqual(quotation_communication_count, 4) - def make_opportunity_from_lead(): new_lead_email_id = "new{}@example.com".format(random_string(5)) From 483fc420a1c247ee0d073a458044d87a240ada3c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 15 Jun 2022 15:46:41 +0530 Subject: [PATCH 126/133] test: invoice from timesheet --- erpnext/projects/doctype/timesheet/test_timesheet.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 57bfd5b607..7298c037a7 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -84,7 +84,9 @@ class TestTimesheet(unittest.TestCase): emp = make_employee("test_employee_6@salary.com") timesheet = make_timesheet(emp, simulate=True, is_billable=1) - sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer") + sales_invoice = make_sales_invoice( + timesheet.name, "_Test Item", "_Test Customer", currency="INR" + ) sales_invoice.due_date = nowdate() sales_invoice.submit() timesheet = frappe.get_doc("Timesheet", timesheet.name) From 2d226be3c4b5970c5b1db0f857dfe3d2b5072eb5 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 22 Jun 2022 12:51:56 +0530 Subject: [PATCH 127/133] fix: Made no of employees a select field --- erpnext/crm/doctype/lead/lead.json | 10 +++++----- erpnext/crm/doctype/opportunity/opportunity.json | 7 ++++--- erpnext/crm/doctype/prospect/prospect.json | 10 +++++----- erpnext/public/js/templates/crm_activities.html | 6 +++++- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index df6ff56986..9216c458c8 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -277,8 +277,7 @@ "fieldtype": "Data", "label": "Website", "oldfieldname": "website", - "oldfieldtype": "Data", - "options": "URL" + "oldfieldtype": "Data" }, { "fieldname": "territory", @@ -334,8 +333,9 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Int", - "label": "No. of Employees" + "fieldtype": "Select", + "label": "No. of Employees", + "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" }, { "fieldname": "column_break_22", @@ -483,7 +483,7 @@ "idx": 5, "image_field": "image", "links": [], - "modified": "2022-06-06 18:10:14.494424", + "modified": "2022-06-21 15:10:06.613519", "modified_by": "Administrator", "module": "CRM", "name": "Lead", diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 98c8d32307..8ddd4e36c2 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -463,8 +463,9 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Int", - "label": "No of Employees" + "fieldtype": "Select", + "label": "No of Employees", + "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" }, { "fieldname": "annual_revenue", @@ -607,7 +608,7 @@ "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-06-10 16:19:33.310006", + "modified": "2022-06-21 15:04:34.363959", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/doctype/prospect/prospect.json b/erpnext/crm/doctype/prospect/prospect.json index 2750e5b421..afc6c1dbec 100644 --- a/erpnext/crm/doctype/prospect/prospect.json +++ b/erpnext/crm/doctype/prospect/prospect.json @@ -80,8 +80,9 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Int", - "label": "No. of Employees" + "fieldtype": "Select", + "label": "No. of Employees", + "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" }, { "fieldname": "annual_revenue", @@ -99,8 +100,7 @@ { "fieldname": "website", "fieldtype": "Data", - "label": "Website", - "options": "URL" + "label": "Website" }, { "fieldname": "prospect_owner", @@ -218,7 +218,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-06-09 16:58:45.100244", + "modified": "2022-06-21 15:10:26.887502", "modified_by": "Administrator", "module": "CRM", "name": "Prospect", diff --git a/erpnext/public/js/templates/crm_activities.html b/erpnext/public/js/templates/crm_activities.html index e0c237cde9..4260319608 100644 --- a/erpnext/public/js/templates/crm_activities.html +++ b/erpnext/public/js/templates/crm_activities.html @@ -39,7 +39,11 @@ name="{{tasks[i].name}}" title="{{ __('Mark As Closed') }}"> -
{%= frappe.datetime.global_date_format(tasks[i].date) %}
+ {% if(tasks[i].date) { %} +
+ {%= frappe.datetime.global_date_format(tasks[i].date) %} +
+ {% } %} {% if(tasks[i].allocated_to) { %}
{{ __("Allocated To:") }} From f72d5506de0b5ffc55922db0c95b52ee28cdc87e Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sat, 25 Jun 2022 15:08:13 +0530 Subject: [PATCH 128/133] fix: get_all replace by sql --- erpnext/patches/v14_0/crm_ux_cleanup.py | 37 +++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/erpnext/patches/v14_0/crm_ux_cleanup.py b/erpnext/patches/v14_0/crm_ux_cleanup.py index c1bc6b265d..b2df36ff35 100644 --- a/erpnext/patches/v14_0/crm_ux_cleanup.py +++ b/erpnext/patches/v14_0/crm_ux_cleanup.py @@ -4,7 +4,7 @@ from frappe.utils import add_months, cstr, today def execute(): - for doctype in ("Lead", "Opportunity", "Prospect", "Prospect Lead"): + for doctype in ("CRM Note", "Lead", "Opportunity", "Prospect", "Prospect Lead"): frappe.reload_doc("crm", "doctype", doctype) try: @@ -28,11 +28,16 @@ def execute(): def add_calendar_event_for_leads(): # create events based on next contact date - leads = frappe.get_all( - "Lead", - {"contact_date": [">=", add_months(today(), -1)]}, - ["name", "contact_date", "contact_by", "ends_on", "lead_name", "lead_owner"], + leads = frappe.db.sql( + """ + select name, contact_date, contact_by, ends_on, lead_name, lead_owner + from tabLead + where contact_date >= %s + """, + add_months(today(), -1), + as_dict=1, ) + for d in leads: event = frappe.get_doc( { @@ -55,19 +60,17 @@ def add_calendar_event_for_leads(): def add_calendar_event_for_opportunities(): # create events based on next contact date - opportunities = frappe.get_all( - "Opportunity", - {"contact_date": [">=", add_months(today(), -1)]}, - [ - "name", - "contact_date", - "contact_by", - "to_discuss", - "party_name", - "opportunity_owner", - "contact_person", - ], + opportunities = frappe.db.sql( + """ + select name, contact_date, contact_by, to_discuss, + party_name, opportunity_owner, contact_person + from tabOpportunity + where contact_date >= %s + """, + add_months(today(), -1), + as_dict=1, ) + for d in opportunities: event = frappe.get_doc( { From 20dac08f5f729081e8fae2e60b3b3b95bbfda4d9 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 27 Jun 2022 15:54:54 +0530 Subject: [PATCH 129/133] refactor: clean up product bundle client side code (#31455) refactor: clean up product bundle cient side code - Remove deprecated CUR_FRM scripts - Remove client side fetches and move it to doctype schema --- .../doctype/product_bundle/product_bundle.js | 28 ++++++++----------- .../product_bundle_item.json | 7 ++++- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.js b/erpnext/selling/doctype/product_bundle/product_bundle.js index 7a04c6ab06..3096b692a7 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.js +++ b/erpnext/selling/doctype/product_bundle/product_bundle.js @@ -1,19 +1,13 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.cscript.refresh = function(doc, cdt, cdn) { - cur_frm.toggle_enable('new_item_code', doc.__islocal); -} - -cur_frm.fields_dict.new_item_code.get_query = function() { - return{ - query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code" - } -} -cur_frm.fields_dict.new_item_code.query_description = __('Please select Item where "Is Stock Item" is "No" and "Is Sales Item" is "Yes" and there is no other Product Bundle'); - -cur_frm.cscript.onload = function() { - // set add fetch for item_code's item_name and description - cur_frm.add_fetch('item_code', 'stock_uom', 'uom'); - cur_frm.add_fetch('item_code', 'description', 'description'); -} +frappe.ui.form.on("Product Bundle", { + refresh: function (frm) { + frm.toggle_enable("new_item_code", frm.is_new()); + frm.set_query("new_item_code", () => { + return { + query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code", + }; + }); + }, +}); diff --git a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json index dc071e4d65..fc8caeb31d 100644 --- a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json +++ b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json @@ -33,6 +33,8 @@ "reqd": 1 }, { + "fetch_from": "item_code.description", + "fetch_if_empty": 1, "fieldname": "description", "fieldtype": "Text Editor", "in_list_view": 1, @@ -51,6 +53,8 @@ "print_hide": 1 }, { + "fetch_from": "item_code.stock_uom", + "fetch_if_empty": 1, "fieldname": "uom", "fieldtype": "Link", "in_list_view": 1, @@ -64,7 +68,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2020-02-28 14:06:05.725655", + "modified": "2022-06-27 05:30:18.475150", "modified_by": "Administrator", "module": "Selling", "name": "Product Bundle Item", @@ -72,5 +76,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file From dd11f26eba45937b809047404ad453b8df2670ac Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 27 Jun 2022 15:55:08 +0530 Subject: [PATCH 130/133] fix: dont update RM items table if not required (#31408) Currently on PO update RM item table is auto computed again and again, if there was any transfer/consumption against that then it will be lost. This change: 1. Disables updating RM table if no change in qty of FG was made. Since RM table can't possibly be different with same FG qty. 2. Blocks update completely if qty is changed and RM items are already transferred. --- .../purchase_order/test_purchase_order.py | 37 +++++++++++++++++ erpnext/controllers/accounts_controller.py | 40 +++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index d732b755fe..5f84de60d0 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -140,6 +140,43 @@ class TestPurchaseOrder(FrappeTestCase): # ordered qty decreases as ordered qty is 0 (deleted row) self.assertEqual(get_ordered_qty(), existing_ordered_qty - 10) # 0 + def test_supplied_items_validations_on_po_update_after_submit(self): + po = create_purchase_order(item_code="_Test FG Item", is_subcontracted=1, qty=5, rate=100) + item = po.items[0] + + original_supplied_items = {po.name: po.required_qty for po in po.supplied_items} + + # Just update rate + trans_item = [ + { + "item_code": "_Test FG Item", + "rate": 20, + "qty": 5, + "conversion_factor": 1.0, + "docname": item.name, + } + ] + update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name) + po.reload() + + new_supplied_items = {po.name: po.required_qty for po in po.supplied_items} + self.assertEqual(set(original_supplied_items.keys()), set(new_supplied_items.keys())) + + # Update qty to 2x + trans_item[0]["qty"] *= 2 + update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name) + po.reload() + + new_supplied_items = {po.name: po.required_qty for po in po.supplied_items} + self.assertEqual(2 * sum(original_supplied_items.values()), sum(new_supplied_items.values())) + + # Set transfer qty and attempt to update qty, shouldn't be allowed + po.supplied_items[0].supplied_qty = 2 + po.supplied_items[0].db_update() + trans_item[0]["qty"] *= 2 + with self.assertRaises(frappe.ValidationError): + update_child_qty_rate("Purchase Order", json.dumps(trans_item), po.name) + def test_update_child(self): mr = make_material_request(qty=10) po = make_purchase_order(mr.name) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index fc6fdcdeff..ceac815bf4 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2440,7 +2440,7 @@ def update_bin_on_delete(row, doctype): update_bin_qty(row.item_code, row.warehouse, qty_dict) -def validate_and_delete_children(parent, data): +def validate_and_delete_children(parent, data) -> bool: deleted_children = [] updated_item_names = [d.get("docname") for d in data] for item in parent.items: @@ -2459,6 +2459,8 @@ def validate_and_delete_children(parent, data): for d in deleted_children: update_bin_on_delete(d, parent.doctype) + return bool(deleted_children) + @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): @@ -2522,13 +2524,38 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ): frappe.throw(_("Cannot set quantity less than received quantity")) + def should_update_supplied_items(doc) -> bool: + """Subcontracted PO can allow following changes *after submit*: + + 1. Change rate of subcontracting - regardless of other changes. + 2. Change qty and/or add new items and/or remove items + Exception: Transfer/Consumption is already made, qty change not allowed. + """ + + supplied_items_processed = any( + item.supplied_qty or item.consumed_qty or item.returned_qty for item in doc.supplied_items + ) + + update_supplied_items = ( + any_qty_changed or items_added_or_removed or any_conversion_factor_changed + ) + if update_supplied_items and supplied_items_processed: + frappe.throw(_("Item qty can not be updated as raw materials are already processed.")) + + return update_supplied_items + data = json.loads(trans_items) + any_qty_changed = False # updated to true if any item's qty changes + items_added_or_removed = False # updated to true if any new item is added or removed + any_conversion_factor_changed = False + sales_doctypes = ["Sales Order", "Sales Invoice", "Delivery Note", "Quotation"] parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") - validate_and_delete_children(parent, data) + _removed_items = validate_and_delete_children(parent, data) + items_added_or_removed |= _removed_items for d in data: new_child_flag = False @@ -2539,6 +2566,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if not d.get("docname"): new_child_flag = True + items_added_or_removed = True check_doc_permissions(parent, "create") child_item = get_new_child_item(d) else: @@ -2561,6 +2589,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil qty_unchanged = prev_qty == new_qty uom_unchanged = prev_uom == new_uom conversion_factor_unchanged = prev_con_fac == new_con_fac + any_conversion_factor_changed |= not conversion_factor_unchanged date_unchanged = ( prev_date == getdate(new_date) if prev_date and new_date else False ) # in case of delivery note etc @@ -2574,6 +2603,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil continue validate_quantity(child_item, d) + if flt(child_item.get("qty")) != flt(d.get("qty")): + any_qty_changed = True child_item.qty = flt(d.get("qty")) rate_precision = child_item.precision("rate") or 2 @@ -2679,8 +2710,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.update_ordered_and_reserved_qty() parent.update_receiving_percentage() if parent.is_subcontracted: - parent.update_reserved_qty_for_subcontract() - parent.create_raw_materials_supplied("supplied_items") + if should_update_supplied_items(parent): + parent.update_reserved_qty_for_subcontract() + parent.create_raw_materials_supplied("supplied_items") parent.save() else: # Sales Order parent.validate_warehouse() From 7921a1a60594deebc15208de1cad01ff5409ff94 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 27 Jun 2022 21:57:03 +0530 Subject: [PATCH 131/133] fix: Restored city, state and country fields --- erpnext/crm/doctype/lead/lead.json | 31 ++++++++++++++++--- .../crm/doctype/opportunity/opportunity.json | 30 ++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index 9216c458c8..d47373fa61 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -46,6 +46,10 @@ "fax", "address_section", "address_html", + "column_break_38", + "city", + "state", + "country", "column_break2", "contact_html", "qualification_tab", @@ -333,9 +337,8 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Select", - "label": "No. of Employees", - "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" + "fieldtype": "Int", + "label": "No. of Employees" }, { "fieldname": "column_break_22", @@ -477,13 +480,33 @@ "fieldname": "disabled", "fieldtype": "Check", "label": "Disabled" + }, + { + "fieldname": "column_break_38", + "fieldtype": "Column Break" + }, + { + "fieldname": "city", + "fieldtype": "Data", + "label": "City" + }, + { + "fieldname": "state", + "fieldtype": "Data", + "label": "State" + }, + { + "fieldname": "country", + "fieldtype": "Link", + "label": "Country", + "options": "Country" } ], "icon": "fa fa-user", "idx": 5, "image_field": "image", "links": [], - "modified": "2022-06-21 15:10:06.613519", + "modified": "2022-06-27 21:56:17.392756", "modified_by": "Administrator", "module": "CRM", "name": "Lead", diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 8ddd4e36c2..1a6f23bc7b 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -32,9 +32,12 @@ "column_break_23", "industry", "market_segment", - "column_break_31", - "territory", "website", + "column_break_31", + "city", + "state", + "country", + "territory", "section_break_14", "currency", "column_break_36", @@ -463,9 +466,8 @@ }, { "fieldname": "no_of_employees", - "fieldtype": "Select", - "label": "No of Employees", - "options": "1-10\n11-20\n21-30\n31-100\n11-50\n51-200\n201-500\n101-500\n500-1000\n501-1000\n>1000\n1000+" + "fieldtype": "Int", + "label": "No of Employees" }, { "fieldname": "annual_revenue", @@ -603,12 +605,28 @@ "label": "Notes", "no_copy": 1, "options": "CRM Note" + }, + { + "fieldname": "city", + "fieldtype": "Data", + "label": "City" + }, + { + "fieldname": "state", + "fieldtype": "Data", + "label": "State" + }, + { + "fieldname": "country", + "fieldtype": "Link", + "label": "Country", + "options": "Country" } ], "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2022-06-21 15:04:34.363959", + "modified": "2022-06-27 18:44:32.858696", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", From 925b9d985e8f614d5a4de5f20218a7fca59f51ac Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 27 Jun 2022 21:58:19 +0530 Subject: [PATCH 132/133] fix: open lead and opportunities based on today's event --- erpnext/crm/utils.py | 24 +++++++++++++++++++++++- erpnext/hooks.py | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 33441b166d..a2528c3703 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -1,6 +1,7 @@ import frappe from frappe.model.document import Document -from frappe.utils import cstr, now +from frappe.utils import cstr, now, today +from pypika import functions def update_lead_phone_numbers(contact, method): @@ -177,6 +178,27 @@ def get_open_events(ref_doctype, ref_docname): return data +def open_leads_opportunities_based_on_todays_event(): + event = frappe.qb.DocType("Event") + event_link = frappe.qb.DocType("Event Participants") + + query = ( + frappe.qb.from_(event) + .join(event_link) + .on(event_link.parent == event.name) + .select(event_link.reference_doctype, event_link.reference_docname) + .where( + (event_link.reference_doctype.isin(["Lead", "Opportunity"])) + & (event.status == "Open") + & (functions.Date(event.starts_on) == today()) + ) + ) + data = query.run(as_dict=True) + + for d in data: + frappe.db.set_value(d.reference_doctype, d.reference_docname, "status", "Open") + + class CRMNote(Document): @frappe.whitelist() def add_note(self, note): diff --git a/erpnext/hooks.py b/erpnext/hooks.py index b3c35cfe0b..8abf65f4b5 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -457,6 +457,7 @@ scheduler_events = { "erpnext.hr.utils.allocate_earned_leaves", "erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", + "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", ], "weekly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly"], "monthly": ["erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly"], From bedb11ee670e3f19b3c6524ece78be5aba66d815 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 28 Jun 2022 10:50:44 +0530 Subject: [PATCH 133/133] fix: youtube stats background sync failures --- erpnext/utilities/doctype/video/video.py | 25 +++++++----------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/erpnext/utilities/doctype/video/video.py b/erpnext/utilities/doctype/video/video.py index a39d0a95eb..15dbccde89 100644 --- a/erpnext/utilities/doctype/video/video.py +++ b/erpnext/utilities/doctype/video/video.py @@ -9,6 +9,7 @@ import frappe import pytz from frappe import _ from frappe.model.document import Document +from frappe.utils import cint from pyyoutube import Api @@ -46,7 +47,7 @@ def is_tracking_enabled(): def get_frequency(value): # Return numeric value from frequency field, return 1 as fallback default value: 1 hour if value != "Daily": - return frappe.utils.cint(value[:2].strip()) + return cint(value[:2].strip()) elif value: return 24 return 1 @@ -120,24 +121,12 @@ def batch_update_youtube_data(): video_stats = entry.to_dict().get("statistics") video_id = entry.to_dict().get("id") stats = { - "like_count": video_stats.get("likeCount"), - "view_count": video_stats.get("viewCount"), - "dislike_count": video_stats.get("dislikeCount"), - "comment_count": video_stats.get("commentCount"), - "video_id": video_id, + "like_count": cint(video_stats.get("likeCount")), + "view_count": cint(video_stats.get("viewCount")), + "dislike_count": cint(video_stats.get("dislikeCount")), + "comment_count": cint(video_stats.get("commentCount")), } - - frappe.db.sql( - """ - UPDATE `tabVideo` - SET - like_count = %(like_count)s, - view_count = %(view_count)s, - dislike_count = %(dislike_count)s, - comment_count = %(comment_count)s - WHERE youtube_video_id = %(video_id)s""", - stats, - ) + frappe.db.set_value("Video", video_id, stats) video_list = frappe.get_all("Video", fields=["youtube_video_id"]) if len(video_list) > 50: