From da73685f7172290151a279f8cf796628dbf6617e Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 10 Feb 2022 13:07:51 +0530 Subject: [PATCH 01/87] fix: Multiple fixes in Gross Profit report --- .../report/gross_profit/gross_profit.js | 10 +++-- .../report/gross_profit/gross_profit.py | 43 +++++++++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js index 685f2d6176..c8a9a228c6 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.js +++ b/erpnext/accounts/report/gross_profit/gross_profit.js @@ -8,20 +8,22 @@ frappe.query_reports["Gross Profit"] = { "label": __("Company"), "fieldtype": "Link", "options": "Company", - "reqd": 1, - "default": frappe.defaults.get_user_default("Company") + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 }, { "fieldname":"from_date", "label": __("From Date"), "fieldtype": "Date", - "default": frappe.defaults.get_user_default("year_start_date") + "default": frappe.defaults.get_user_default("year_start_date"), + "reqd": 1 }, { "fieldname":"to_date", "label": __("To Date"), "fieldtype": "Date", - "default": frappe.defaults.get_user_default("year_end_date") + "default": frappe.defaults.get_user_default("year_end_date"), + "reqd": 1 }, { "fieldname":"sales_invoice", diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 84effc0f46..225b7c6426 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -369,20 +369,37 @@ class GrossProfitGenerator(object): return self.average_buying_rate[item_code] def get_last_purchase_rate(self, item_code, row): - condition = '' - if row.project: - condition += " AND a.project=%s" % (frappe.db.escape(row.project)) - elif row.cost_center: - condition += " AND a.cost_center=%s" % (frappe.db.escape(row.cost_center)) - if self.filters.to_date: - condition += " AND modified='%s'" % (self.filters.to_date) + purchase_invoice = frappe.qb.DocType("Purchase Invoice") + purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") - last_purchase_rate = frappe.db.sql(""" - select (a.base_rate / a.conversion_factor) - from `tabPurchase Invoice Item` a - where a.item_code = %s and a.docstatus=1 - {0} - order by a.modified desc limit 1""".format(condition), item_code) + query = (frappe.qb.from_(purchase_invoice_item) + .inner_join( + purchase_invoice + ).on( + purchase_invoice.name == purchase_invoice_item.parent + ).select( + purchase_invoice_item.base_rate / purchase_invoice_item.conversion_factor + ).where( + purchase_invoice.docstatus == 1 + ).where( + purchase_invoice.posting_date <= self.filters.to_date + ).where( + purchase_invoice_item.item_code == item_code + )) + + if row.project: + query.where( + purchase_invoice_item.item_code == row.project + ) + + if row.cost_center: + query.where( + purchase_invoice_item.cost_center == row.cost_center + ) + + query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc) + query.limit(1) + last_purchase_rate = query.run() return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0 From 2172ab2d37d8be0c43d1f885a40657d352d255b4 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 11 Feb 2022 14:48:39 +0530 Subject: [PATCH 02/87] fix: Update columns in new format --- .../report/gross_profit/gross_profit.json | 4 +- .../report/gross_profit/gross_profit.py | 80 ++++++------------- 2 files changed, 28 insertions(+), 56 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.json b/erpnext/accounts/report/gross_profit/gross_profit.json index 76c560ad24..0730ffd77e 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.json +++ b/erpnext/accounts/report/gross_profit/gross_profit.json @@ -1,5 +1,5 @@ { - "add_total_row": 0, + "add_total_row": 1, "columns": [], "creation": "2013-02-25 17:03:34", "disable_prepared_report": 0, @@ -9,7 +9,7 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "modified": "2021-11-13 19:14:23.730198", + "modified": "2022-02-11 10:18:36.956558", "modified_by": "Administrator", "module": "Accounts", "name": "Gross Profit", diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 225b7c6426..c403b76f87 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -70,43 +70,42 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ data.append(row) def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_columns, data): - for idx, src in enumerate(gross_profit_data.grouped_data): + for src in gross_profit_data.grouped_data: row = [] for col in group_wise_columns.get(scrub(filters.group_by)): row.append(src.get(col)) row.append(filters.currency) - if idx == len(gross_profit_data.grouped_data)-1: - row[0] = "Total" data.append(row) def get_columns(group_wise_columns, filters): columns = [] column_map = frappe._dict({ - "parent": _("Sales Invoice") + ":Link/Sales Invoice:120", - "invoice_or_item": _("Sales Invoice") + ":Link/Sales Invoice:120", - "posting_date": _("Posting Date") + ":Date:100", - "posting_time": _("Posting Time") + ":Data:100", - "item_code": _("Item Code") + ":Link/Item:100", - "item_name": _("Item Name") + ":Data:100", - "item_group": _("Item Group") + ":Link/Item Group:100", - "brand": _("Brand") + ":Link/Brand:100", - "description": _("Description") +":Data:100", - "warehouse": _("Warehouse") + ":Link/Warehouse:100", - "qty": _("Qty") + ":Float:80", - "base_rate": _("Avg. Selling Rate") + ":Currency/currency:100", - "buying_rate": _("Valuation Rate") + ":Currency/currency:100", - "base_amount": _("Selling Amount") + ":Currency/currency:100", - "buying_amount": _("Buying Amount") + ":Currency/currency:100", - "gross_profit": _("Gross Profit") + ":Currency/currency:100", - "gross_profit_percent": _("Gross Profit %") + ":Percent:100", - "project": _("Project") + ":Link/Project:100", - "sales_person": _("Sales person"), - "allocated_amount": _("Allocated Amount") + ":Currency/currency:100", - "customer": _("Customer") + ":Link/Customer:100", - "customer_group": _("Customer Group") + ":Link/Customer Group:100", - "territory": _("Territory") + ":Link/Territory:100" + "parent": {"label": _('Sales Invoice'), "fieldname": "parent_invoice", "fieldtype": "Link", "options": "Sales Invoice", "width": 120}, + "invoice_or_item": {"label": _('Sales Invoice'), "fieldtype": "Link", "options": "Sales Invoice", "width": 120}, + "posting_date": {"label": _('Posting Date'), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, + "posting_time": {"label": _('Posting Time'), "fieldname": "posting_time", "fieldtype": "Data", "width": 100}, + "item_code": {"label": _('Item Code'), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 100}, + "item_name": {"label": _('Item Name'), "fieldname": "item_name", "fieldtype": "Data", "width": 100}, + "item_group": {"label": _('Item Group'), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100}, + "brand": {"label": _('Brand'), "fieldtype": "Link", "options": "Brand", "width": 100}, + "description": {"label": _('Description'), "fieldname": "description", "fieldtype": "Data", "width": 100}, + "warehouse": {"label": _('Warehouse'), "fieldname": "warehouse", "fieldtype": "Link", "options": "warehouse", "width": 100}, + "qty": {"label": _('Qty'), "fieldname": "qty", "fieldtype": "Float", "width": 80}, + "base_rate": {"label": _('Avg. Selling Rate'), "fieldname": "avg._selling_rate", "fieldtype": "Currency", "options": "currency", "width": 100}, + "buying_rate": {"label": _('Valuation Rate'), "fieldname": "valuation_rate", "fieldtype": "Currency", "options": "currency", "width": 100}, + "base_amount": {"label": _('Selling Amount'), "fieldname": "selling_amount", "fieldtype": "Currency", "options": "currency", "width": 100}, + "buying_amount": {"label": _('Buying Amount'), "fieldname": "buying_amount", "fieldtype": "Currency", "options": "currency", "width": 100}, + "gross_profit": {"label": _('Gross Profit'), "fieldname": "gross_profit", "fieldtype": "Currency", "options": "currency", "width": 100}, + "gross_profit_percent": {"label": _('Gross Profit Percent'), "fieldname": "gross_profit_%", + "fieldtype": "Percent", "width": 100}, + "project": {"label": _('Project'), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 100}, + "sales_person": {"label": _('Sales Person'), "fieldname": "sales_person", "fieldtype": "Data","width": 100}, + "allocated_amount": {"label": _('Allocated Amount'), "fieldname": "allocated_amount", "fieldtype": "Currency", "options": "currency", "width": 100}, + "customer": {"label": _('Customer'), "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 100}, + "customer_group": {"label": _('Customer Group'), "fieldname": "customer_group", "fieldtype": "Link", "options": "customer", "width": 100}, + "territory": {"label": _('Territory'), "fieldname": "territory", "fieldtype": "Link", "options": "territory", "width": 100}, }) for col in group_wise_columns.get(scrub(filters.group_by)): @@ -223,16 +222,6 @@ class GrossProfitGenerator(object): self.get_average_rate_based_on_group_by() def get_average_rate_based_on_group_by(self): - # sum buying / selling totals for group - self.totals = frappe._dict( - qty=0, - base_amount=0, - buying_amount=0, - gross_profit=0, - gross_profit_percent=0, - base_rate=0, - buying_rate=0 - ) for key in list(self.grouped): if self.filters.get("group_by") != "Invoice": for i, row in enumerate(self.grouped[key]): @@ -244,7 +233,6 @@ class GrossProfitGenerator(object): 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) - self.add_to_totals(new_row) else: for i, row in enumerate(self.grouped[key]): if row.indent == 1.0: @@ -258,17 +246,6 @@ class GrossProfitGenerator(object): if (flt(row.qty) or row.base_amount): row = self.set_average_rate(row) self.grouped_data.append(row) - self.add_to_totals(row) - - self.set_average_gross_profit(self.totals) - - if self.filters.get("group_by") == "Invoice": - self.totals.indent = 0.0 - self.totals.parent_invoice = "" - self.totals.invoice_or_item = "Total" - self.si_list.append(self.totals) - else: - self.grouped_data.append(self.totals) def is_not_invoice_row(self, row): return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get("group_by") != "Invoice" @@ -284,11 +261,6 @@ class GrossProfitGenerator(object): new_row.gross_profit_percent = flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision) \ if new_row.base_amount else 0 - def add_to_totals(self, new_row): - for key in self.totals: - if new_row.get(key): - self.totals[key] += new_row[key] - def get_returned_invoice_items(self): returned_invoices = frappe.db.sql(""" select @@ -389,7 +361,7 @@ class GrossProfitGenerator(object): if row.project: query.where( - purchase_invoice_item.item_code == row.project + purchase_invoice_item.project == row.project ) if row.cost_center: From 07bcbc6c7e10f977bc5a6ff8f5b48d91ec9b2b70 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 12 Feb 2022 19:05:03 +0530 Subject: [PATCH 03/87] fix: Remove unused param --- erpnext/accounts/report/gross_profit/gross_profit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index c403b76f87..ebb929aaac 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -172,7 +172,7 @@ class GrossProfitGenerator(object): buying_amount = 0 for row in reversed(self.si_list): - if self.skip_row(row, self.product_bundles): + if self.skip_row(row): continue row.base_amount = flt(row.base_net_amount, self.currency_precision) @@ -278,7 +278,7 @@ class GrossProfitGenerator(object): self.returned_invoices.setdefault(inv.return_against, frappe._dict())\ .setdefault(inv.item_code, []).append(inv) - def skip_row(self, row, product_bundles): + def skip_row(self, row): if self.filters.get("group_by") != "Invoice": if not row.get(scrub(self.filters.get("group_by", ""))): return True From ae613008be59334e5ff72882ef9d70355f56805e Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 12 Feb 2022 21:54:22 +0530 Subject: [PATCH 04/87] fix: Error in consolidated financial statements --- .../consolidated_financial_statement.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 758e3e9337..62bf156219 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -367,7 +367,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0) def get_account_heads(root_type, companies, filters): - accounts = get_accounts(root_type, filters) + accounts = get_accounts(root_type, companies) if not accounts: return None, None, None @@ -396,7 +396,7 @@ def update_parent_account_names(accounts): for account in accounts: if account.parent_account: - account["parent_account_name"] = name_to_account_map[account.parent_account] + account["parent_account_name"] = name_to_account_map.get(account.parent_account) return accounts @@ -419,12 +419,21 @@ def get_subsidiary_companies(company): return frappe.db.sql_list("""select name from `tabCompany` where lft >= {0} and rgt <= {1} order by lft, rgt""".format(lft, rgt)) -def get_accounts(root_type, filters): - return frappe.db.sql(""" select name, is_group, company, - parent_account, lft, rgt, root_type, report_type, account_name, account_number - from - `tabAccount` where company = %s and root_type = %s - """ , (filters.get('company'), root_type), as_dict=1) +def get_accounts(root_type, companies): + accounts = [] + added_accounts = [] + + for company in companies: + for account in frappe.db.sql(""" select name, is_group, company, + parent_account, lft, rgt, root_type, report_type, account_name, account_number + from + `tabAccount` where company = %s and root_type = %s + """ , (company, root_type), as_dict=1): + if account.account_name not in added_accounts: + accounts.append(account) + added_accounts.append(account.account_name) + + return accounts def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters): data = [] From dbd29da189145cb059ee88707e62c7d1888ed91a Mon Sep 17 00:00:00 2001 From: Wolfram Schmidt Date: Sun, 13 Feb 2022 13:11:31 +0100 Subject: [PATCH 05/87] Translation for DocType https://testsystem.frappe.cloud/app/milestone --- erpnext/translations/de.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index cf73564b9e..f345a87d03 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -1597,6 +1597,7 @@ Method,Methode, Middle Income,Mittleres Einkommen, Middle Name,Zweiter Vorname, Middle Name (Optional),Weiterer Vorname (optional), +Milestonde,Meilenstein, Min Amt can not be greater than Max Amt,Min. Amt kann nicht größer als Max. Amt sein, Min Qty can not be greater than Max Qty,Mindestmenge kann nicht größer als Maximalmenge sein, Minimum Lead Age (Days),Mindest Lead-Alter (in Tagen), From 615dd9decd1947eb8203d0b2145138044c2522a5 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sun, 13 Feb 2022 19:24:10 +0530 Subject: [PATCH 06/87] fix: Patch fixes --- .../v14_0/update_opportunity_currency_fields.py | 12 ++++-------- erpnext/regional/saudi_arabia/setup.py | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py index 13071478c8..82213fff6c 100644 --- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py +++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py @@ -6,8 +6,8 @@ from erpnext.setup.utils import get_exchange_rate def execute(): - frappe.reload_doc('crm', 'doctype', 'opportunity') - frappe.reload_doc('crm', 'doctype', 'opportunity_item') + frappe.reload_doc('crm', 'doctype', 'opportunity', force=True) + frappe.reload_doc('crm', 'doctype', 'opportunity_item', force=True) opportunities = frappe.db.get_list('Opportunity', filters={ 'opportunity_amount': ['>', 0] @@ -20,15 +20,11 @@ def execute(): if opportunity.currency != company_currency: conversion_rate = get_exchange_rate(opportunity.currency, company_currency) base_opportunity_amount = flt(conversion_rate) * flt(opportunity.opportunity_amount) - grand_total = flt(opportunity.opportunity_amount) - base_grand_total = flt(conversion_rate) * flt(opportunity.opportunity_amount) else: conversion_rate = 1 - base_opportunity_amount = grand_total = base_grand_total = flt(opportunity.opportunity_amount) + base_opportunity_amount = flt(opportunity.opportunity_amount) frappe.db.set_value('Opportunity', opportunity.name, { 'conversion_rate': conversion_rate, - 'base_opportunity_amount': base_opportunity_amount, - 'grand_total': grand_total, - 'base_grand_total': base_grand_total + 'base_opportunity_amount': base_opportunity_amount }, update_modified=False) diff --git a/erpnext/regional/saudi_arabia/setup.py b/erpnext/regional/saudi_arabia/setup.py index 15d524d5b8..d2ef6f3f17 100644 --- a/erpnext/regional/saudi_arabia/setup.py +++ b/erpnext/regional/saudi_arabia/setup.py @@ -102,7 +102,7 @@ def make_custom_fields(): ] } - create_custom_fields(custom_fields, update=True) + create_custom_fields(custom_fields, ignore_validate=True, update=True) def update_regional_tax_settings(country, company): create_ksa_vat_setting(company) From 973f6b1bbd53594e5b2a51a1dcdf7d9e38dd46a8 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 14 Feb 2022 22:14:17 +0530 Subject: [PATCH 07/87] fix: Gross profit for credit notes --- erpnext/accounts/report/gross_profit/gross_profit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index ebb929aaac..b03bb9bb13 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -282,8 +282,8 @@ class GrossProfitGenerator(object): if self.filters.get("group_by") != "Invoice": if not row.get(scrub(self.filters.get("group_by", ""))): return True - elif row.get("is_return") == 1: - return True + + return False def get_buying_amount_from_product_bundle(self, row, product_bundle): buying_amount = 0.0 From 42cdd6d2379d68efb592a5c8a8148979dce8cf1e Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 15 Feb 2022 12:05:51 +0530 Subject: [PATCH 08/87] fix: Remove commented out code --- .../consolidated_financial_statement.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 62bf156219..dad7384fea 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -354,9 +354,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): if d.parent_account: account = d.parent_account_name - # if not accounts_by_name.get(account): - # continue - for company in companies: accounts_by_name[account][company] = \ accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0) From fec40aac7a25c383e384f29471f9ea82382524b2 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 15 Feb 2022 12:15:35 +0530 Subject: [PATCH 09/87] fix: Linting issues --- .../consolidated_financial_statement.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index dad7384fea..1e20f7be3e 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -421,11 +421,9 @@ def get_accounts(root_type, companies): added_accounts = [] for company in companies: - for account in frappe.db.sql(""" select name, is_group, company, - parent_account, lft, rgt, root_type, report_type, account_name, account_number - from - `tabAccount` where company = %s and root_type = %s - """ , (company, root_type), as_dict=1): + for account in frappe.get_all("Account", fields=["name", "is_group", "company", + "parent_account", "lft", "rgt", "root_type", "report_type", "account_name", "account_number"], + filters={"company": company, "root_type": root_type}): if account.account_name not in added_accounts: accounts.append(account) added_accounts.append(account.account_name) From 799671c7482fa8bca12a24636ea0000579ca9537 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 15 Feb 2022 18:10:57 +0530 Subject: [PATCH 10/87] fix: Transfer Bucket logic for Repack Entry with split batch rows --- .../stock/report/stock_ageing/stock_ageing.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index a89a4038c2..9866e63fb5 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -286,10 +286,11 @@ class FIFOSlots: def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List): "Update FIFO Queue on inward stock." - if self.transferred_item_details.get(transfer_key): - # inward/outward from same voucher, item & warehouse - slot = self.transferred_item_details[transfer_key].pop(0) - fifo_queue.append(slot) + transfer_data = self.transferred_item_details.get(transfer_key) + if transfer_data: + # [Repack] inward/outward from same voucher, item & warehouse + # consume transfer data and add stock to fifo queue + self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) else: if not serial_nos: if fifo_queue and flt(fifo_queue[0][0]) < 0: @@ -333,6 +334,27 @@ class FIFOSlots: self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]]) qty_to_pop = 0 + def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict): + "Add previously removed stock back to FIFO Queue." + transfer_qty_to_pop = flt(row.actual_qty) + first_bucket_qty = transfer_data[0][0] + first_bucket_date = transfer_data[0][1] + + while transfer_qty_to_pop: + if transfer_data and 0 > first_bucket_qty <= transfer_qty_to_pop: + # bucket qty is not enough, consume whole + transfer_qty_to_pop -= first_bucket_qty + slot = transfer_data.pop(0) + fifo_queue.append(slot) + elif not transfer_data: + # transfer bucket is empty, extra incoming qty + fifo_queue.append([transfer_qty_to_pop, row.posting_date]) + else: + # ample bucket qty to consume + first_bucket_qty -= transfer_qty_to_pop + fifo_queue.append([transfer_qty_to_pop, first_bucket_date]) + transfer_qty_to_pop = 0 + def __update_balances(self, row: Dict, key: Union[Tuple, str]): self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction From ea3b7de867fdcc565567ec9ca1b7925116e16f2f Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 15 Feb 2022 18:41:42 +0530 Subject: [PATCH 11/87] test: Stock Ageing FIFO buckets for Repack entry with same item --- .../report/stock_ageing/test_stock_ageing.py | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 66d2f6b753..3055332540 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -236,6 +236,159 @@ class TestStockAgeing(ERPNextTestCase): item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots] self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"]) + def test_repack_entry_same_item_split_rows(self): + """ + Split consumption rows and have single repacked item row (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 500 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | -50 | 002 (repack) + Item 1 | 100 | 002 (repack) + + Case most likely for batch items. Test time bucket computation. + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=500, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=450, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=400, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=100, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 500.0) + self.assertEqual(queue[0][0], 400.0) + self.assertEqual(queue[1][0], 100.0) + # check if time buckets add up to balance qty + self.assertEqual(sum([i[0] for i in queue]), 500.0) + + def test_repack_entry_same_item_overconsume(self): + """ + Over consume item and have less repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 500 | 001 + Item 1 | -100 | 002 (repack) + Item 1 | 50 | 002 (repack) + + Case most likely for batch items. Test time bucket computation. + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=500, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-100), qty_after_transaction=400, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=450, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 450.0) + self.assertEqual(queue[0][0], 400.0) + self.assertEqual(queue[1][0], 50.0) + # check if time buckets add up to balance qty + self.assertEqual(sum([i[0] for i in queue]), 450.0) + + def test_repack_entry_same_item_overproduce(self): + """ + Under consume item and have more repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 500 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | 100 | 002 (repack) + + Case most likely for batch items. Test time bucket computation. + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=500, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=450, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=100, qty_after_transaction=550, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 550.0) + self.assertEqual(queue[0][0], 450.0) + self.assertEqual(queue[1][0], 100.0) + # check if time buckets add up to balance qty + self.assertEqual(sum([i[0] for i in queue]), 550.0) + def generate_item_and_item_wh_wise_slots(filters, sle): "Return results with and without 'show_warehouse_wise_stock'" item_wise_slots = FIFOSlots(filters, sle).generate() From f6233e77c6c2cbfeec6aeb82a73c1bbcbaa8f5da Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 15 Feb 2022 20:30:16 +0530 Subject: [PATCH 12/87] chore: Add transfer bucket working to .md file --- .../stock_ageing/stock_ageing_fifo_logic.md | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md index 9e9bed48e3..3d759dd998 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md +++ b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md @@ -71,4 +71,39 @@ Date | Qty | Queue 2nd | -60 | [[-10, 1-12-2021]] 3rd | +5 | [[-5, 3-12-2021]] 4th | +10 | [[5, 4-12-2021]] -4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]] \ No newline at end of file +4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]] + +### Concept of Transfer Qty Bucket +In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse. + +Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue. +While adding stock back to the queue we need to know how much to add. +For this we need to keep track of how much was previously consumed. +Hence we use **Transfer Qty Bucket**. + +While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness. + +#### Case 1: Same Item-Warehouse in Repack +Eg: +------------------------------------------------------------------------------------- +Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets +------------------------------------------------------------------------------------- +1st | +500 | PR | [[500, 1-12-2021]] | +2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]] +2nd | +50 | Repack | [[450, 1-12-2021], [50, 1-12-2021]] | [] + +- The balance at the end is restored back to 500 +- However, the initial 500 qty bucket is now split into 450 and 50, with the same date +- The net effect is the same as that before the Repack + +#### Case 2: Same Item-Warehouse in Repack with Split Consumption rows +Eg: +------------------------------------------------------------------------------------- +Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets +------------------------------------------------------------------------------------- +1st | +500 | PR | [[500, 1-12-2021]] | +2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]] +2nd | -50 | Repack | [[400, 1-12-2021]] | [[50, 1-12-2021], +- | | | |[50, 1-12-2021]] +2nd | +100 | Repack | [[400, 1-12-2021], [50, 1-12-2021], | [] +- | | | [50, 1-12-2021]] | From 274399978572b1f2e80fd2a1db2663efa544fcf7 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 17 Feb 2022 15:59:12 +0530 Subject: [PATCH 13/87] fix: coupon code is applied even if ignore_pricing_rule is enabled --- erpnext/public/js/controllers/transaction.js | 20 +++++----------- .../selling/page/point_of_sale/pos_payment.js | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index aa3e2f30d7..136e1edb6b 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2284,20 +2284,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe coupon_code() { var me = this; - if (this.frm.doc.coupon_code) { - frappe.run_serially([ - () => this.frm.doc.ignore_pricing_rule=1, - () => me.ignore_pricing_rule(), - () => this.frm.doc.ignore_pricing_rule=0, - () => me.apply_pricing_rule(), - () => this.frm.save() - ]); - } else { - frappe.run_serially([ - () => this.frm.doc.ignore_pricing_rule=1, - () => me.ignore_pricing_rule() - ]); - } + frappe.run_serially([ + () => this.frm.doc.ignore_pricing_rule=1, + () => me.ignore_pricing_rule(), + () => this.frm.doc.ignore_pricing_rule=0, + () => me.apply_pricing_rule() + ]); } }; diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index b9b65591dc..9650bc88a4 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -169,6 +169,29 @@ erpnext.PointOfSale.Payment = class { } }); + frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => { + if (!frm.doc.ignore_pricing_rule) { + if (frm.doc.coupon_code) { + frappe.run_serially([ + () => frm.doc.ignore_pricing_rule=1, + () => frm.trigger('ignore_pricing_rule'), + () => frm.doc.ignore_pricing_rule=0, + () => frm.trigger('apply_pricing_rule'), + () => frm.save(), + () => this.update_totals_section(frm.doc) + ]); + } else { + frappe.run_serially([ + () => frm.doc.ignore_pricing_rule=1, + () => frm.trigger('ignore_pricing_rule'), + () => frm.doc.ignore_pricing_rule=0, + () => frm.save(), + () => this.update_totals_section(frm.doc) + ]); + } + } + }); + this.setup_listener_for_payments(); this.$payment_modes.on('click', '.shortcut', function() { From 229db14b7e28d2ac0179052e7b792e06c5c9e22d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 17 Feb 2022 15:22:36 +0530 Subject: [PATCH 14/87] ci: move some tasks to background - wkhtml download - asset building --- .github/helper/install.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/helper/install.sh b/.github/helper/install.sh index eab6d50e79..859146bbcd 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -40,10 +40,14 @@ if [ "$DB" == "postgres" ];then echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres; fi -wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz -tar -xf /tmp/wkhtmltox.tar.xz -C /tmp -sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf -sudo chmod o+x /usr/local/bin/wkhtmltopdf + +install_whktml() { + wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz + tar -xf /tmp/wkhtmltox.tar.xz -C /tmp + sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf + sudo chmod o+x /usr/local/bin/wkhtmltopdf +} +install_whktml & cd ~/frappe-bench || exit @@ -57,5 +61,5 @@ bench get-app erpnext "${GITHUB_WORKSPACE}" if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi bench start &> bench_run_logs.txt & +CI=Yes bench build --app frappe & bench --site test_site reinstall --yes -bench build --app frappe From e2e998fbd9baa6015bc9c376dd5b6db7ae6cae49 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 17 Feb 2022 12:00:19 +0530 Subject: [PATCH 15/87] fix(Timesheet): convert time logs to datetime while checking for overlap --- .../projects/doctype/timesheet/timesheet.py | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index dd0b5f90f4..fa0411e0f8 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours +from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours from erpnext.controllers.queries import get_match_cond from erpnext.hr.utils import validate_active_employee @@ -145,7 +145,7 @@ class Timesheet(Document): if not (data.from_time and data.hours): return - _to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True) + _to_time = get_datetime(add_to_date(data.from_time, hours=data.hours, as_datetime=True)) if data.to_time != _to_time: data.to_time = _to_time @@ -186,24 +186,37 @@ class Timesheet(Document): and ts.docstatus < 2""".format(cond), { "val": value, - "from_time": args.from_time, - "to_time": args.to_time, + "from_time": get_datetime(args.from_time), + "to_time": get_datetime(args.to_time), "name": args.name or "No Name", "parent": args.parent or "No Name" }, as_dict=True) - # check internal overlap - for time_log in self.time_logs: - if not (time_log.from_time and time_log.to_time - and args.from_time and args.to_time): continue - if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \ - args.idx != time_log.idx and ((args.from_time > time_log.from_time and args.from_time < time_log.to_time) or - (args.to_time > time_log.from_time and args.to_time < time_log.to_time) or - (args.from_time <= time_log.from_time and args.to_time >= time_log.to_time)): - return self + if self.check_internal_overlap(fieldname, args): + return self return existing[0] if existing else None + def check_internal_overlap(self, fieldname, args): + for time_log in self.time_logs: + if not (time_log.from_time and time_log.to_time + and args.from_time and args.to_time): + continue + + from_time = get_datetime(time_log.from_time) + to_time = get_datetime(time_log.to_time) + args_from_time = get_datetime(args.from_time) + args_to_time = get_datetime(args.to_time) + + if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \ + args.idx != time_log.idx and ( + (args_from_time > from_time and args_from_time < to_time) + or (args_to_time > from_time and args_to_time < to_time) + or (args_from_time <= from_time and args_to_time >= to_time) + ): + return True + return False + def update_cost(self): for data in self.time_logs: if data.activity_type or data.is_billable: From 3ec9acf8f7c8fd08e5709ac0f352728f6a9d6cfa Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 17 Feb 2022 14:39:17 +0530 Subject: [PATCH 16/87] fix: convert overlap raw query to frappe.qb --- .../projects/doctype/timesheet/timesheet.py | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index fa0411e0f8..c43be8cbd8 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -171,26 +171,35 @@ class Timesheet(Document): .format(args.idx, self.name, existing.name), OverlapError) def get_overlap_for(self, fieldname, args, value): - cond = "ts.`{0}`".format(fieldname) - if fieldname == 'workstation': - cond = "tsd.`{0}`".format(fieldname) + timesheet = frappe.qb.DocType("Timesheet") + timelog = frappe.qb.DocType("Timesheet Detail") - existing = frappe.db.sql("""select ts.name as name, tsd.from_time as from_time, tsd.to_time as to_time from - `tabTimesheet Detail` tsd, `tabTimesheet` ts where {0}=%(val)s and tsd.parent = ts.name and - ( - (%(from_time)s > tsd.from_time and %(from_time)s < tsd.to_time) or - (%(to_time)s > tsd.from_time and %(to_time)s < tsd.to_time) or - (%(from_time)s <= tsd.from_time and %(to_time)s >= tsd.to_time)) - and tsd.name!=%(name)s - and ts.name!=%(parent)s - and ts.docstatus < 2""".format(cond), - { - "val": value, - "from_time": get_datetime(args.from_time), - "to_time": get_datetime(args.to_time), - "name": args.name or "No Name", - "parent": args.parent or "No Name" - }, as_dict=True) + from_time = get_datetime(args.from_time) + to_time = get_datetime(args.to_time) + + query = ( + frappe.qb.from_(timesheet) + .join(timelog) + .on(timelog.parent == timesheet.name) + .select(timesheet.name.as_('name'), timelog.from_time.as_('from_time'), timelog.to_time.as_('to_time')) + .where( + (timelog.name != (args.name or "No Name")) + & (timesheet.name != (args.parent or "No Name")) + & (timesheet.docstatus < 2) + & ( + ((from_time > timelog.from_time) & (from_time < timelog.to_time)) + | ((to_time > timelog.from_time) & (to_time < timelog.to_time)) + | ((from_time <= timelog.from_time) & (to_time >= timelog.to_time)) + ) + ) + ) + + if fieldname == "workstation": + query = query.where(timelog[fieldname] == value) + else: + query = query.where(timesheet[fieldname] == value) + + existing = query.run(as_dict=True) if self.check_internal_overlap(fieldname, args): return self @@ -208,12 +217,13 @@ class Timesheet(Document): args_from_time = get_datetime(args.from_time) args_to_time = get_datetime(args.to_time) - if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \ + if ((fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and args.idx != time_log.idx and ( (args_from_time > from_time and args_from_time < to_time) or (args_to_time > from_time and args_to_time < to_time) or (args_from_time <= from_time and args_to_time >= to_time) - ): + ) + ): return True return False From 47ff968253ff7c4e7ca4e7769ccc29d93a8f71f2 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 17 Feb 2022 14:39:26 +0530 Subject: [PATCH 17/87] test: timesheet not overlapping with continuous timelogs --- .../doctype/timesheet/test_timesheet.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 989bcd1670..8b60357021 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -151,6 +151,35 @@ class TestTimesheet(unittest.TestCase): settings.ignore_employee_time_overlap = initial_setting settings.save() + def test_timesheet_not_overlapping_with_continuous_timelogs(self): + emp = make_employee("test_employee_6@salary.com") + + update_activity_type("_Test Activity Type") + timesheet = frappe.new_doc("Timesheet") + timesheet.employee = emp + timesheet.append( + 'time_logs', + { + "billable": 1, + "activity_type": "_Test Activity Type", + "from_time": now_datetime(), + "to_time": now_datetime() + datetime.timedelta(hours=3), + "company": "_Test Company" + } + ) + timesheet.append( + 'time_logs', + { + "billable": 1, + "activity_type": "_Test Activity Type", + "from_time": now_datetime() + datetime.timedelta(hours=3), + "to_time": now_datetime() + datetime.timedelta(hours=4), + "company": "_Test Company" + } + ) + + timesheet.save() # should not throw an error + def test_to_time(self): emp = make_employee("test_employee_6@salary.com") from_time = now_datetime() From bef46e2b645f17eca8c1cd6ebe74e2845f6ea64f Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 17 Feb 2022 16:59:14 +0530 Subject: [PATCH 18/87] chore: remove unused code and fields related to workstation from Timesheet Detail --- .../projects/doctype/timesheet/timesheet.py | 22 +++------ .../timesheet_detail/timesheet_detail.json | 48 ++----------------- 2 files changed, 10 insertions(+), 60 deletions(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index c43be8cbd8..b44d501743 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -177,7 +177,7 @@ class Timesheet(Document): from_time = get_datetime(args.from_time) to_time = get_datetime(args.to_time) - query = ( + existing = ( frappe.qb.from_(timesheet) .join(timelog) .on(timelog.parent == timesheet.name) @@ -186,20 +186,14 @@ class Timesheet(Document): (timelog.name != (args.name or "No Name")) & (timesheet.name != (args.parent or "No Name")) & (timesheet.docstatus < 2) + & (timesheet[fieldname] == value) & ( ((from_time > timelog.from_time) & (from_time < timelog.to_time)) | ((to_time > timelog.from_time) & (to_time < timelog.to_time)) | ((from_time <= timelog.from_time) & (to_time >= timelog.to_time)) ) ) - ) - - if fieldname == "workstation": - query = query.where(timelog[fieldname] == value) - else: - query = query.where(timesheet[fieldname] == value) - - existing = query.run(as_dict=True) + ).run(as_dict=True) if self.check_internal_overlap(fieldname, args): return self @@ -217,12 +211,10 @@ class Timesheet(Document): args_from_time = get_datetime(args.from_time) args_to_time = get_datetime(args.to_time) - if ((fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and - args.idx != time_log.idx and ( - (args_from_time > from_time and args_from_time < to_time) - or (args_to_time > from_time and args_to_time < to_time) - or (args_from_time <= from_time and args_to_time >= to_time) - ) + if (args.get(fieldname) == time_log.get(fieldname)) and (args.idx != time_log.idx) and ( + (args_from_time > from_time and args_from_time < to_time) + or (args_to_time > from_time and args_to_time < to_time) + or (args_from_time <= from_time and args_to_time >= to_time) ): return True return False diff --git a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json index ee04c612c9..90fdb83331 100644 --- a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json +++ b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json @@ -14,12 +14,6 @@ "to_time", "hours", "completed", - "section_break_7", - "completed_qty", - "workstation", - "column_break_12", - "operation", - "operation_id", "project_details", "project", "project_name", @@ -83,43 +77,6 @@ "fieldtype": "Check", "label": "Completed" }, - { - "fieldname": "section_break_7", - "fieldtype": "Section Break" - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "completed_qty", - "fieldtype": "Float", - "label": "Completed Qty" - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "workstation", - "fieldtype": "Link", - "label": "Workstation", - "options": "Workstation", - "read_only": 1 - }, - { - "fieldname": "column_break_12", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "operation", - "fieldtype": "Link", - "label": "Operation", - "options": "Operation", - "read_only": 1 - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "operation_id", - "fieldtype": "Data", - "hidden": 1, - "label": "Operation Id" - }, { "fieldname": "project_details", "fieldtype": "Section Break" @@ -267,7 +224,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-05-18 12:19:33.205940", + "modified": "2022-02-17 16:53:34.878798", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet Detail", @@ -275,5 +232,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file From 555b1335f65cca4f77c28294e153002a39e114a4 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 17 Feb 2022 19:15:30 +0530 Subject: [PATCH 19/87] feat: Bank Reconciliation for loan documents --- .../bank_reconciliation_tool.py | 73 ++++++++++++++++++- .../loan_disbursement/loan_disbursement.json | 47 ++++++++++-- .../loan_disbursement/loan_disbursement.py | 12 +-- .../loan_repayment/loan_repayment.json | 52 ++++++++++++- .../doctype/loan_repayment/loan_repayment.py | 24 +++--- .../dialog_manager.js | 17 ++++- 6 files changed, 190 insertions(+), 35 deletions(-) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 4211bd0169..26078d6329 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -275,6 +275,10 @@ def check_matching(bank_account, company, transaction, document_types): } matching_vouchers = [] + + matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, + document_types, filters)) + for query in subquery: matching_vouchers.extend( frappe.db.sql(query, filters,) @@ -311,6 +315,74 @@ def get_queries(bank_account, company, transaction, document_types): return queries +def get_loan_vouchers(bank_account, transaction, document_types, filters): + vouchers = [] + amount_condition = True if "exact_match" in document_types else False + + if transaction.withdrawal > 0 and "loan_disbursement" in document_types: + vouchers.append(get_ld_matching_query(bank_account, amount_condition, filters)) + + if transaction.deposit > 0 and "loan_repayment" in document_types: + vouchers.append(get_lr_matching_query(bank_account, amount_condition, filters)) + +def get_ld_matching_query(bank_account, amount_condition, filters): + loan_disbursement = frappe.qb.DocType("Loan Disbursement") + query = frappe.qb.from_(loan_disbursement).select( + loan_disbursement.name, + loan_disbursement.disbursed_amount, + loan_disbursement.reference_number, + loan_disbursement.reference_date, + loan_disbursement.applicant_type, + loan_disbursement.disbursement_date + ).where( + loan_disbursement.docstatus == 1 + ).where( + loan_disbursement.clearance_date.isnull() + ).where( + loan_disbursement.disbursement_account == bank_account + ) + + if amount_condition: + query.where( + loan_disbursement.disbursed_amount == filters.get('amount') + ) + else: + query.where( + loan_disbursement.disbursed_amount <= filters.get('amount') + ) + + vouchers = query.run(as_dict=1) + return vouchers + +def get_lr_matching_query(bank_account, amount_condition, filters): + loan_repayment = frappe.qb.DocType("Loan Repayment") + query = frappe.qb.from_(loan_repayment).select( + loan_repayment.name, + loan_repayment.paid_amount, + loan_repayment.reference_number, + loan_repayment.reference_date, + loan_repayment.applicant_type, + loan_repayment.posting_date + ).where( + loan_repayment.docstatus == 1 + ).where( + loan_repayment.clearance_date.isnull() + ).where( + loan_repayment.disbursement_account == bank_account + ) + + if amount_condition: + query.where( + loan_repayment.paid_amount == filters.get('amount') + ) + else: + query.where( + loan_repayment.paid_amount <= filters.get('amount') + ) + + vouchers = query.run(as_dict=1) + return vouchers + def get_pe_matching_query(amount_condition, account_from_to, transaction): # get matching payment entries query if transaction.deposit > 0: @@ -348,7 +420,6 @@ def get_je_matching_query(amount_condition, transaction): # We have mapping at the bank level # So one bank could have both types of bank accounts like asset and liability # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type - company_account = frappe.get_value("Bank Account", transaction.bank_account, "account") cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit" return f""" diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json index 7811d56a75..50926d7726 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json @@ -14,11 +14,15 @@ "applicant", "section_break_7", "disbursement_date", + "clearance_date", "column_break_8", "disbursed_amount", "accounting_dimensions_section", "cost_center", - "customer_details_section", + "accounting_details", + "disbursement_account", + "column_break_16", + "loan_account", "bank_account", "disbursement_references_section", "reference_date", @@ -106,11 +110,6 @@ "fieldtype": "Section Break", "label": "Disbursement Details" }, - { - "fieldname": "customer_details_section", - "fieldtype": "Section Break", - "label": "Customer Details" - }, { "fetch_from": "against_loan.applicant_type", "fieldname": "applicant_type", @@ -149,15 +148,48 @@ "fieldname": "reference_number", "fieldtype": "Data", "label": "Reference Number" + }, + { + "fieldname": "clearance_date", + "fieldtype": "Date", + "label": "Clearance Date", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "accounting_details", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fetch_from": "against_loan.disbursement_account", + "fieldname": "disbursement_account", + "fieldtype": "Link", + "label": "Disbursement Account", + "options": "Account", + "read_only": 1 + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fetch_from": "against_loan.loan_account", + "fieldname": "loan_account", + "fieldtype": "Link", + "label": "Loan Account", + "options": "Account", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:09:32.175355", + "modified": "2022-02-17 18:23:44.157598", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Disbursement", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -194,5 +226,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py index df3aadfb18..54a03b92b5 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -42,9 +42,6 @@ class LoanDisbursement(AccountsController): if not self.posting_date: self.posting_date = self.disbursement_date or nowdate() - if not self.bank_account and self.applicant_type == "Customer": - self.bank_account = frappe.db.get_value("Customer", self.applicant, "default_bank_account") - def validate_disbursal_amount(self): possible_disbursal_amount = get_disbursal_amount(self.against_loan) @@ -117,12 +114,11 @@ class LoanDisbursement(AccountsController): def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] - loan_details = frappe.get_doc("Loan", self.against_loan) gle_map.append( self.get_gl_dict({ - "account": loan_details.loan_account, - "against": loan_details.disbursement_account, + "account": self.loan_account, + "against": self.disbursement_account, "debit": self.disbursed_amount, "debit_in_account_currency": self.disbursed_amount, "against_voucher_type": "Loan", @@ -137,8 +133,8 @@ class LoanDisbursement(AccountsController): gle_map.append( self.get_gl_dict({ - "account": loan_details.disbursement_account, - "against": loan_details.loan_account, + "account": self.disbursement_account, + "against": self.loan_account, "credit": self.disbursed_amount, "credit_in_account_currency": self.disbursed_amount, "against_voucher_type": "Loan", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json index 93ef217042..766602de86 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json @@ -1,7 +1,7 @@ { "actions": [], "autoname": "LM-REP-.####", - "creation": "2019-09-03 14:44:39.977266", + "creation": "2022-01-25 10:30:02.767941", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -13,6 +13,7 @@ "column_break_3", "company", "posting_date", + "clearance_date", "rate_of_interest", "payroll_payable_account", "is_term_loan", @@ -37,7 +38,12 @@ "total_penalty_paid", "total_interest_paid", "repayment_details", - "amended_from" + "amended_from", + "accounting_details_section", + "repayment_account", + "penalty_income_account", + "column_break_36", + "loan_account" ], "fields": [ { @@ -260,12 +266,52 @@ "fieldname": "repay_from_salary", "fieldtype": "Check", "label": "Repay From Salary" + }, + { + "fieldname": "clearance_date", + "fieldtype": "Date", + "label": "Clearance Date", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "accounting_details_section", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fetch_from": "against_loan.payment_account", + "fieldname": "repayment_account", + "fieldtype": "Link", + "label": "Repayment Account", + "options": "Account", + "read_only": 1 + }, + { + "fieldname": "column_break_36", + "fieldtype": "Column Break" + }, + { + "fetch_from": "against_loan.loan_account", + "fieldname": "loan_account", + "fieldtype": "Link", + "label": "Loan Account", + "options": "Account", + "read_only": 1 + }, + { + "fetch_from": "against_loan.penalty_income_account", + "fieldname": "penalty_income_account", + "fieldtype": "Link", + "hidden": 1, + "label": "Penalty Income Account", + "options": "Account" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-01-06 01:51:06.707782", + "modified": "2022-02-17 19:10:07.742298", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index f3ed611255..67c2b1ee14 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -310,7 +310,6 @@ class LoanRepayment(AccountsController): def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] - loan_details = frappe.get_doc("Loan", self.against_loan) if self.shortfall_amount and self.amount_paid > self.shortfall_amount: remarks = _("Shortfall Repayment of {0}.\nRepayment against Loan: {1}").format(self.shortfall_amount, @@ -323,13 +322,13 @@ class LoanRepayment(AccountsController): if self.repay_from_salary: payment_account = self.payroll_payable_account else: - payment_account = loan_details.payment_account + payment_account = self.payment_account if self.total_penalty_paid: gle_map.append( self.get_gl_dict({ - "account": loan_details.loan_account, - "against": loan_details.payment_account, + "account": self.loan_account, + "against": payment_account, "debit": self.total_penalty_paid, "debit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", @@ -344,8 +343,8 @@ class LoanRepayment(AccountsController): gle_map.append( self.get_gl_dict({ - "account": loan_details.penalty_income_account, - "against": loan_details.loan_account, + "account": self.penalty_income_account, + "against": self.loan_account, "credit": self.total_penalty_paid, "credit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", @@ -359,8 +358,7 @@ class LoanRepayment(AccountsController): gle_map.append( self.get_gl_dict({ "account": payment_account, - "against": loan_details.loan_account + ", " + loan_details.interest_income_account - + ", " + loan_details.penalty_income_account, + "against": self.loan_account + ", " + self.penalty_income_account, "debit": self.amount_paid, "debit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", @@ -368,16 +366,16 @@ class LoanRepayment(AccountsController): "remarks": remarks, "cost_center": self.cost_center, "posting_date": getdate(self.posting_date), - "party_type": loan_details.applicant_type if self.repay_from_salary else '', - "party": loan_details.applicant if self.repay_from_salary else '' + "party_type": self.applicant_type if self.repay_from_salary else '', + "party": self.applicant if self.repay_from_salary else '' }) ) gle_map.append( self.get_gl_dict({ - "account": loan_details.loan_account, - "party_type": loan_details.applicant_type, - "party": loan_details.applicant, + "account": self.loan_account, + "party_type": self.applicant_type, + "party": self.applicant, "against": payment_account, "credit": self.amount_paid, "credit_in_account_currency": self.amount_paid, diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index ca73393c54..214a1be134 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -181,6 +181,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "journal_entry", onchange: () => this.update_options(), }, + { + fieldtype: "Check", + label: "Loan Repayment", + fieldname: "loan_repayment", + onchange: () => this.update_options(), + }, { fieldname: "column_break_5", fieldtype: "Column Break", @@ -191,13 +197,18 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "sales_invoice", onchange: () => this.update_options(), }, - { fieldtype: "Check", label: "Purchase Invoice", fieldname: "purchase_invoice", onchange: () => this.update_options(), }, + { + fieldtype: "Check", + label: "Show Only Exact Amount", + fieldname: "exact_match", + onchange: () => this.update_options(), + }, { fieldname: "column_break_5", fieldtype: "Column Break", @@ -210,8 +221,8 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { }, { fieldtype: "Check", - label: "Show Only Exact Amount", - fieldname: "exact_match", + label: "Loan Disbursement", + fieldname: "loan_disbursement", onchange: () => this.update_options(), }, { From c36bd7e1a6fe48c5fff4765e843571a0d6560dd1 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 17 Feb 2022 19:25:00 +0530 Subject: [PATCH 20/87] fix: avoid creating bins without item-wh Co-Authored-By: Shadrak Gurupnor <30501401+shadrak98@users.noreply.github.com> Co-Authored-By: Saurabh --- erpnext/controllers/accounts_controller.py | 3 ++- erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py | 2 ++ erpnext/patches/v4_2/repost_reserved_qty.py | 8 +++++--- erpnext/patches/v4_2/update_requested_and_ordered_qty.py | 2 ++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 994b903b32..d05787fdfb 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1955,7 +1955,8 @@ def update_bin_on_delete(row, doctype): qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) - update_bin_qty(row.item_code, row.warehouse, qty_dict) + if row.warehouse: + update_bin_qty(row.item_code, row.warehouse, qty_dict) def validate_and_delete_children(parent, data): deleted_children = [] diff --git a/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py b/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py index 9b083cafb3..8dec9ff381 100644 --- a/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py +++ b/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py @@ -9,6 +9,8 @@ def execute(): FROM `tabBin`""",as_dict=1) for entry in bin_details: + if not (entry.item_code and entry.warehouse): + continue update_bin_qty(entry.get("item_code"), entry.get("warehouse"), { "indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse")) }) diff --git a/erpnext/patches/v4_2/repost_reserved_qty.py b/erpnext/patches/v4_2/repost_reserved_qty.py index c2ca9be64a..ed4b19d07d 100644 --- a/erpnext/patches/v4_2/repost_reserved_qty.py +++ b/erpnext/patches/v4_2/repost_reserved_qty.py @@ -29,9 +29,11 @@ def execute(): """) for item_code, warehouse in repost_for: - update_bin_qty(item_code, warehouse, { - "reserved_qty": get_reserved_qty(item_code, warehouse) - }) + if not (item_code and warehouse): + continue + update_bin_qty(item_code, warehouse, { + "reserved_qty": get_reserved_qty(item_code, warehouse) + }) frappe.db.sql("""delete from tabBin where exists( diff --git a/erpnext/patches/v4_2/update_requested_and_ordered_qty.py b/erpnext/patches/v4_2/update_requested_and_ordered_qty.py index 42b0b04076..dd79410ba5 100644 --- a/erpnext/patches/v4_2/update_requested_and_ordered_qty.py +++ b/erpnext/patches/v4_2/update_requested_and_ordered_qty.py @@ -14,6 +14,8 @@ def execute(): union select item_code, warehouse from `tabStock Ledger Entry`) a"""): try: + if not (item_code and warehouse): + continue count += 1 update_bin_qty(item_code, warehouse, { "indented_qty": get_indented_qty(item_code, warehouse), From 87b074ac0966ab26bf776c720fcb96b92a451d55 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 17 Feb 2022 22:01:00 +0530 Subject: [PATCH 21/87] fix: GSTIN filter for GSTR-1 report --- erpnext/regional/report/gstr_1/gstr_1.js | 23 ++++++++++++++++++++--- erpnext/regional/report/gstr_1/gstr_1.py | 23 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/erpnext/regional/report/gstr_1/gstr_1.js b/erpnext/regional/report/gstr_1/gstr_1.js index 4b98978f13..1766fdb2ec 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.js +++ b/erpnext/regional/report/gstr_1/gstr_1.js @@ -17,7 +17,7 @@ frappe.query_reports["GSTR-1"] = { "fieldtype": "Link", "options": "Address", "get_query": function () { - var company = frappe.query_report.get_filter_value('company'); + let company = frappe.query_report.get_filter_value('company'); if (company) { return { "query": 'frappe.contacts.doctype.address.address.address_query', @@ -26,6 +26,11 @@ frappe.query_reports["GSTR-1"] = { } } }, + { + "fieldname": "company_gstin", + "label": __("Company GSTIN"), + "fieldtype": "Select" + }, { "fieldname": "from_date", "label": __("From Date"), @@ -60,10 +65,22 @@ frappe.query_reports["GSTR-1"] = { } ], onload: function (report) { + let filters = report.get_values(); + + frappe.call({ + method: 'erpnext.regional.report.gstr_1.gstr_1.get_company_gstins', + args: { + company: filters.company + }, + callback: function(r) { + console.log(r.message); + frappe.query_report.page.fields_dict.company_gstin.df.options = r.message; + frappe.query_report.page.fields_dict.company_gstin.refresh(); + } + }); + report.page.add_inner_button(__("Download as JSON"), function () { - var filters = report.get_values(); - frappe.call({ method: 'erpnext.regional.report.gstr_1.gstr_1.get_json', args: { diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index ce2ffb4010..8fcb6bb444 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -253,7 +253,8 @@ class Gstr1Report(object): for opts in (("company", " and company=%(company)s"), ("from_date", " and posting_date>=%(from_date)s"), ("to_date", " and posting_date<=%(to_date)s"), - ("company_address", " and company_address=%(company_address)s")): + ("company_address", " and company_address=%(company_address)s"), + ("company_gstin", " and company_gstin=%(company_gstin)s")): if self.filters.get(opts[0]): conditions += opts[1] @@ -1192,3 +1193,23 @@ def is_inter_state(invoice_detail): return True else: return False + + +@frappe.whitelist() +def get_company_gstins(company): + address = frappe.qb.DocType("Address") + links = frappe.qb.DocType("Dynamic Link") + + addresses = frappe.qb.from_(address).inner_join(links).on( + address.name == links.parent + ).select( + address.gstin + ).where( + links.link_doctype == 'Company' + ).where( + links.link_name == company + ).run(as_dict=1) + + address_list = [''] + [d.gstin for d in addresses] + + return address_list \ No newline at end of file From 1617e0d0e6d7f8f3cbffab4edaf388b5aa6db4b4 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 17 Feb 2022 22:52:53 +0530 Subject: [PATCH 22/87] fix: Remove reload doc --- erpnext/patches/v14_0/update_opportunity_currency_fields.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py index 82213fff6c..75049a6e8a 100644 --- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py +++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py @@ -6,9 +6,6 @@ from erpnext.setup.utils import get_exchange_rate def execute(): - frappe.reload_doc('crm', 'doctype', 'opportunity', force=True) - frappe.reload_doc('crm', 'doctype', 'opportunity_item', force=True) - opportunities = frappe.db.get_list('Opportunity', filters={ 'opportunity_amount': ['>', 0] }, fields=['name', 'company', 'currency', 'opportunity_amount']) From 3a966d4dbe3cd868bcb01d4951b236cad154605c Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 17 Feb 2022 23:18:07 +0530 Subject: [PATCH 23/87] fix: Move patch to post sync --- erpnext/patches.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d300340671..33366867f2 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -329,7 +329,6 @@ execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings' erpnext.patches.v14_0.set_payroll_cost_centers erpnext.patches.v13_0.agriculture_deprecation_warning erpnext.patches.v13_0.hospitality_deprecation_warning -erpnext.patches.v13_0.update_exchange_rate_settings erpnext.patches.v13_0.update_asset_quantity_field erpnext.patches.v13_0.delete_bank_reconciliation_detail erpnext.patches.v13_0.enable_provisional_accounting @@ -351,3 +350,4 @@ erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template erpnext.patches.v13_0.shopping_cart_to_ecommerce erpnext.patches.v13_0.update_disbursement_account erpnext.patches.v13_0.update_reserved_qty_closed_wo +erpnext.patches.v13_0.update_exchange_rate_settings From d3fbbcfed39570fbad52a77b2533c2b72da8679f Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 17 Feb 2022 14:30:00 +0530 Subject: [PATCH 24/87] fix: Precision of available qty and negative stock in transfer bucket - Maintain only positive values in transfer bucket - Use it to neutralize/add stock to fifo queue --- .../stock/report/stock_ageing/stock_ageing.py | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 9866e63fb5..60f9e959c8 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -28,6 +28,7 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li "Returns ordered, formatted data with ranges." _func = itemgetter(1) data = [] + precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) for item, item_dict in item_details.items(): earliest_age, latest_age = 0, 0 @@ -48,10 +49,13 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li if filters.get("show_warehouse_wise_stock"): row.append(details.warehouse) - row.extend([item_dict.get("total_qty"), average_age, + row.extend([ + flt(item_dict.get("total_qty"), precision), + average_age, range1, range2, range3, above_range3, earliest_age, latest_age, - details.stock_uom]) + details.stock_uom + ]) data.append(row) @@ -288,13 +292,14 @@ class FIFOSlots: transfer_data = self.transferred_item_details.get(transfer_key) if transfer_data: - # [Repack] inward/outward from same voucher, item & warehouse + # inward/outward from same voucher, item & warehouse + # eg: Repack with same item, Stock reco for batch item # consume transfer data and add stock to fifo queue self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) else: if not serial_nos: - if fifo_queue and flt(fifo_queue[0][0]) < 0: - # neutralize negative stock by adding positive stock + if fifo_queue and flt(fifo_queue[0][0]) <= 0: + # neutralize 0/negative stock by adding positive stock fifo_queue[0][0] += flt(row.actual_qty) fifo_queue[0][1] = row.posting_date else: @@ -325,7 +330,7 @@ class FIFOSlots: elif not fifo_queue: # negative stock, no balance but qty yet to consume fifo_queue.append([-(qty_to_pop), row.posting_date]) - self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date]) + self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date]) qty_to_pop = 0 else: # qty to pop < slot qty, ample balance @@ -337,22 +342,28 @@ class FIFOSlots: def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict): "Add previously removed stock back to FIFO Queue." transfer_qty_to_pop = flt(row.actual_qty) - first_bucket_qty = transfer_data[0][0] - first_bucket_date = transfer_data[0][1] + + def add_to_fifo_queue(slot): + if fifo_queue and flt(fifo_queue[0][0]) <= 0: + # neutralize 0/negative stock by adding positive stock + fifo_queue[0][0] += flt(slot[0]) + fifo_queue[0][1] = slot[1] + else: + fifo_queue.append(slot) while transfer_qty_to_pop: - if transfer_data and 0 > first_bucket_qty <= transfer_qty_to_pop: + if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop: # bucket qty is not enough, consume whole - transfer_qty_to_pop -= first_bucket_qty - slot = transfer_data.pop(0) - fifo_queue.append(slot) + transfer_qty_to_pop -= transfer_data[0][0] + add_to_fifo_queue(transfer_data.pop(0)) elif not transfer_data: # transfer bucket is empty, extra incoming qty - fifo_queue.append([transfer_qty_to_pop, row.posting_date]) + add_to_fifo_queue([transfer_qty_to_pop, row.posting_date]) + transfer_qty_to_pop = 0 else: # ample bucket qty to consume - first_bucket_qty -= transfer_qty_to_pop - fifo_queue.append([transfer_qty_to_pop, first_bucket_date]) + transfer_data[0][0] -= transfer_qty_to_pop + add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]]) transfer_qty_to_pop = 0 def __update_balances(self, row: Dict, key: Union[Tuple, str]): From ed4a6c6cc63ca37a6033f9f87c35cd26aaa2cb43 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 18 Feb 2022 18:52:42 +0530 Subject: [PATCH 25/87] fix: Range Qty precision --- erpnext/stock/report/stock_ageing/stock_ageing.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 60f9e959c8..97a740e184 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos Filters = frappe._dict +precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) def execute(filters: Filters = None) -> Tuple: to_date = filters["to_date"] @@ -28,7 +29,6 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li "Returns ordered, formatted data with ranges." _func = itemgetter(1) data = [] - precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) for item, item_dict in item_details.items(): earliest_age, latest_age = 0, 0 @@ -83,13 +83,13 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0 if age <= filters.range1: - range1 += qty + range1 = flt(range1 + qty, precision) elif age <= filters.range2: - range2 += qty + range2 = flt(range2 + qty, precision) elif age <= filters.range3: - range3 += qty + range3 = flt(range3 + qty, precision) else: - above_range3 += qty + above_range3 = flt(above_range3 + qty, precision) return range1, range2, range3, above_range3 From d5be536740642d0bef9ea23151a41ce2657b9cd2 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 18 Feb 2022 18:53:05 +0530 Subject: [PATCH 26/87] test: Negative Stock, over consumption & over production with split rows, balance precision --- .../report/stock_ageing/test_stock_ageing.py | 221 +++++++++++++++++- 1 file changed, 217 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 3055332540..3fc357e8d4 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -3,7 +3,7 @@ import frappe -from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots +from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data from erpnext.tests.utils import ERPNextTestCase @@ -11,7 +11,8 @@ class TestStockAgeing(ERPNextTestCase): def setUp(self) -> None: self.filters = frappe._dict( company="_Test Company", - to_date="2021-12-10" + to_date="2021-12-10", + range1=30, range2=60, range3=90 ) def test_normal_inward_outward_queue(self): @@ -289,7 +290,8 @@ class TestStockAgeing(ERPNextTestCase): self.assertEqual(item_result["total_qty"], 500.0) self.assertEqual(queue[0][0], 400.0) - self.assertEqual(queue[1][0], 100.0) + self.assertEqual(queue[1][0], 50.0) + self.assertEqual(queue[2][0], 50.0) # check if time buckets add up to balance qty self.assertEqual(sum([i[0] for i in queue]), 500.0) @@ -341,6 +343,63 @@ class TestStockAgeing(ERPNextTestCase): # check if time buckets add up to balance qty self.assertEqual(sum([i[0] for i in queue]), 450.0) + def test_repack_entry_same_item_overconsume_with_split_rows(self): + """ + Over consume item and have less repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 20 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | -50 | 002 (repack) + Item 1 | 50 | 002 (repack) + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=20, qty_after_transaction=20, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-30), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-80), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=(-30), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], -30.0) + self.assertEqual(queue[0][0], -30.0) + + # check transfer bucket + transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')] + self.assertEqual(transfer_bucket[0][0], 50) + def test_repack_entry_same_item_overproduce(self): """ Under consume item and have more repacked item qty (same warehouse). @@ -385,10 +444,164 @@ class TestStockAgeing(ERPNextTestCase): self.assertEqual(item_result["total_qty"], 550.0) self.assertEqual(queue[0][0], 450.0) - self.assertEqual(queue[1][0], 100.0) + self.assertEqual(queue[1][0], 50.0) + self.assertEqual(queue[2][0], 50.0) # check if time buckets add up to balance qty self.assertEqual(sum([i[0] for i in queue]), 550.0) + def test_repack_entry_same_item_overproduce_with_split_rows(self): + """ + Over consume item and have less repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 20 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | 50 | 002 (repack) + Item 1 | 50 | 002 (repack) + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=20, qty_after_transaction=20, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-30), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=20, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=70, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 70.0) + self.assertEqual(queue[0][0], 20.0) + self.assertEqual(queue[1][0], 50.0) + + # check transfer bucket + transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')] + self.assertFalse(transfer_bucket) + + def test_negative_stock_same_voucher(self): + """ + Test negative stock scenario in transfer bucket via repack entry (same wh). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | -50 | 001 + Item 1 | -50 | 001 + Item 1 | 30 | 001 + Item 1 | 80 | 001 + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-50), + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( # stock up item + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-100), + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( # stock up item + name="Flask Item", + actual_qty=30, qty_after_transaction=(-70), + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + ] + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + + # check transfer bucket + transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')] + self.assertEqual(transfer_bucket[0][0], 20) + self.assertEqual(transfer_bucket[1][0], 50) + self.assertEqual(item_result["fifo_queue"][0][0], -70.0) + + sle.append(frappe._dict( + name="Flask Item", + actual_qty=80, qty_after_transaction=10, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + )) + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + + transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')] + self.assertFalse(transfer_bucket) + self.assertEqual(item_result["fifo_queue"][0][0], 10.0) + + def test_precision(self): + "Test if final balance qty is rounded off correctly." + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=0.3, qty_after_transaction=0.3, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( # stock up item + name="Flask Item", + actual_qty=0.6, qty_after_transaction=0.9, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + report_data = format_report_data(self.filters, slots, self.filters["to_date"]) + row = report_data[0] # first row in report + bal_qty = row[5] + range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance + + # check if value of Available Qty column matches with range bucket post format + self.assertEqual(bal_qty, 0.9) + self.assertEqual(bal_qty, range_qty_sum) + def generate_item_and_item_wh_wise_slots(filters, sle): "Return results with and without 'show_warehouse_wise_stock'" item_wise_slots = FIFOSlots(filters, sle).generate() From 5a2b571aa9e0f448d2030e1901dfb9ec3e547d46 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 18 Feb 2022 20:05:49 +0530 Subject: [PATCH 27/87] fix: Validate party account with company --- .../accounts/doctype/payment_entry/payment_entry.py | 2 +- .../sales_taxes_and_charges_template.py | 2 +- erpnext/accounts/party.py | 5 ++++- erpnext/controllers/accounts_controller.py | 12 +++++------- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 02a144d3e7..0d8f079d7a 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1077,7 +1077,7 @@ def get_outstanding_reference_documents(args): if d.voucher_type in ("Purchase Invoice"): d["bill_no"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "bill_no") - # Get all SO / PO which are not fully billed or aginst which full advance not paid + # Get all SO / PO which are not fully billed or against which full advance not paid orders_to_be_billed = [] if (args.get("party_type") != "Student"): orders_to_be_billed = get_orders_to_be_billed(args.get("posting_date"),args.get("party_type"), diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py index b5909447dc..1d30934df9 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py +++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py @@ -46,7 +46,7 @@ def valdiate_taxes_and_charges_template(doc): for tax in doc.get("taxes"): validate_taxes_and_charges(tax) - validate_account_head(tax, doc) + validate_account_head(tax.idx, tax.account_head, doc.company) validate_cost_center(tax, doc) validate_inclusive_tax(tax, doc) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index c13bc23c15..d6f6c5bcb6 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -307,7 +307,7 @@ def validate_party_gle_currency(party_type, party, company, party_account_curren .format(frappe.bold(party_type), frappe.bold(party), frappe.bold(existing_gle_currency), frappe.bold(company)), InvalidAccountCurrency) def validate_party_accounts(doc): - + from erpnext.controllers.accounts_controller import validate_account_head companies = [] for account in doc.get("accounts"): @@ -330,6 +330,9 @@ def validate_party_accounts(doc): if doc.default_currency != party_account_currency and doc.default_currency != company_default_currency: frappe.throw(_("Billing currency must be equal to either default company's currency or party account currency")) + # validate if account is mapped for same company + validate_account_head(account.idx, account.account, account.company) + @frappe.whitelist() def get_due_date(posting_date, party_type, party, company=None, bill_date=None): diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index d05787fdfb..7913a39329 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1566,13 +1566,12 @@ def validate_taxes_and_charges(tax): tax.rate = None -def validate_account_head(tax, doc): - company = frappe.get_cached_value('Account', - tax.account_head, 'company') +def validate_account_head(idx, account, company): + account_company = frappe.get_cached_value('Account', account, 'company') - if company != doc.company: + if account_company != company: frappe.throw(_('Row {0}: Account {1} does not belong to Company {2}') - .format(tax.idx, frappe.bold(tax.account_head), frappe.bold(doc.company)), title=_('Invalid Account')) + .format(idx, frappe.bold(account), frappe.bold(company)), title=_('Invalid Account')) def validate_cost_center(tax, doc): @@ -1955,8 +1954,7 @@ def update_bin_on_delete(row, doctype): qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) - if row.warehouse: - update_bin_qty(row.item_code, row.warehouse, qty_dict) + update_bin_qty(row.item_code, row.warehouse, qty_dict) def validate_and_delete_children(parent, data): deleted_children = [] From 1aa12fb3f1bee18a8a58d11954acd8112e96261d Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 19 Feb 2022 19:19:32 +0530 Subject: [PATCH 28/87] fix: Ledger entries on LIA for term loans --- .../loan_interest_accrual.py | 33 ------------------- 1 file changed, 33 deletions(-) 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 0de073f85d..1c800a06da 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 @@ -74,39 +74,6 @@ class LoanInterestAccrual(AccountsController): }) ) - if self.payable_principal_amount: - gle_map.append( - self.get_gl_dict({ - "account": self.loan_account, - "party_type": self.applicant_type, - "party": self.applicant, - "against": self.interest_income_account, - "debit": self.payable_principal_amount, - "debit_in_account_currency": self.interest_amount, - "against_voucher_type": "Loan", - "against_voucher": self.loan, - "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format( - self.last_accrual_date, self.posting_date, self.loan), - "cost_center": erpnext.get_default_cost_center(self.company), - "posting_date": self.posting_date - }) - ) - - gle_map.append( - self.get_gl_dict({ - "account": self.interest_income_account, - "against": self.loan_account, - "credit": self.payable_principal_amount, - "credit_in_account_currency": self.interest_amount, - "against_voucher_type": "Loan", - "against_voucher": self.loan, - "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format( - self.last_accrual_date, self.posting_date, self.loan), - "cost_center": erpnext.get_default_cost_center(self.company), - "posting_date": self.posting_date - }) - ) - if gle_map: make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj) From a28ec89507fd42bf100b6a64c6bcdeef55f4b032 Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Sat, 19 Feb 2022 19:35:57 +0530 Subject: [PATCH 29/87] Update gstr_1.js --- erpnext/regional/report/gstr_1/gstr_1.js | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/regional/report/gstr_1/gstr_1.js b/erpnext/regional/report/gstr_1/gstr_1.js index 1766fdb2ec..9999a6d167 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.js +++ b/erpnext/regional/report/gstr_1/gstr_1.js @@ -73,7 +73,6 @@ frappe.query_reports["GSTR-1"] = { company: filters.company }, callback: function(r) { - console.log(r.message); frappe.query_report.page.fields_dict.company_gstin.df.options = r.message; frappe.query_report.page.fields_dict.company_gstin.refresh(); } From d188fcc06698c32342873db8cec32884434c53bd Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Sat, 19 Feb 2022 19:36:43 +0530 Subject: [PATCH 30/87] chore: remove console statements From fa38c291bd577b40f0d5007470108596d392f89b Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 21 Feb 2022 11:38:16 +0530 Subject: [PATCH 31/87] fix(pos): removal of coupon code --- erpnext/selling/page/point_of_sale/pos_payment.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 9650bc88a4..4d75e6ef1b 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -180,14 +180,6 @@ erpnext.PointOfSale.Payment = class { () => frm.save(), () => this.update_totals_section(frm.doc) ]); - } else { - frappe.run_serially([ - () => frm.doc.ignore_pricing_rule=1, - () => frm.trigger('ignore_pricing_rule'), - () => frm.doc.ignore_pricing_rule=0, - () => frm.save(), - () => this.update_totals_section(frm.doc) - ]); } } }); From a0bdcbd0cd551895af63955343f517051917c8eb Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 21 Feb 2022 11:44:00 +0530 Subject: [PATCH 32/87] fix: Add patch for account fields --- erpnext/patches.txt | 1 + .../v13_0/update_accounts_in_loan_docs.py | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 erpnext/patches/v13_0/update_accounts_in_loan_docs.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d104bc003c..b24bf0a7e0 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -352,3 +352,4 @@ erpnext.patches.v13_0.shopping_cart_to_ecommerce erpnext.patches.v13_0.update_disbursement_account erpnext.patches.v13_0.update_reserved_qty_closed_wo erpnext.patches.v14_0.delete_amazon_mws_doctype +erpnext.patches.v13_0.update_accounts_in_loan_docs diff --git a/erpnext/patches/v13_0/update_accounts_in_loan_docs.py b/erpnext/patches/v13_0/update_accounts_in_loan_docs.py new file mode 100644 index 0000000000..440f912be2 --- /dev/null +++ b/erpnext/patches/v13_0/update_accounts_in_loan_docs.py @@ -0,0 +1,37 @@ +import frappe + + +def execute(): + ld = frappe.qb.DocType("Loan Disbursement").as_("ld") + lr = frappe.qb.DocType("Loan Repayment").as_("lr") + loan = frappe.qb.DocType("Loan") + + frappe.qb.update( + ld + ).inner_join( + loan + ).on( + loan.name == ld.against_loan + ).set( + ld.disbursement_account, loan.disbursement_account + ).set( + ld.loan_account, loan.loan_account + ).where( + ld.docstatus < 2 + ).run() + + frappe.qb.update( + lr + ).inner_join( + loan + ).on( + loan.name == lr.against_loan + ).set( + lr.payment_account, loan.payment_account + ).set( + lr.loan_account, loan.loan_account + ).set( + lr.penalty_income_account, loan.penalty_income_account + ).where( + lr.docstatus < 2 + ).run() From 295cbb0ff22b04c705148d727d96f70b836fee93 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 21 Feb 2022 11:45:23 +0530 Subject: [PATCH 33/87] fix: Update queries in Bank Reconciliation Tool --- .../bank_reconciliation_tool.py | 57 ++++++++++++++++--- .../bank_transaction/bank_transaction.py | 13 ++++- .../loan_repayment/loan_repayment.json | 6 +- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 26078d6329..f3351ddcba 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder.custom import ConstantColumn from frappe.utils import flt from erpnext import get_company_currency @@ -320,14 +321,34 @@ def get_loan_vouchers(bank_account, transaction, document_types, filters): amount_condition = True if "exact_match" in document_types else False if transaction.withdrawal > 0 and "loan_disbursement" in document_types: - vouchers.append(get_ld_matching_query(bank_account, amount_condition, filters)) + vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters)) if transaction.deposit > 0 and "loan_repayment" in document_types: - vouchers.append(get_lr_matching_query(bank_account, amount_condition, filters)) + vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters)) + + return vouchers def get_ld_matching_query(bank_account, amount_condition, filters): loan_disbursement = frappe.qb.DocType("Loan Disbursement") + matching_reference = loan_disbursement.reference_number == filters.get("reference_number") + matching_party = loan_disbursement.applicant_type == filters.get("party_type") and \ + loan_disbursement.applicant == filters.get("party") + + rank = ( + frappe.qb.terms.Case() + .when(matching_reference, 1) + .else_(0) + ) + + rank1 = ( + frappe.qb.terms.Case() + .when(matching_party, 1) + .else_(0) + ) + query = frappe.qb.from_(loan_disbursement).select( + rank + rank1 + 1, + ConstantColumn("Loan Disbursement").as_("doctype"), loan_disbursement.name, loan_disbursement.disbursed_amount, loan_disbursement.reference_number, @@ -351,14 +372,33 @@ def get_ld_matching_query(bank_account, amount_condition, filters): loan_disbursement.disbursed_amount <= filters.get('amount') ) - vouchers = query.run(as_dict=1) + vouchers = query.run(as_list=True) + return vouchers def get_lr_matching_query(bank_account, amount_condition, filters): loan_repayment = frappe.qb.DocType("Loan Repayment") + matching_reference = loan_repayment.reference_number == filters.get("reference_number") + matching_party = loan_repayment.applicant_type == filters.get("party_type") and \ + loan_repayment.applicant == filters.get("party") + + rank = ( + frappe.qb.terms.Case() + .when(matching_reference, 1) + .else_(0) + ) + + rank1 = ( + frappe.qb.terms.Case() + .when(matching_party, 1) + .else_(0) + ) + query = frappe.qb.from_(loan_repayment).select( + rank + rank1 + 1, + ConstantColumn("Loan Repayment").as_("doctype"), loan_repayment.name, - loan_repayment.paid_amount, + loan_repayment.amount_paid, loan_repayment.reference_number, loan_repayment.reference_date, loan_repayment.applicant_type, @@ -368,19 +408,20 @@ def get_lr_matching_query(bank_account, amount_condition, filters): ).where( loan_repayment.clearance_date.isnull() ).where( - loan_repayment.disbursement_account == bank_account + loan_repayment.payment_account == bank_account ) if amount_condition: query.where( - loan_repayment.paid_amount == filters.get('amount') + loan_repayment.amount_paid == filters.get('amount') ) else: query.where( - loan_repayment.paid_amount <= filters.get('amount') + loan_repayment.amount_paid <= filters.get('amount') ) - vouchers = query.run(as_dict=1) + vouchers = query.run() + return vouchers def get_pe_matching_query(amount_condition, account_from_to, transaction): diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 51e1d6e9a0..da944fa4ce 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -49,7 +49,8 @@ class BankTransaction(StatusUpdater): def clear_linked_payment_entries(self, for_cancel=False): for payment_entry in self.payment_entries: - if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim"]: + if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim", "Loan Repayment", + "Loan Disbursement"]: self.clear_simple_entry(payment_entry, for_cancel=for_cancel) elif payment_entry.payment_document == "Sales Invoice": @@ -104,6 +105,7 @@ def get_total_allocated_amount(payment_entry): bt.docstatus = 1""", (payment_entry.payment_document, payment_entry.payment_entry), as_dict=True) def get_paid_amount(payment_entry, currency, bank_account): + print(payment_entry.payment_document, "#@#@#@") if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]: paid_amount_field = "paid_amount" @@ -116,11 +118,18 @@ def get_paid_amount(payment_entry, currency, bank_account): payment_entry.payment_entry, paid_amount_field) elif payment_entry.payment_document == "Journal Entry": - return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, "sum(credit_in_account_currency)") + return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, + "sum(credit_in_account_currency)") elif payment_entry.payment_document == "Expense Claim": return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed") + elif payment_entry.payment_document == "Loan Disbursement": + return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "disbursed_amount") + + elif payment_entry.payment_document == "Loan Repayment": + return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "amount_paid") + else: frappe.throw("Please reconcile {0}: {1} manually".format(payment_entry.payment_document, payment_entry.payment_entry)) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json index 766602de86..480e010b49 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json @@ -40,7 +40,7 @@ "repayment_details", "amended_from", "accounting_details_section", - "repayment_account", + "payment_account", "penalty_income_account", "column_break_36", "loan_account" @@ -281,7 +281,7 @@ }, { "fetch_from": "against_loan.payment_account", - "fieldname": "repayment_account", + "fieldname": "payment_account", "fieldtype": "Link", "label": "Repayment Account", "options": "Account", @@ -311,7 +311,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-02-17 19:10:07.742298", + "modified": "2022-02-18 19:10:07.742298", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment", From 0b5e618e3ab206f7ae080f570a736a87fcbccf2d Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 21 Feb 2022 11:46:44 +0530 Subject: [PATCH 34/87] fix: Update bank reconciliation statement --- .../bank_reconciliation_statement.py | 105 ++++++++++++++++-- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py index 6c401fb8f3..b72d266977 100644 --- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py +++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py @@ -4,7 +4,12 @@ import frappe from frappe import _ -from frappe.utils import flt, getdate, nowdate +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Sum +from frappe.utils import flt, getdate +from pypika import CustomFunction + +from erpnext.accounts.utils import get_balance_on def execute(filters=None): @@ -18,7 +23,6 @@ def execute(filters=None): data = get_entries(filters) - from erpnext.accounts.utils import get_balance_on balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) total_debit, total_credit = 0,0 @@ -118,7 +122,21 @@ def get_columns(): ] def get_entries(filters): - journal_entries = frappe.db.sql(""" + journal_entries = get_journal_entries(filters) + + payment_entries = get_payment_entries(filters) + + loan_entries = get_loan_entries(filters) + + pos_entries = [] + if filters.include_pos_transactions: + pos_entries = get_pos_entries(filters) + + return sorted(list(payment_entries)+list(journal_entries+list(pos_entries) + list(loan_entries)), + key=lambda k: getdate(k['posting_date'])) + +def get_journal_entries(filters): + return frappe.db.sql(""" select "Journal Entry" as payment_document, jv.posting_date, jv.name as payment_entry, jvd.debit_in_account_currency as debit, jvd.credit_in_account_currency as credit, jvd.against_account, @@ -130,7 +148,8 @@ def get_entries(filters): and ifnull(jv.clearance_date, '4000-01-01') > %(report_date)s and ifnull(jv.is_opening, 'No') = 'No'""", filters, as_dict=1) - payment_entries = frappe.db.sql(""" +def get_payment_entries(filters): + return frappe.db.sql(""" select "Payment Entry" as payment_document, name as payment_entry, reference_no, reference_date as ref_date, @@ -145,9 +164,8 @@ def get_entries(filters): and ifnull(clearance_date, '4000-01-01') > %(report_date)s """, filters, as_dict=1) - pos_entries = [] - if filters.include_pos_transactions: - pos_entries = frappe.db.sql(""" +def get_pos_entries(filters): + return frappe.db.sql(""" select "Sales Invoice Payment" as payment_document, sip.name as payment_entry, sip.amount as debit, si.posting_date, si.debit_to as against_account, sip.clearance_date, @@ -161,8 +179,42 @@ def get_entries(filters): si.posting_date ASC, si.name DESC """, filters, as_dict=1) - return sorted(list(payment_entries)+list(journal_entries+list(pos_entries)), - key=lambda k: k['posting_date'] or getdate(nowdate())) +def get_loan_entries(filters): + loan_docs = [] + for doctype in ["Loan Disbursement", "Loan Repayment"]: + loan_doc = frappe.qb.DocType(doctype) + ifnull = CustomFunction('IFNULL', ['value', 'default']) + + if doctype == "Loan Disbursement": + amount_field = (loan_doc.disbursed_amount).as_("credit") + posting_date = (loan_doc.disbursement_date).as_("posting_date") + account = loan_doc.disbursement_account + else: + amount_field = (loan_doc.amount_paid).as_("debit") + posting_date = (loan_doc.posting_date).as_("posting_date") + account = loan_doc.payment_account + + entries = frappe.qb.from_(loan_doc).select( + ConstantColumn(doctype).as_("payment_document"), + (loan_doc.name).as_("payment_entry"), + (loan_doc.reference_number).as_("reference_no"), + (loan_doc.reference_date).as_("ref_date"), + amount_field, + posting_date, + ).where( + loan_doc.docstatus == 1 + ).where( + account == filters.get('account') + ).where( + posting_date <= getdate(filters.get('report_date')) + ).where( + ifnull(loan_doc.clearance_date, '4000-01-01') > getdate(filters.get('report_date')) + ).run(as_dict=1) + + loan_docs.extend(entries) + + return loan_docs + def get_amounts_not_reflected_in_system(filters): je_amount = frappe.db.sql(""" @@ -182,7 +234,40 @@ def get_amounts_not_reflected_in_system(filters): pe_amount = flt(pe_amount[0][0]) if pe_amount else 0.0 - return je_amount + pe_amount + loan_amount = get_loan_amount(filters) + + return je_amount + pe_amount + loan_amount + +def get_loan_amount(filters): + total_amount = 0 + for doctype in ["Loan Disbursement", "Loan Repayment"]: + loan_doc = frappe.qb.DocType(doctype) + ifnull = CustomFunction('IFNULL', ['value', 'default']) + + if doctype == "Loan Disbursement": + amount_field = Sum(loan_doc.disbursed_amount) + posting_date = (loan_doc.disbursement_date).as_("posting_date") + account = loan_doc.disbursement_account + else: + amount_field = Sum(loan_doc.amount_paid) + posting_date = (loan_doc.posting_date).as_("posting_date") + account = loan_doc.payment_account + + amount = frappe.qb.from_(loan_doc).select( + amount_field + ).where( + loan_doc.docstatus == 1 + ).where( + account == filters.get('account') + ).where( + posting_date > getdate(filters.get('report_date')) + ).where( + ifnull(loan_doc.clearance_date, '4000-01-01') <= getdate(filters.get('report_date')) + ).run()[0][0] + + total_amount += flt(amount) + + return amount def get_balance_row(label, amount, account_currency): if amount > 0: From c5808543c83ea43f62784331fb7c513543e454f0 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 21 Feb 2022 12:41:08 +0530 Subject: [PATCH 35/87] fix(asset): no. of depreciation booked cannot be equal to total no. of depreciations --- erpnext/assets/doctype/asset/asset.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 6e87426ccb..ea473fa7bb 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -417,11 +417,12 @@ class Asset(AccountsController): def validate_asset_finance_books(self, row): if flt(row.expected_value_after_useful_life) >= flt(self.gross_purchase_amount): frappe.throw(_("Row {0}: Expected Value After Useful Life must be less than Gross Purchase Amount") - .format(row.idx)) + .format(row.idx), title=_("Invalid Schedule")) if not row.depreciation_start_date: if not self.available_for_use_date: - frappe.throw(_("Row {0}: Depreciation Start Date is required").format(row.idx)) + frappe.throw(_("Row {0}: Depreciation Start Date is required") + .format(row.idx), title=_("Invalid Schedule")) row.depreciation_start_date = get_last_day(self.available_for_use_date) if not self.is_existing_asset: @@ -439,8 +440,9 @@ class Asset(AccountsController): else: self.number_of_depreciations_booked = 0 - if cint(self.number_of_depreciations_booked) > cint(row.total_number_of_depreciations): - frappe.throw(_("Number of Depreciations Booked cannot be greater than Total Number of Depreciations")) + if flt(row.total_number_of_depreciations) <= cint(self.number_of_depreciations_booked): + frappe.throw(_("Row {0}: Total Number of Depreciations cannot be less than or equal to Number of Depreciations Booked") + .format(row.idx), title=_("Invalid Schedule")) if row.depreciation_start_date and getdate(row.depreciation_start_date) < getdate(self.purchase_date): frappe.throw(_("Depreciation Row {0}: Next Depreciation Date cannot be before Purchase Date") From 780694f6e2d686ca7d037556a52e097802814266 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 21 Feb 2022 12:45:52 +0530 Subject: [PATCH 36/87] test: number_of_depr_booked = total_number_of_depr --- erpnext/assets/doctype/asset/test_asset.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index c08dc21a8f..ddbff89fc7 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -873,8 +873,9 @@ class TestDepreciationBasics(AssetSetup): self.assertRaises(frappe.ValidationError, asset.save) def test_number_of_depreciations(self): - """Tests if an error is raised when number_of_depreciations_booked > total_number_of_depreciations.""" + """Tests if an error is raised when number_of_depreciations_booked >= total_number_of_depreciations.""" + # number_of_depreciations_booked > total_number_of_depreciations asset = create_asset( item_code = "Macbook Pro", calculate_depreciation = 1, @@ -889,6 +890,21 @@ class TestDepreciationBasics(AssetSetup): self.assertRaises(frappe.ValidationError, asset.save) + # number_of_depreciations_booked = total_number_of_depreciations + asset_2 = create_asset( + item_code = "Macbook Pro", + calculate_depreciation = 1, + available_for_use_date = "2019-12-31", + total_number_of_depreciations = 5, + expected_value_after_useful_life = 10000, + depreciation_start_date = "2020-07-01", + opening_accumulated_depreciation = 10000, + number_of_depreciations_booked = 5, + do_not_save = 1 + ) + + self.assertRaises(frappe.ValidationError, asset_2.save) + def test_depreciation_start_date_is_before_purchase_date(self): asset = create_asset( item_code = "Macbook Pro", From a82cf7214e301a3f70513e308d1625a726a1beea Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 21 Feb 2022 13:58:56 +0530 Subject: [PATCH 37/87] fix: Total Credit amount in TDS Payable monthly report --- .../accounts/report/tds_payable_monthly/tds_payable_monthly.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index 57f79748f0..e6cbff5d42 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -43,7 +43,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map): if entry.account in tds_accounts: tds_deducted += (entry.credit - entry.debit) - total_amount_credited += (entry.credit - entry.debit) + total_amount_credited += entry.credit if tds_deducted: row = { From e952cce17d8931054575de2e430f6000ae80ef9f Mon Sep 17 00:00:00 2001 From: Marica Date: Mon, 21 Feb 2022 14:22:14 +0530 Subject: [PATCH 38/87] chore: Show 'Produced Qty' field in Sales Order Item (#29903) --- .../selling/doctype/sales_order_item/sales_order_item.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 95f6c4e96d..080d517d13 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -83,8 +83,8 @@ "planned_qty", "column_break_69", "work_order_qty", - "delivered_qty", "produced_qty", + "delivered_qty", "returned_qty", "shopping_cart_section", "additional_notes", @@ -701,10 +701,8 @@ "width": "50px" }, { - "description": "For Production", "fieldname": "produced_qty", "fieldtype": "Float", - "hidden": 1, "label": "Produced Quantity", "oldfieldname": "produced_qty", "oldfieldtype": "Currency", @@ -802,7 +800,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-10-05 12:27:25.014789", + "modified": "2022-02-21 13:55:08.883104", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", @@ -811,5 +809,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file From 1f9ce92011b4bfff27efeb8bf8542c9b716b5251 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 14:29:54 +0530 Subject: [PATCH 39/87] ci: moar backport labels [skip ci] --- .mergify.yml | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index f3d04096cf..b7d1df4524 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -14,9 +14,39 @@ pull_request_rules: close: comment: message: | - @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. + @{{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: backport to develop + conditions: + - label="backport develop" + actions: + backport: + branches: + - develop + assignees: + - "{{ author }}" + + - name: backport to version-14-hotfix + conditions: + - label="backport version-14-hotfix" + actions: + backport: + branches: + - version-14-hotfix + assignees: + - "{{ author }}" + + - name: backport to version-14-pre-release + conditions: + - label="backport version-14-pre-release" + actions: + backport: + branches: + - version-14-pre-release + assignees: + - "{{ author }}" + - name: backport to version-13-hotfix conditions: - label="backport version-13-hotfix" @@ -55,4 +85,4 @@ pull_request_rules: branches: - version-12-pre-release assignees: - - "{{ author }}" \ No newline at end of file + - "{{ author }}" From 3a5dbfab505866fb84d02ea61aecc7d4456fa251 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 10:55:55 +0530 Subject: [PATCH 40/87] fix: make cashflow mapping template child doctype --- .../cash_flow_mapping_template_details.json | 118 +++++------------- 1 file changed, 29 insertions(+), 89 deletions(-) diff --git a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json index 22cf797fc3..a2487c5543 100644 --- a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json +++ b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json @@ -1,94 +1,34 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:mapping", - "beta": 0, - "creation": "2018-02-08 10:18:48.513608", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2022-02-11 11:25:05.336846", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "mapping" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mapping", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Mapping", - "length": 0, - "no_copy": 0, - "options": "Cash Flow Mapping", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "mapping", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Mapping", + "options": "Cash Flow Mapping", + "reqd": 1, + "unique": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-02-08 10:33:39.413930", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Cash Flow Mapping Template Details", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2022-02-21 03:34:57.902332", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Cash Flow Mapping Template Details", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file From e3ea431ef39074b77e9089b19bac4bffc1a54e6e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 10:56:14 +0530 Subject: [PATCH 41/87] test: test all form loads --- erpnext/tests/test_zform_loads.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 erpnext/tests/test_zform_loads.py diff --git a/erpnext/tests/test_zform_loads.py b/erpnext/tests/test_zform_loads.py new file mode 100644 index 0000000000..8414acf7d8 --- /dev/null +++ b/erpnext/tests/test_zform_loads.py @@ -0,0 +1,29 @@ +""" dumb test to check all function calls on known form loads """ + +import unittest + +import frappe +from frappe.desk.form.load import getdoc + + +class TestFormLoads(unittest.TestCase): + + def test_load(self): + doctypes = frappe.get_all("DocType", {"istable": 0, "issingle": 0, "is_virtual": 0}, pluck="name") + + for doctype in doctypes: + last_doc = frappe.db.get_value(doctype, {}, "name", order_by="modified desc") + if not last_doc: + continue + with self.subTest(msg=f"Loading {doctype} - {last_doc}", doctype=doctype, last_doc=last_doc): + try: + # reset previous response + frappe.response = frappe._dict({"docs":[]}) + frappe.response.docinfo = None + + getdoc(doctype, last_doc) + except Exception as e: + self.fail(f"Failed to load {doctype} - {last_doc}: {e}") + + self.assertTrue(frappe.response.docs, msg=f"expected document in reponse, found: {frappe.response.docs}") + self.assertTrue(frappe.response.docinfo, msg=f"expected docinfo in reponse, found: {frappe.response.docinfo}") From afc81351b7daa2c245f9ac96a42c54c302da1e8f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 12:49:06 +0530 Subject: [PATCH 42/87] test: only test erpnext doctypes Co-authored-by: gavin --- .../cash_flow_mapping_template_details.json | 2 +- erpnext/tests/test_zform_loads.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json index a2487c5543..02c6875fb3 100644 --- a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json +++ b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json @@ -1,6 +1,6 @@ { "actions": [], - "creation": "2022-02-11 11:25:05.336846", + "creation": "2018-02-08 10:18:48.513608", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", diff --git a/erpnext/tests/test_zform_loads.py b/erpnext/tests/test_zform_loads.py index 8414acf7d8..b6fb636687 100644 --- a/erpnext/tests/test_zform_loads.py +++ b/erpnext/tests/test_zform_loads.py @@ -9,7 +9,8 @@ from frappe.desk.form.load import getdoc class TestFormLoads(unittest.TestCase): def test_load(self): - doctypes = frappe.get_all("DocType", {"istable": 0, "issingle": 0, "is_virtual": 0}, pluck="name") + erpnext_modules = frappe.get_all("Module Def", filters={"app_name": "erpnext"}, pluck="name") + doctypes = frappe.get_all("DocType", {"istable": 0, "issingle": 0, "is_virtual": 0, "module": ("in", erpnext_modules)}, pluck="name") for doctype in doctypes: last_doc = frappe.db.get_value(doctype, {}, "name", order_by="modified desc") From 28cc2dbb72fc3d716ffcb19b039dccd67c13eb33 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 21 Feb 2022 16:14:40 +0530 Subject: [PATCH 43/87] fix: Block merging items if both have product bundles --- erpnext/stock/doctype/item/item.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index b9e8b3f2f1..d984d6eb99 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -398,6 +398,7 @@ class Item(Document): if merge: self.validate_properties_before_merge(new_name) + self.validate_duplicate_product_bundles_before_merge(old_name, new_name) self.validate_duplicate_website_item_before_merge(old_name, new_name) def after_rename(self, old_name, new_name, merge): @@ -462,6 +463,18 @@ class Item(Document): msg += ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list]) frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) + def validate_duplicate_product_bundles_before_merge(self, old_name, new_name): + "Block merge if both old and new items have product bundles." + bundle = frappe.get_value("Product Bundle",filters={"new_item_code": old_name}) + if bundle: + bundle_link = get_link_to_form("Product Bundle", bundle) + old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) + + msg = _("Please delete Product Bundle {0}, before merging {1} into {2}").format( + bundle_link, old_name, new_name + ) + frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) + def validate_duplicate_website_item_before_merge(self, old_name, new_name): """ Block merge if both old and new items have website items against them. @@ -479,8 +492,9 @@ class Item(Document): old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0] web_item_link = get_link_to_form("Website Item", old_web_item) + old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) - msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} and {new_name}" + msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} into {new_name}" frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError) def set_last_purchase_rate(self, new_name): From 530f9f70291758d51babd7ec4f52eefe1a899ef1 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 21 Feb 2022 16:48:04 +0530 Subject: [PATCH 44/87] test: Item Merging with Product Bundles --- erpnext/stock/doctype/item/test_item.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index fd4df42187..6f5f1ff786 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -15,6 +15,7 @@ from erpnext.controllers.item_variant import ( get_variant, ) from erpnext.stock.doctype.item.item import ( + DataValidationError, InvalidBarcode, StockExistsForTemplate, get_item_attribute, @@ -388,6 +389,25 @@ class TestItem(ERPNextTestCase): self.assertTrue(frappe.db.get_value("Bin", {"item_code": "Test Item for Merging 2", "warehouse": "_Test Warehouse 1 - _TC"})) + def test_item_merging_with_product_bundle(self): + from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + + create_item("Test Item Bundle Item 1", is_stock_item=False) + create_item("Test Item Bundle Item 2", is_stock_item=False) + create_item("Test Item inside Bundle") + bundle_items = ["Test Item inside Bundle"] + + bundle1 = make_product_bundle("Test Item Bundle Item 1", bundle_items, qty=2) + make_product_bundle("Test Item Bundle Item 2", bundle_items, qty=2) + + with self.assertRaises(DataValidationError): + frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True) + + bundle1.delete() + frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True) + + self.assertFalse(frappe.db.exists("Item", "Test Item Bundle Item 1")) + def test_uom_conversion_factor(self): if frappe.db.exists('Item', 'Test Item UOM'): frappe.delete_doc('Item', 'Test Item UOM') From a4c6cb9f12f0ff931909a15b657b62a4bc85a20b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 21 Feb 2022 17:08:25 +0530 Subject: [PATCH 45/87] fix: Remove print statements --- erpnext/accounts/doctype/bank_transaction/bank_transaction.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index da944fa4ce..a476cab55f 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -105,7 +105,6 @@ def get_total_allocated_amount(payment_entry): bt.docstatus = 1""", (payment_entry.payment_document, payment_entry.payment_entry), as_dict=True) def get_paid_amount(payment_entry, currency, bank_account): - print(payment_entry.payment_document, "#@#@#@") if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]: paid_amount_field = "paid_amount" From 00e8565868e3bb8a1547abeedd2d158a9b7e5bf4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 17:41:23 +0530 Subject: [PATCH 46/87] fix: round off increments in numeric item variant --- erpnext/stock/doctype/item/item.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index dfc09181ca..ffea9c2d6e 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -594,7 +594,7 @@ $.extend(erpnext.item, { const increment = r.message.increment; let values = []; - for(var i = from; i <= to; i += increment) { + for(var i = from; i <= to; i = flt(i + increment, 6)) { values.push(i); } attr_val_fields[d.attribute] = values; From f4af75f60b7bb594df4f9a6e6d0cb1ad949dfa33 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Tue, 15 Feb 2022 11:51:52 +0530 Subject: [PATCH 47/87] feat: batchwise valuation flag This is required to avoid breaking behaviour in valuation of old batches --- erpnext/patches.txt | 1 + .../patches/v14_0/update_batch_valuation_flag.py | 12 ++++++++++++ erpnext/stock/doctype/batch/batch.json | 16 +++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v14_0/update_batch_valuation_flag.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index a93ceca437..52c29b22b9 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -353,3 +353,4 @@ erpnext.patches.v13_0.update_reserved_qty_closed_wo erpnext.patches.v13_0.update_exchange_rate_settings erpnext.patches.v14_0.delete_amazon_mws_doctype erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr +erpnext.patches.v14_0.update_batch_valuation_flag diff --git a/erpnext/patches/v14_0/update_batch_valuation_flag.py b/erpnext/patches/v14_0/update_batch_valuation_flag.py new file mode 100644 index 0000000000..d9f08d8d97 --- /dev/null +++ b/erpnext/patches/v14_0/update_batch_valuation_flag.py @@ -0,0 +1,12 @@ +import frappe + + +def execute(): + """ + - Don't use batchwise valuation for existing batches. + - Only batches created after this patch shoule use it. + """ + frappe.db.sql(""" + UPDATE `tabBatch` + SET use_batchwise_valuation=0 + """) diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index fc4cf1dbdb..0d28ea0919 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -9,6 +9,8 @@ "field_order": [ "sb_disabled", "disabled", + "column_break_24", + "use_batchwise_valuation", "sb_batch", "batch_id", "item", @@ -186,6 +188,18 @@ "fieldtype": "Float", "label": "Produced Qty", "read_only": 1 + }, + { + "fieldname": "column_break_24", + "fieldtype": "Column Break" + }, + { + "default": "1", + "fieldname": "use_batchwise_valuation", + "fieldtype": "Check", + "label": "Use Batch-wise Valuation", + "read_only": 1, + "set_only_once": 1 } ], "icon": "fa fa-archive", @@ -193,7 +207,7 @@ "image_field": "image", "links": [], "max_attachments": 5, - "modified": "2021-07-08 16:22:01.343105", + "modified": "2021-10-11 13:38:12.806976", "modified_by": "Administrator", "module": "Stock", "name": "Batch", From ce0514c8db17d59f2f84b3f6c263cd7e5877a049 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 15 Feb 2022 11:41:41 +0530 Subject: [PATCH 48/87] feat: batch wise valuation rates start with most used case: negative inventory isn't enabled - simple addition of qty and value when new batch qty is added - fetch outgoing rate from stock movement of specific batch --- erpnext/stock/doctype/batch/test_batch.py | 46 ++++++++++++++++++++ erpnext/stock/stock_ledger.py | 52 +++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 0a663c2a18..e7d04db454 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -7,6 +7,7 @@ from frappe.utils import cint, flt from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.get_item_details import get_item_details from erpnext.tests.utils import ERPNextTestCase @@ -300,6 +301,51 @@ class TestBatch(ERPNextTestCase): details = get_item_details(args) self.assertEqual(details.get('price_list_rate'), 400) + + def test_basic_batch_wise_valuation(self, batch_qty = 100): + item_code = "_TestBatchWiseVal" + warehouse = "_Test Warehouse - _TC" + self.make_batch_item(item_code) + + rates = [42, 420] + + batches = {} + for rate in rates: + se = make_stock_entry(item_code=item_code, qty=10, rate=rate, target=warehouse) + batches[se.items[0].batch_no] = rate + + LOW, HIGH = list(batches.keys()) + + # consume things out of order + consumption_plan = [ + (HIGH, 1), + (LOW, 2), + (HIGH, 2), + (HIGH, 4), + (LOW, 6), + ] + + stock_value = sum(rates) * 10 + qty_after_transaction = 20 + for batch, qty in consumption_plan: + # consume out of order + se = make_stock_entry(item_code=item_code, source=warehouse, qty=qty, batch_no=batch) + + sle = frappe.get_last_doc("Stock Ledger Entry", {"is_cancelled": 0, "voucher_no": se.name}) + + stock_value_difference = sle.actual_qty * batches[sle.batch_no] + self.assertAlmostEqual(sle.stock_value_difference, stock_value_difference) + + stock_value += stock_value_difference + self.assertAlmostEqual(sle.stock_value, stock_value) + + qty_after_transaction += sle.actual_qty + self.assertAlmostEqual(sle.qty_after_transaction, qty_after_transaction) + self.assertAlmostEqual(sle.valuation_rate, stock_value / qty_after_transaction) + + self.assertEqual(sle.stock_queue, []) # queues don't apply on batched items + + def create_batch(item_code, rate, create_item_price_for_batch): pi = make_purchase_invoice(company="_Test Company", warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1, diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 00ca81f2b4..c33cc12c2f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -447,6 +447,8 @@ class update_entries_after(object): self.wh_data.qty_after_transaction = sle.qty_after_transaction self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) + elif sle.batch_no and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True): + self.update_batched_values(sle) else: if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no: # assert @@ -481,6 +483,7 @@ class update_entries_after(object): if not self.args.get("sle_id"): self.update_outgoing_rate_on_transaction(sle) + def validate_negative_stock(self, sle): """ validate negative stock for entries current datetime onwards @@ -736,7 +739,22 @@ class update_entries_after(object): if not self.wh_data.stock_queue: self.wh_data.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.wh_data.valuation_rate]) + def update_batched_values(self, sle): + incoming_rate = flt(sle.incoming_rate) + actual_qty = flt(sle.actual_qty) + self.wh_data.qty_after_transaction += actual_qty + + if actual_qty > 0: + stock_value_difference = incoming_rate * actual_qty + self.wh_data.stock_value += stock_value_difference + else: + outgoing_rate = _get_batch_outgoing_rate(item_code=sle.item_code, warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation) + stock_value_difference = outgoing_rate * actual_qty + self.wh_data.stock_value += stock_value_difference + + if self.wh_data.qty_after_transaction: + self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction def check_if_allow_zero_valuation_rate(self, voucher_type, voucher_detail_no): ref_item_dt = "" @@ -897,6 +915,40 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): ['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'], as_dict=1) +def _get_batch_outgoing_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation): + + batch_details = frappe.db.sql(""" + select sum(stock_value_difference) as batch_value, sum(actual_qty) as batch_qty + from `tabStock Ledger Entry` + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and batch_no = %(batch_no)s + and is_cancelled = 0 + and ( + timestamp(posting_date, posting_time) < timestamp(%(posting_date)s, %(posting_time)s) + or ( + timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) + and creation < %(creation)s + ) + ) + """, + { + "item_code": item_code, + "warehouse": warehouse, + "batch_no": batch_no, + "posting_date": posting_date, + "posting_time": posting_time, + "creation": creation, + }, + as_dict=True + ) + + if batch_details and batch_details[0].batch_qty: + return batch_details[0].batch_value / batch_details[0].batch_qty + + + def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True): From 342d09a671c522031f73ba777950c70983cea31a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 14:28:51 +0530 Subject: [PATCH 49/87] feat: get_valuation_rate batch wise This function is used to show valuation rate on frontend and also as fallback in case values aren't available. Add "batch_no" param to get batch specific valuation rates. Co-Authored-By: Alan Tom <2.alan.tom@gmail.com> --- erpnext/controllers/buying_controller.py | 1 + .../controllers/sales_and_purchase_return.py | 1 + erpnext/controllers/selling_controller.py | 1 + erpnext/public/js/controllers/transaction.js | 1 + erpnext/stock/doctype/batch/test_batch.py | 39 +++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.js | 3 ++ .../stock/doctype/stock_entry/stock_entry.py | 3 +- erpnext/stock/stock_ledger.py | 43 +++++++++++++------ erpnext/stock/utils.py | 2 +- 9 files changed, 79 insertions(+), 15 deletions(-) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index a181af7313..b831557200 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -249,6 +249,7 @@ class BuyingController(StockController, Subcontracting): "posting_time": self.get('posting_time'), "qty": -1 * flt(d.get('stock_qty')), "serial_no": d.get('serial_no'), + "batch_no": d.get("batch_no"), "company": self.company, "voucher_type": self.doctype, "voucher_no": self.name, diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index df3c5f10c1..8c3aab442b 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -420,6 +420,7 @@ def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None "posting_time": sle.get('posting_time'), "qty": sle.actual_qty, "serial_no": sle.get('serial_no'), + "batch_no": sle.get("batch_no"), "company": sle.company, "voucher_type": sle.voucher_type, "voucher_no": sle.voucher_no diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 31b2209399..e918cde7c4 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -394,6 +394,7 @@ class SellingController(StockController): "posting_time": self.get('posting_time') or nowtime(), "qty": qty if cint(self.get("is_return")) else (-1 * qty), "serial_no": d.get('serial_no'), + "batch_no": d.get("batch_no"), "company": self.company, "voucher_type": self.doctype, "voucher_no": self.name, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 136e1edb6b..933ced0bd7 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -719,6 +719,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe 'posting_time': posting_time, 'qty': item.qty * item.conversion_factor, 'serial_no': item.serial_no, + 'batch_no': item.batch_no, 'voucher_type': voucher_type, 'company': company, 'allow_zero_valuation_rate': item.allow_zero_valuation_rate diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index e7d04db454..73a48b3f13 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -8,7 +8,11 @@ from frappe.utils import cint, flt from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, +) from erpnext.stock.get_item_details import get_item_details +from erpnext.stock.stock_ledger import get_valuation_rate from erpnext.tests.utils import ERPNextTestCase @@ -345,6 +349,41 @@ class TestBatch(ERPNextTestCase): self.assertEqual(sle.stock_queue, []) # queues don't apply on batched items + def test_moving_batch_valuation_rates(self): + item_code = "_TestBatchWiseVal" + warehouse = "_Test Warehouse - _TC" + self.make_batch_item(item_code) + + def assertValuation(expected): + actual = get_valuation_rate(item_code, warehouse, "voucher_type", "voucher_no", batch_no=batch_no) + self.assertAlmostEqual(actual, expected) + + se = make_stock_entry(item_code=item_code, qty=100, rate=10, target=warehouse) + batch_no = se.items[0].batch_no + assertValuation(10) + + # consumption should never affect current valuation rate + make_stock_entry(item_code=item_code, qty=20, source=warehouse) + assertValuation(10) + + make_stock_entry(item_code=item_code, qty=30, source=warehouse) + assertValuation(10) + + # 50 * 10 = 500 current value, add more item with higher valuation + make_stock_entry(item_code=item_code, qty=50, rate=20, target=warehouse, batch_no=batch_no) + assertValuation(15) + + # consuming again shouldn't do anything + make_stock_entry(item_code=item_code, qty=20, source=warehouse) + assertValuation(15) + + # reset rate with stock reconiliation + create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=10, rate=25, batch_no=batch_no) + assertValuation(25) + + make_stock_entry(item_code=item_code, qty=20, rate=20, target=warehouse, batch_no=batch_no) + assertValuation((20 * 20 + 10 * 25) / (10 + 20)) + def create_batch(item_code, rate, create_item_price_for_batch): pi = make_purchase_invoice(company="_Test Company", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index c4b8131305..5c9da3a205 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -425,6 +425,7 @@ frappe.ui.form.on('Stock Entry', { 'posting_time' : frm.doc.posting_time, 'warehouse' : cstr(item.s_warehouse) || cstr(item.t_warehouse), 'serial_no' : item.serial_no, + 'batch_no' : item.batch_no, 'company' : frm.doc.company, 'qty' : item.s_warehouse ? -1*flt(item.transfer_qty) : flt(item.transfer_qty), 'voucher_type' : frm.doc.doctype, @@ -457,6 +458,7 @@ frappe.ui.form.on('Stock Entry', { 'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse), 'transfer_qty': child.transfer_qty, 'serial_no': child.serial_no, + 'batch_no': child.batch_no, 'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty, 'posting_date': frm.doc.posting_date, 'posting_time': frm.doc.posting_time, @@ -680,6 +682,7 @@ frappe.ui.form.on('Stock Entry Detail', { 'warehouse' : cstr(d.s_warehouse) || cstr(d.t_warehouse), 'transfer_qty' : d.transfer_qty, 'serial_no' : d.serial_no, + 'batch_no' : d.batch_no, 'bom_no' : d.bom_no, 'expense_account' : d.expense_account, 'cost_center' : d.cost_center, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 9ba007a186..99cf4de5de 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -510,7 +510,7 @@ class StockEntry(StockController): d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse, self.doctype, self.name, d.allow_zero_valuation_rate, currency=erpnext.get_company_currency(self.company), company=self.company, - raise_error_if_no_rate=raise_error_if_no_rate) + raise_error_if_no_rate=raise_error_if_no_rate, batch_no=d.batch_no) d.basic_rate = flt(d.basic_rate, d.precision("basic_rate")) if d.is_process_loss: @@ -541,6 +541,7 @@ class StockEntry(StockController): "posting_time": self.posting_time, "qty": item.s_warehouse and -1*flt(item.transfer_qty) or flt(item.transfer_qty), "serial_no": item.serial_no, + "batch_no": item.batch_no, "voucher_type": self.doctype, "voucher_no": self.name, "company": self.company, diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index c33cc12c2f..53bfed8722 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -634,7 +634,7 @@ class update_entries_after(object): if not allow_zero_rate: self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company) + currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company @@ -702,7 +702,7 @@ class update_entries_after(object): if not allow_zero_valuation_rate: self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company) + currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) def update_queue_values(self, sle): incoming_rate = flt(sle.incoming_rate) @@ -722,7 +722,7 @@ class update_entries_after(object): if not allow_zero_valuation_rate: return get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company) + currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) else: return 0.0 @@ -950,21 +950,38 @@ def _get_batch_outgoing_rate(item_code, warehouse, batch_no, posting_date, posti def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, - allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True): + allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True, batch_no=None): if not company: company = frappe.get_cached_value("Warehouse", warehouse, "company") + last_valuation_rate = None + + # Get moving average rate of a specific batch number + if warehouse and batch_no and frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"): + last_valuation_rate = frappe.db.sql(""" + select sum(stock_value_difference) / sum(actual_qty) + from `tabStock Ledger Entry` + where + item_code = %s + AND warehouse = %s + AND batch_no = %s + AND is_cancelled = 0 + AND NOT (voucher_no = %s AND voucher_type = %s) + """, + (item_code, warehouse, batch_no, voucher_no, voucher_type)) + # Get valuation rate from last sle for the same item and warehouse - last_valuation_rate = frappe.db.sql("""select valuation_rate - from `tabStock Ledger Entry` force index (item_warehouse) - where - item_code = %s - AND warehouse = %s - AND valuation_rate >= 0 - AND is_cancelled = 0 - AND NOT (voucher_no = %s AND voucher_type = %s) - order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type)) + if not last_valuation_rate or last_valuation_rate[0][0] is None: + last_valuation_rate = frappe.db.sql("""select valuation_rate + from `tabStock Ledger Entry` force index (item_warehouse) + where + item_code = %s + AND warehouse = %s + AND valuation_rate >= 0 + AND is_cancelled = 0 + AND NOT (voucher_no = %s AND voucher_type = %s) + order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type)) if not last_valuation_rate: # Get valuation rate from last sle for the item against any warehouse diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 7263e39cc9..3be252e593 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -231,7 +231,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'), args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'), currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'), - raise_error_if_no_rate=raise_error_if_no_rate) + raise_error_if_no_rate=raise_error_if_no_rate, batch_no=args.get("batch_no")) return flt(in_rate) From ab926521bd0c9802666032cb3c32aa803655bde0 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 15:37:03 +0530 Subject: [PATCH 50/87] fix: correct incoming rate for batched items --- erpnext/stock/stock_ledger.py | 5 ++--- erpnext/stock/utils.py | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 53bfed8722..4748ad4e46 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -749,7 +749,7 @@ class update_entries_after(object): stock_value_difference = incoming_rate * actual_qty self.wh_data.stock_value += stock_value_difference else: - outgoing_rate = _get_batch_outgoing_rate(item_code=sle.item_code, warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation) + outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code, warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation) stock_value_difference = outgoing_rate * actual_qty self.wh_data.stock_value += stock_value_difference @@ -915,7 +915,7 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): ['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'], as_dict=1) -def _get_batch_outgoing_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation): +def get_batch_incoming_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation=None): batch_details = frappe.db.sql(""" select sum(stock_value_difference) as batch_value, sum(actual_qty) as batch_qty @@ -948,7 +948,6 @@ def _get_batch_outgoing_rate(item_code, warehouse, batch_no, posting_date, posti return batch_details[0].batch_value / batch_details[0].batch_qty - def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True, batch_no=None): diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 3be252e593..e2bd2f197d 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -209,13 +209,28 @@ def _create_bin(item_code, warehouse): @frappe.whitelist() def get_incoming_rate(args, raise_error_if_no_rate=True): """Get Incoming Rate based on valuation method""" - from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate + from erpnext.stock.stock_ledger import ( + get_batch_incoming_rate, + get_previous_sle, + get_valuation_rate, + ) if isinstance(args, str): args = json.loads(args) - in_rate = 0 + voucher_no = args.get('voucher_no') or args.get('name') + + in_rate = None if (args.get("serial_no") or "").strip(): in_rate = get_avg_purchase_rate(args.get("serial_no")) + elif args.get("batch_no") and \ + frappe.db.get_value("Batch", args.get("batch_no"), "use_batchwise_valuation", cache=True): + in_rate = get_batch_incoming_rate( + item_code=args.get('item_code'), + warehouse=args.get('warehouse'), + batch_no=args.get("batch_no"), + posting_date=args.get("posting_date"), + posting_time=args.get("posting_time"), + ) else: valuation_method = get_valuation_method(args.get("item_code")) previous_sle = get_previous_sle(args) @@ -226,8 +241,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): elif valuation_method == 'Moving Average': in_rate = previous_sle.get('valuation_rate') or 0 - if not in_rate: - voucher_no = args.get('voucher_no') or args.get('name') + if in_rate is None: in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'), args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'), currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'), From 102fff24c886b49d08776307d513d68ffd56e918 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 15:51:04 +0530 Subject: [PATCH 51/87] refactor: convert query to QB and make creation optional --- erpnext/stock/doctype/batch/test_batch.py | 4 +- erpnext/stock/stock_ledger.py | 53 ++++++++++++----------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 73a48b3f13..6495b56e92 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -1,6 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.exceptions import ValidationError from frappe.utils import cint, flt @@ -347,7 +349,7 @@ class TestBatch(ERPNextTestCase): self.assertAlmostEqual(sle.qty_after_transaction, qty_after_transaction) self.assertAlmostEqual(sle.valuation_rate, stock_value / qty_after_transaction) - self.assertEqual(sle.stock_queue, []) # queues don't apply on batched items + self.assertEqual(json.loads(sle.stock_queue), []) # queues don't apply on batched items def test_moving_batch_valuation_rates(self): item_code = "_TestBatchWiseVal" diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 4748ad4e46..cacec408ce 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -8,7 +8,9 @@ from typing import Optional import frappe from frappe import _ from frappe.model.meta import get_field_precision +from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate +from pypika import CustomFunction import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty @@ -24,7 +26,6 @@ class NegativeStockError(frappe.ValidationError): pass class SerialNoExistsInFutureTransaction(frappe.ValidationError): pass -_exceptions = frappe.local('stockledger_exceptions') def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): from erpnext.controllers.stock_controller import future_sle_exists @@ -917,32 +918,32 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): def get_batch_incoming_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation=None): - batch_details = frappe.db.sql(""" - select sum(stock_value_difference) as batch_value, sum(actual_qty) as batch_qty - from `tabStock Ledger Entry` - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and batch_no = %(batch_no)s - and is_cancelled = 0 - and ( - timestamp(posting_date, posting_time) < timestamp(%(posting_date)s, %(posting_time)s) - or ( - timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) - and creation < %(creation)s - ) + Timestamp = CustomFunction('timestamp', ['date', 'time']) + + sle = frappe.qb.DocType("Stock Ledger Entry") + + timestamp_condition = (Timestamp(sle.posting_date, sle.posting_time) < Timestamp(posting_date, posting_time)) + if creation: + timestamp_condition |= ( + (Timestamp(sle.posting_date, sle.posting_time) == Timestamp(posting_date, posting_time)) + & (sle.creation < creation) ) - """, - { - "item_code": item_code, - "warehouse": warehouse, - "batch_no": batch_no, - "posting_date": posting_date, - "posting_time": posting_time, - "creation": creation, - }, - as_dict=True - ) + + batch_details = ( + frappe.qb + .from_(sle) + .select( + Sum(sle.stock_value_difference).as_("batch_value"), + Sum(sle.actual_qty).as_("batch_qty") + ) + .where( + (sle.item_code == item_code) + & (sle.warehouse == warehouse) + & (sle.batch_no == batch_no) + & (sle.is_cancelled == 0) + ) + .where(timestamp_condition) + ).run(as_dict=True) if batch_details and batch_details[0].batch_qty: return batch_details[0].batch_value / batch_details[0].batch_qty From d130233ffc79d085b61bc1b63956d18c03de7a88 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 16:14:15 +0530 Subject: [PATCH 52/87] test: fix expected test failures --- .../stock_reconciliation/test_stock_reconciliation.py | 11 ++++++----- erpnext/stock/stock_ledger.py | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 86af0a0cf3..2ffe127d9a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -200,7 +200,6 @@ class TestStockReconciliation(ERPNextTestCase): def test_stock_reco_for_batch_item(self): to_delete_records = [] - to_delete_serial_nos = [] # Add new serial nos item_code = "Stock-Reco-batch-Item-1" @@ -208,20 +207,22 @@ class TestStockReconciliation(ERPNextTestCase): sr = create_stock_reconciliation(item_code=item_code, warehouse = warehouse, qty=5, rate=200, do_not_submit=1) - sr.save(ignore_permissions=True) + sr.save() sr.submit() - self.assertTrue(sr.items[0].batch_no) + batch_no = sr.items[0].batch_no + self.assertTrue(batch_no) to_delete_records.append(sr.name) sr1 = create_stock_reconciliation(item_code=item_code, - warehouse = warehouse, qty=6, rate=300, batch_no=sr.items[0].batch_no) + warehouse = warehouse, qty=6, rate=300, batch_no=batch_no) args = { "item_code": item_code, "warehouse": warehouse, "posting_date": nowdate(), "posting_time": nowtime(), + "batch_no": batch_no, } valuation_rate = get_incoming_rate(args) @@ -230,7 +231,7 @@ class TestStockReconciliation(ERPNextTestCase): sr2 = create_stock_reconciliation(item_code=item_code, - warehouse = warehouse, qty=0, rate=0, batch_no=sr.items[0].batch_no) + warehouse = warehouse, qty=0, rate=0, batch_no=batch_no) stock_value = get_stock_value_on(warehouse, nowdate(), item_code) self.assertEqual(stock_value, 0) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index cacec408ce..2dd26643f7 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -751,6 +751,7 @@ class update_entries_after(object): self.wh_data.stock_value += stock_value_difference else: outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code, warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation) + # TODO: negative stock handling stock_value_difference = outgoing_rate * actual_qty self.wh_data.stock_value += stock_value_difference From 312db429e4605d6d0ce47d1034662fdf0ec053b7 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 16:26:17 +0530 Subject: [PATCH 53/87] refactor: use qb for patching flag --- erpnext/patches/v14_0/update_batch_valuation_flag.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/erpnext/patches/v14_0/update_batch_valuation_flag.py b/erpnext/patches/v14_0/update_batch_valuation_flag.py index d9f08d8d97..55c8c48aa2 100644 --- a/erpnext/patches/v14_0/update_batch_valuation_flag.py +++ b/erpnext/patches/v14_0/update_batch_valuation_flag.py @@ -6,7 +6,6 @@ def execute(): - Don't use batchwise valuation for existing batches. - Only batches created after this patch shoule use it. """ - frappe.db.sql(""" - UPDATE `tabBatch` - SET use_batchwise_valuation=0 - """) + + batch = frappe.qb.DocType("Batch") + frappe.qb.update(batch).set(batch.use_batchwise_valuation, 0).run() From 683ef8a60397b728bd18e1a3c3c317e2f155793c Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Sat, 19 Feb 2022 16:19:30 +0530 Subject: [PATCH 54/87] test: more tests for batchwise valuation Co-Authored-By: Ankush Menat --- .../purchase_receipt/test_purchase_receipt.py | 1 + .../test_stock_ledger_entry.py | 278 ++++++++++++++++++ 2 files changed, 279 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 5ab7929a2a..d481689c13 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1540,6 +1540,7 @@ def make_purchase_receipt(**args): "conversion_factor": args.conversion_factor or 1.0, "stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0), "serial_no": args.serial_no, + "batch_no": args.batch_no, "stock_uom": args.stock_uom or "_Test UOM", "uom": uom, "cost_center": args.cost_center or frappe.get_cached_value('Company', pr.company, 'cost_center'), 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 a1030d5496..60fea9613a 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 @@ -1,6 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +import json +from operator import itemgetter +from uuid import uuid4 + import frappe from frappe.core.page.permission_manager.permission_manager import reset from frappe.utils import add_days, today @@ -349,6 +353,170 @@ class TestStockLedgerEntry(ERPNextTestCase): frappe.set_user("Administrator") user.remove_roles("Stock Manager") + def test_batchwise_item_valuation_moving_average(self): + suffix = get_unique_suffix() + item, warehouses, batches = setup_item_valuation_test( + valuation_method="Moving Average", suffix=suffix + ) + + # Incoming Entries for Stock Value check + pr_entry_list = [ + (item, warehouses[0], batches[0], 1, 100), + (item, warehouses[0], batches[1], 1, 50), + (item, warehouses[0], batches[0], 1, 150), + (item, warehouses[0], batches[1], 1, 100), + ] + prs = create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list) + sle_details = fetch_sle_details_for_doc_list(prs, ['stock_value']) + sv_list = [d['stock_value'] for d in sle_details] + expected_sv = [100, 150, 300, 400] + self.assertEqual(expected_sv, sv_list, "Incorrect 'Stock Value' values") + + # Outgoing Entries for Stock Value Difference check + dn_entry_list = [ + (item, warehouses[0], batches[1], 1, 200), + (item, warehouses[0], batches[0], 1, 200), + (item, warehouses[0], batches[1], 1, 200), + (item, warehouses[0], batches[0], 1, 200) + ] + dns = create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list) + sle_details = fetch_sle_details_for_doc_list(dns, ['stock_value_difference']) + svd_list = [-1 * d['stock_value_difference'] for d in sle_details] + expected_incoming_rates = expected_abs_svd = [75, 125, 75, 125] + + self.assertEqual(expected_abs_svd, svd_list, "Incorrect 'Stock Value Difference' values") + for dn, incoming_rate in zip(dns, expected_incoming_rates): + self.assertEqual( + dn.items[0].incoming_rate, incoming_rate, + "Incorrect 'Incoming Rate' values fetched for DN items" + ) + + + def assertSLEs(self, doc, expected_sles): + """ Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" + sles = frappe.get_all("Stock Ledger Entry", fields=["*"], + filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0}, + order_by="timestamp(posting_date, posting_time), creation") + + for exp_sle, act_sle in zip(expected_sles, sles): + for k, v in exp_sle.items(): + self.assertEqual(v, act_sle[k], msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}") + + def test_batchwise_item_valuation_stock_reco(self): + suffix = get_unique_suffix() + item, warehouses, batches = setup_item_valuation_test( + valuation_method="FIFO", suffix=suffix + ) + state = { + "stock_value" : 0.0, + "qty": 0.0 + } + def update_invariants(exp_sles): + for sle in exp_sles: + state["stock_value"] += sle["stock_value_difference"] + state["qty"] += sle["actual_qty"] + sle["stock_value"] = state["stock_value"] + sle["qty_after_transaction"] = state["qty"] + + osr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=10, rate=100, batch_no=batches[1]) + expected_sles = [ + {"actual_qty": 10, "stock_value_difference": 1000}, + ] + update_invariants(expected_sles) + self.assertSLEs(osr1, expected_sles) + + osr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=13, rate=200, batch_no=batches[0]) + expected_sles = [ + {"actual_qty": 13, "stock_value_difference": 200*13}, + ] + update_invariants(expected_sles) + self.assertSLEs(osr2, expected_sles) + + sr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=5, rate=50, batch_no=batches[1]) + + expected_sles = [ + {"actual_qty": -10, "stock_value_difference": -10 * 100}, + {"actual_qty": 5, "stock_value_difference": 250} + ] + update_invariants(expected_sles) + self.assertSLEs(sr1, expected_sles) + + sr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=20, rate=75, batch_no=batches[0]) + expected_sles = [ + {"actual_qty": -13, "stock_value_difference": -13 * 200}, + {"actual_qty": 20, "stock_value_difference": 20 * 75} + ] + update_invariants(expected_sles) + self.assertSLEs(sr2, expected_sles) + + def test_legacy_item_valuation_stock_entry(self): + suffix = get_unique_suffix() + columns = [ + 'stock_value_difference', + 'stock_value', + 'actual_qty', + 'qty_after_transaction', + 'stock_queue', + ] + item, warehouses, batches = setup_item_valuation_test( + valuation_method="FIFO", suffix=suffix, use_batchwise_valuation=0 + ) + + def check_sle_details_against_expected(sle_details, expected_sle_details, detail, columns): + for i, (sle_vals, ex_sle_vals) in enumerate(zip(sle_details, expected_sle_details)): + for col, sle_val, ex_sle_val in zip(columns, sle_vals, ex_sle_vals): + if col == 'stock_queue': + sle_val = get_stock_value_from_q(sle_val) + ex_sle_val = get_stock_value_from_q(ex_sle_val) + self.assertEqual( + sle_val, ex_sle_val, + f"Incorrect {col} value on transaction #: {i} in {detail}" + ) + + # List used to defer assertions to prevent commits cause of error skipped rollback + details_list = [] + + + # Test Material Receipt Entries + se_entry_list_mr = [ + (item, None, warehouses[0], batches[0], 1, 50, "2021-01-21"), + (item, None, warehouses[0], batches[1], 1, 100, "2021-01-23"), + ] + ses = create_stock_entry_entries_for_batchwise_item_valuation_test( + se_entry_list_mr, "Material Receipt" + ) + sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0) + expected_sle_details = [ + (50.0, 50.0, 1.0, 1.0, '[[1.0, 50.0]]'), + (100.0, 150.0, 1.0, 2.0, '[[1.0, 50.0], [1.0, 100.0]]'), + ] + details_list.append(( + sle_details, expected_sle_details, + "Material Receipt Entries", columns + )) + + + # Test Material Issue Entries + se_entry_list_mi = [ + (item, warehouses[0], None, batches[1], 1, None, "2021-01-29"), + ] + ses = create_stock_entry_entries_for_batchwise_item_valuation_test( + se_entry_list_mi, "Material Issue" + ) + sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0) + expected_sle_details = [ + (-50.0, 100.0, -1.0, 1.0, '[[1, 100.0]]') + ] + details_list.append(( + sle_details, expected_sle_details, + "Material Issue Entries", columns + )) + + + # Run assertions + for details in details_list: + check_sle_details_against_expected(*details) + def create_repack_entry(**args): args = frappe._dict(args) @@ -412,3 +580,113 @@ def create_items(): make_item(d, properties=properties) return items + +def setup_item_valuation_test(valuation_method, suffix, use_batchwise_valuation=1, batches_list=['X', 'Y']): + from erpnext.stock.doctype.batch.batch import make_batch + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + item = make_item( + f"IV - Test Item {valuation_method} {suffix}", + dict(valuation_method=valuation_method, has_batch_no=1) + ) + warehouses = [create_warehouse(f"IV - Test Warehouse {i}") for i in ['J', 'K']] + batches = [f"IV - Test Batch {i} {valuation_method} {suffix}" for i in batches_list] + + for i, batch_id in enumerate(batches): + if not frappe.db.exists("Batch", batch_id): + ubw = use_batchwise_valuation + if isinstance(use_batchwise_valuation, (list, tuple)): + ubw = use_batchwise_valuation[i] + make_batch( + frappe._dict( + batch_id=batch_id, + item=item.item_code, + use_batchwise_valuation=ubw + ) + ) + + return item.item_code, warehouses, batches + +def create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list): + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + prs = [] + + for item, warehouse, batch_no, qty, rate in pr_entry_list: + pr = make_purchase_receipt(item=item, warehouse=warehouse, qty=qty, rate=rate, batch_no=batch_no) + prs.append(pr) + + return prs + +def create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list): + from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + dns = [] + for item, warehouse, batch_no, qty, rate in dn_entry_list: + so = make_sales_order( + rate=rate, + qty=qty, + item=item, + warehouse=warehouse, + against_blanket_order=0 + ) + + dn = make_delivery_note(so.name) + dn.items[0].batch_no = batch_no + dn.insert() + dn.submit() + dns.append(dn) + return dns + +def fetch_sle_details_for_doc_list(doc_list, columns, as_dict=1): + return frappe.db.sql(f""" + SELECT { ', '.join(columns)} + FROM `tabStock Ledger Entry` + WHERE + voucher_no IN %(voucher_nos)s + and docstatus = 1 + ORDER BY timestamp(posting_date, posting_time) ASC, CREATION ASC + """, dict( + voucher_nos=[doc.name for doc in doc_list] + ), as_dict=as_dict) + +def get_stock_value_from_q(q): + return sum(r*q for r,q in json.loads(q)) + +def create_stock_entry_entries_for_batchwise_item_valuation_test(se_entry_list, purpose): + ses = [] + for item, source, target, batch, qty, rate, posting_date in se_entry_list: + args = dict( + item_code=item, + qty=qty, + company="_Test Company", + batch_no=batch, + posting_date=posting_date, + purpose=purpose + ) + + if purpose == "Material Receipt": + args.update( + dict(to_warehouse=target, rate=rate) + ) + + elif purpose == "Material Issue": + args.update( + dict(from_warehouse=source) + ) + + elif purpose == "Material Transfer": + args.update( + dict(from_warehouse=source, to_warehouse=target) + ) + + else: + raise ValueError(f"Invalid purpose: {purpose}") + ses.append(make_stock_entry(**args)) + + return ses + +def get_unique_suffix(): + # Used to isolate valuation sensitive + # tests to prevent future tests from failing. + return str(uuid4())[:8].upper() From 5718777a2b3018e07ea310e87e5a2ea26ff3eb1b Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 18:36:16 +0530 Subject: [PATCH 55/87] fix: consider batch_no when getting incoming rate --- erpnext/controllers/buying_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index b831557200..b740476481 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -279,7 +279,8 @@ class BuyingController(StockController, Subcontracting): "posting_date": self.posting_date, "posting_time": self.posting_time, "qty": -1 * d.consumed_qty, - "serial_no": d.serial_no + "serial_no": d.serial_no, + "batch_no": d.batch_no, }) if rate > 0: From 60b8bae85f00b6a6bf4a26c7604e28e0b075bb52 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 19:18:35 +0530 Subject: [PATCH 56/87] test: batch wise valuation for transfer and intermediate --- .../test_stock_ledger_entry.py | 99 ++++++++++++++++--- 1 file changed, 86 insertions(+), 13 deletions(-) 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 60fea9613a..c298b5a096 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 @@ -354,10 +354,7 @@ class TestStockLedgerEntry(ERPNextTestCase): user.remove_roles("Stock Manager") def test_batchwise_item_valuation_moving_average(self): - suffix = get_unique_suffix() - item, warehouses, batches = setup_item_valuation_test( - valuation_method="Moving Average", suffix=suffix - ) + item, warehouses, batches = setup_item_valuation_test(valuation_method="Moving Average") # Incoming Entries for Stock Value check pr_entry_list = [ @@ -403,10 +400,7 @@ class TestStockLedgerEntry(ERPNextTestCase): self.assertEqual(v, act_sle[k], msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}") def test_batchwise_item_valuation_stock_reco(self): - suffix = get_unique_suffix() - item, warehouses, batches = setup_item_valuation_test( - valuation_method="FIFO", suffix=suffix - ) + item, warehouses, batches = setup_item_valuation_test() state = { "stock_value" : 0.0, "qty": 0.0 @@ -449,8 +443,86 @@ class TestStockLedgerEntry(ERPNextTestCase): update_invariants(expected_sles) self.assertSLEs(sr2, expected_sles) + def test_batch_wise_valuation_across_warehouse(self): + item_code, warehouses, batches = setup_item_valuation_test() + source = warehouses[0] + target = warehouses[1] + + unrelated_batch = make_stock_entry(item_code=item_code, target=source, batch_no=batches[1], + qty=5, rate=10) + self.assertSLEs(unrelated_batch, [ + {"actual_qty": 5, "stock_value_difference": 10 * 5}, + ]) + + reciept = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0], qty=5, rate=10) + self.assertSLEs(reciept, [ + {"actual_qty": 5, "stock_value_difference": 10 * 5}, + ]) + + transfer = make_stock_entry(item_code=item_code, source=source, target=target, batch_no=batches[0], qty=5) + self.assertSLEs(transfer, [ + {"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source}, + {"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target} + ]) + + backdated_receipt = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0], + qty=5, rate=20, posting_date=add_days(today(), -1)) + self.assertSLEs(backdated_receipt, [ + {"actual_qty": 5, "stock_value_difference": 20 * 5}, + ]) + + # check reposted average rate in *future* transfer + self.assertSLEs(transfer, [ + {"actual_qty": -5, "stock_value_difference": -15 * 5, "warehouse": source, "stock_value": 15 * 5 + 10 * 5}, + {"actual_qty": 5, "stock_value_difference": 15 * 5, "warehouse": target, "stock_value": 15 * 5} + ]) + + transfer_unrelated = make_stock_entry(item_code=item_code, source=source, + target=target, batch_no=batches[1], qty=5) + self.assertSLEs(transfer_unrelated, [ + {"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source, "stock_value": 15 * 5}, + {"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target, "stock_value": 15 * 5 + 10 * 5} + ]) + + def test_intermediate_average_batch_wise_valuation(self): + """ A batch has moving average up until posting time, + check if same is respected when backdated entry is inserted in middle""" + item_code, warehouses, batches = setup_item_valuation_test() + warehouse = warehouses[0] + + batch = batches[0] + + yesterday = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batch, + qty=1, rate=10, posting_date=add_days(today(), -1)) + self.assertSLEs(yesterday, [ + {"actual_qty": 1, "stock_value_difference": 10}, + ]) + + tomorrow = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=1, rate=30, posting_date=add_days(today(), 1)) + self.assertSLEs(tomorrow, [ + {"actual_qty": 1, "stock_value_difference": 30}, + ]) + + create_today = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=1, rate=20) + self.assertSLEs(create_today, [ + {"actual_qty": 1, "stock_value_difference": 20}, + ]) + + consume_today = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0], + qty=1) + self.assertSLEs(consume_today, [ + {"actual_qty": -1, "stock_value_difference": -15}, + ]) + + consume_tomorrow = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0], + qty=2, posting_date=add_days(today(), 2)) + self.assertSLEs(consume_tomorrow, [ + {"stock_value_difference": -(30 + 15), "stock_value": 0, "qty_after_transaction": 0}, + ]) + def test_legacy_item_valuation_stock_entry(self): - suffix = get_unique_suffix() columns = [ 'stock_value_difference', 'stock_value', @@ -458,9 +530,7 @@ class TestStockLedgerEntry(ERPNextTestCase): 'qty_after_transaction', 'stock_queue', ] - item, warehouses, batches = setup_item_valuation_test( - valuation_method="FIFO", suffix=suffix, use_batchwise_valuation=0 - ) + item, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0) def check_sle_details_against_expected(sle_details, expected_sle_details, detail, columns): for i, (sle_vals, ex_sle_vals) in enumerate(zip(sle_details, expected_sle_details)): @@ -581,11 +651,14 @@ def create_items(): return items -def setup_item_valuation_test(valuation_method, suffix, use_batchwise_valuation=1, batches_list=['X', 'Y']): +def setup_item_valuation_test(valuation_method="FIFO", suffix=None, use_batchwise_valuation=1, batches_list=['X', 'Y']): from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + if not suffix: + suffix = get_unique_suffix() + item = make_item( f"IV - Test Item {valuation_method} {suffix}", dict(valuation_method=valuation_method, has_batch_no=1) From c5bd34d2383982e99db825cef1b5ec8215ccabee Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 19:21:12 +0530 Subject: [PATCH 57/87] test: multi-batch stock entry --- .../doctype/stock_entry/test_stock_entry.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 306f2c3e69..6c6513beff 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -1107,6 +1107,52 @@ class TestStockEntry(ERPNextTestCase): posting_date='2021-09-02', # backdated consumption of 2nd batch purpose='Material Issue') + def test_multi_batch_value_diff(self): + """ Test value difference on stock entry in case of multi-batch. + | Stock entry | batch | qty | rate | value diff on SE | + | --- | --- | --- | --- | --- | + | receipt | A | 1 | 10 | 30 | + | receipt | B | 1 | 20 | | + | issue | A | -1 | 10 | -30 (to assert after submit) | + | issue | B | -1 | 20 | | + """ + from erpnext.stock.doctype.batch.test_batch import TestBatch + + batch_nos = [] + + item_code = '_TestMultibatchFifo' + TestBatch.make_batch_item(item_code) + warehouse = '_Test Warehouse - _TC' + receipt = make_stock_entry( + item_code=item_code, + qty=1, + rate=10, + to_warehouse=warehouse, + purpose='Material Receipt', + do_not_save=True + ) + receipt.append("items", frappe.copy_doc(receipt.items[0], ignore_no_copy=False).update({"basic_rate": 20}) ) + receipt.save() + receipt.submit() + batch_nos.extend(row.batch_no for row in receipt.items) + self.assertEqual(receipt.value_difference, 30) + + issue = make_stock_entry( + item_code=item_code, + qty=1, + from_warehouse=warehouse, + purpose='Material Issue', + do_not_save=True + ) + issue.append("items", frappe.copy_doc(issue.items[0], ignore_no_copy=False)) + for row, batch_no in zip(issue.items, batch_nos): + row.batch_no = batch_no + issue.save() + issue.submit() + + issue.reload() # reload because reposting current voucher updates rate + self.assertEqual(issue.value_difference, -30) + def make_serialized_item(**args): args = frappe._dict(args) se = frappe.copy_doc(test_records[0]) From d7ca83ef0b42af42bca94e43c18c26cbf8e19ed3 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 19:35:33 +0530 Subject: [PATCH 58/87] refactor: code duplication for fallback rates --- erpnext/stock/stock_ledger.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 2dd26643f7..9339b3ea23 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -633,9 +633,7 @@ class update_entries_after(object): if not self.wh_data.valuation_rate and sle.voucher_detail_no: allow_zero_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_rate: - self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, - sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) + self.wh_data.valuation_rate = self.get_fallback_rate(sle) def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company @@ -701,9 +699,7 @@ class update_entries_after(object): if not self.wh_data.valuation_rate and sle.voucher_detail_no: allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_valuation_rate: - self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, - sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) + self.wh_data.valuation_rate = self.get_fallback_rate(sle) def update_queue_values(self, sle): incoming_rate = flt(sle.incoming_rate) @@ -721,9 +717,7 @@ class update_entries_after(object): def rate_generator() -> float: allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_valuation_rate: - return get_valuation_rate(sle.item_code, sle.warehouse, - sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) + return self.get_fallback_rate(sle) else: return 0.0 @@ -771,6 +765,13 @@ class update_entries_after(object): else: return 0 + def get_fallback_rate(self, sle) -> float: + """When exact incoming rate isn't available use any of other "average" rates as fallback. + This should only get used for negative stock.""" + return get_valuation_rate(sle.item_code, sle.warehouse, + sle.voucher_type, sle.voucher_no, self.allow_zero_rate, + currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) + def get_sle_before_datetime(self, args): """get previous stock ledger entry before current time-bucket""" sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False) From aba7a7ce4e4dc1fb264023db0034df5e906b5571 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 19:36:28 +0530 Subject: [PATCH 59/87] fix: handle negative inventory inside a batch --- erpnext/stock/stock_ledger.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 9339b3ea23..edbe755329 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -742,13 +742,17 @@ class update_entries_after(object): if actual_qty > 0: stock_value_difference = incoming_rate * actual_qty - self.wh_data.stock_value += stock_value_difference else: outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code, warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation) - # TODO: negative stock handling + if outgoing_rate is None: + # This can *only* happen if qty available for the batch is zero. + # in such case fall back various other rates. + # future entries will correct the overall accounting as each + # batch individually uses moving average rates. + outgoing_rate = self.get_fallback_rate(sle) stock_value_difference = outgoing_rate * actual_qty - self.wh_data.stock_value += stock_value_difference + self.wh_data.stock_value += stock_value_difference if self.wh_data.qty_after_transaction: self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction From b534fee2c7220390ed749d9ee87759663558a019 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 20:58:36 +0530 Subject: [PATCH 60/87] refactor: use queue difference instead of actual values --- erpnext/stock/stock_ledger.py | 19 ++++++++++++------- erpnext/stock/tests/test_valuation.py | 12 ++++++------ erpnext/stock/valuation.py | 12 ++++++------ 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index edbe755329..677266ee0c 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -19,7 +19,7 @@ from erpnext.stock.utils import ( get_or_make_bin, get_valuation_method, ) -from erpnext.stock.valuation import FIFOValuation, LIFOValuation +from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero class NegativeStockError(frappe.ValidationError): pass @@ -465,7 +465,6 @@ class update_entries_after(object): self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: self.update_queue_values(sle) - self.wh_data.qty_after_transaction += flt(sle.actual_qty) # rounding as per precision self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision) @@ -706,11 +705,15 @@ class update_entries_after(object): actual_qty = flt(sle.actual_qty) outgoing_rate = flt(sle.outgoing_rate) + self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty) + if self.valuation_method == "LIFO": stock_queue = LIFOValuation(self.wh_data.stock_queue) else: stock_queue = FIFOValuation(self.wh_data.stock_queue) + _prev_qty, prev_stock_value = stock_queue.get_total_stock_and_value() + if actual_qty > 0: stock_queue.add_stock(qty=actual_qty, rate=incoming_rate) else: @@ -723,17 +726,19 @@ class update_entries_after(object): stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) - stock_qty, stock_value = stock_queue.get_total_stock_and_value() + _qty, stock_value = stock_queue.get_total_stock_and_value() + + stock_value_difference = stock_value - prev_stock_value self.wh_data.stock_queue = stock_queue.state - self.wh_data.stock_value = stock_value - if stock_qty: - self.wh_data.valuation_rate = stock_value / stock_qty - + self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference) if not self.wh_data.stock_queue: self.wh_data.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.wh_data.valuation_rate]) + if self.wh_data.qty_after_transaction: + self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction + def update_batched_values(self, sle): incoming_rate = flt(sle.incoming_rate) actual_qty = flt(sle.actual_qty) diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py index 648d4406ca..bdb768f1ad 100644 --- a/erpnext/stock/tests/test_valuation.py +++ b/erpnext/stock/tests/test_valuation.py @@ -7,7 +7,7 @@ from hypothesis import strategies as st from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero +from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero from erpnext.tests.utils import ERPNextTestCase qty_gen = st.floats(min_value=-1e6, max_value=1e6) @@ -113,11 +113,11 @@ class TestFIFOValuation(unittest.TestCase): self.assertTotalQty(0) def test_rounding_off_near_zero(self): - self.assertEqual(_round_off_if_near_zero(0), 0) - self.assertEqual(_round_off_if_near_zero(1), 1) - self.assertEqual(_round_off_if_near_zero(-1), -1) - self.assertEqual(_round_off_if_near_zero(-1e-8), 0) - self.assertEqual(_round_off_if_near_zero(1e-8), 0) + self.assertEqual(round_off_if_near_zero(0), 0) + self.assertEqual(round_off_if_near_zero(1), 1) + self.assertEqual(round_off_if_near_zero(-1), -1) + self.assertEqual(round_off_if_near_zero(-1e-8), 0) + self.assertEqual(round_off_if_near_zero(1e-8), 0) def test_totals(self): self.queue.add_stock(1, 10) diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py index ee9477ed74..e2bd1ad4df 100644 --- a/erpnext/stock/valuation.py +++ b/erpnext/stock/valuation.py @@ -34,7 +34,7 @@ class BinWiseValuation(ABC): total_qty += flt(qty) total_value += flt(qty) * flt(rate) - return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) + return round_off_if_near_zero(total_qty), round_off_if_near_zero(total_value) def __repr__(self): return str(self.state) @@ -136,7 +136,7 @@ class FIFOValuation(BinWiseValuation): fifo_bin = self.queue[index] if qty >= fifo_bin[QTY]: # consume current bin - qty = _round_off_if_near_zero(qty - fifo_bin[QTY]) + qty = round_off_if_near_zero(qty - fifo_bin[QTY]) to_consume = self.queue.pop(index) consumed_bins.append(list(to_consume)) @@ -148,7 +148,7 @@ class FIFOValuation(BinWiseValuation): break else: # qty found in current bin consume it and exit - fifo_bin[QTY] = _round_off_if_near_zero(fifo_bin[QTY] - qty) + fifo_bin[QTY] = round_off_if_near_zero(fifo_bin[QTY] - qty) consumed_bins.append([qty, fifo_bin[RATE]]) qty = 0 @@ -231,7 +231,7 @@ class LIFOValuation(BinWiseValuation): stock_bin = self.stack[index] if qty >= stock_bin[QTY]: # consume current bin - qty = _round_off_if_near_zero(qty - stock_bin[QTY]) + qty = round_off_if_near_zero(qty - stock_bin[QTY]) to_consume = self.stack.pop(index) consumed_bins.append(list(to_consume)) @@ -243,14 +243,14 @@ class LIFOValuation(BinWiseValuation): break else: # qty found in current bin consume it and exit - stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty) + stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty) consumed_bins.append([qty, stock_bin[RATE]]) qty = 0 return consumed_bins -def _round_off_if_near_zero(number: float, precision: int = 7) -> float: +def round_off_if_near_zero(number: float, precision: int = 7) -> float: """Rounds off the number to zero only if number is close to zero for decimal specified in precision. Precision defaults to 7. """ From b1555fd477923a968a203c2fde68e754777a1e08 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 21:21:39 +0530 Subject: [PATCH 61/87] chore: batch flag and consumption rate in invariant report --- .../stock_ledger_invariant_check.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index cb35bf75d1..7826d34422 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -60,6 +60,9 @@ def add_invariant_check_fields(sles): fifo_qty += qty fifo_value += qty * rate + if sle.actual_qty < 0: + sle.consumption_rate = sle.stock_value_difference / sle.actual_qty + balance_qty += sle.actual_qty balance_stock_value += sle.stock_value_difference if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: @@ -90,6 +93,9 @@ def add_invariant_check_fields(sles): sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference + if sle.batch_no: + sle.use_batchwise_valuation = frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True) + return sles @@ -134,6 +140,11 @@ def get_columns(): "label": "Batch", "options": "Batch", }, + { + "fieldname": "use_batchwise_valuation", + "fieldtype": "Check", + "label": "Batchwise Valuation", + }, { "fieldname": "actual_qty", "fieldtype": "Float", @@ -145,9 +156,9 @@ def get_columns(): "label": "Incoming Rate", }, { - "fieldname": "outgoing_rate", + "fieldname": "consumption_rate", "fieldtype": "Float", - "label": "Outgoing Rate", + "label": "Consumption Rate", }, { "fieldname": "qty_after_transaction", From 76b395d62ee5f9ffb96e3c3e4920fa6eebaec175 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 22:01:34 +0530 Subject: [PATCH 62/87] test: old/new mix batches valuation consumption --- .../test_stock_ledger_entry.py | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) 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 c298b5a096..b0df45ffd4 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 @@ -397,7 +397,15 @@ class TestStockLedgerEntry(ERPNextTestCase): for exp_sle, act_sle in zip(expected_sles, sles): for k, v in exp_sle.items(): - self.assertEqual(v, act_sle[k], msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}") + act_value = act_sle[k] + if k == "stock_queue": + act_value = json.loads(act_value) + if act_value and act_value[0][0] == 0: + # ignore empty fifo bins + continue + + self.assertEqual(v, act_value, msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}") + def test_batchwise_item_valuation_stock_reco(self): item, warehouses, batches = setup_item_valuation_test() @@ -587,6 +595,77 @@ class TestStockLedgerEntry(ERPNextTestCase): for details in details_list: check_sle_details_against_expected(*details) + def test_mixed_valuation_batches(self): + item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0) + warehouse = warehouses[0] + + state = { + "qty": 0.0, + "stock_value": 0.0 + } + def update_invariants(exp_sles): + for sle in exp_sles: + state["stock_value"] += sle["stock_value_difference"] + state["qty"] += sle["actual_qty"] + sle["stock_value"] = state["stock_value"] + sle["qty_after_transaction"] = state["qty"] + return exp_sles + + old1 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=10, rate=10) + self.assertSLEs(old1, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*10, "stock_queue": [[10, 10]]}, + ])) + old2 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[1], + qty=10, rate=20) + self.assertSLEs(old2, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*20, "stock_queue": [[10, 10], [10, 20]]}, + ])) + old3 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=5, rate=15) + + self.assertSLEs(old3, update_invariants([ + {"actual_qty": 5, "stock_value_difference": 5*15, "stock_queue": [[10, 10], [10, 20], [5, 15]]}, + ])) + + new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40) + batches.append(new1.items[0].batch_no) + # assert old queue remains + self.assertSLEs(new1, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*40, "stock_queue": [[10, 10], [10, 20], [5, 15]]}, + ])) + + new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42) + batches.append(new2.items[0].batch_no) + self.assertSLEs(new2, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*42, "stock_queue": [[10, 10], [10, 20], [5, 15]]}, + ])) + + # consume old batch as per FIFO + consume_old1 = make_stock_entry(item_code=item_code, source=warehouse, qty=15, batch_no=batches[0]) + self.assertSLEs(consume_old1, update_invariants([ + {"actual_qty": -15, "stock_value_difference": -10*10 - 5*20, "stock_queue": [[5, 20], [5, 15]]}, + ])) + + # consume new batch as per batch + consume_new2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1]) + self.assertSLEs(consume_new2, update_invariants([ + {"actual_qty": -10, "stock_value_difference": -10*42, "stock_queue": [[5, 20], [5, 15]]}, + ])) + + # finish all old batches + consume_old2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[1]) + self.assertSLEs(consume_old2, update_invariants([ + {"actual_qty": -10, "stock_value_difference": -5*20 - 5*15, "stock_queue": []}, + ])) + + # finish all new batches + consume_new1 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2]) + self.assertSLEs(consume_new1, update_invariants([ + {"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []}, + ])) + + def create_repack_entry(**args): args = frappe._dict(args) @@ -661,7 +740,7 @@ def setup_item_valuation_test(valuation_method="FIFO", suffix=None, use_batchwis item = make_item( f"IV - Test Item {valuation_method} {suffix}", - dict(valuation_method=valuation_method, has_batch_no=1) + dict(valuation_method=valuation_method, has_batch_no=1, create_new_batch=1) ) warehouses = [create_warehouse(f"IV - Test Warehouse {i}") for i in ['J', 'K']] batches = [f"IV - Test Batch {i} {valuation_method} {suffix}" for i in batches_list] From 35483242b3864e09c635979afe7793aac7f12596 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 19 Feb 2022 22:22:27 +0530 Subject: [PATCH 63/87] fix: extend round_off_if_near_zero fix to other methods --- erpnext/stock/stock_ledger.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 677266ee0c..de6c409d7c 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -743,12 +743,14 @@ class update_entries_after(object): incoming_rate = flt(sle.incoming_rate) actual_qty = flt(sle.actual_qty) - self.wh_data.qty_after_transaction += actual_qty + self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty) if actual_qty > 0: stock_value_difference = incoming_rate * actual_qty else: - outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code, warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation) + outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code, + warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, + posting_time=sle.posting_time, creation=sle.creation) if outgoing_rate is None: # This can *only* happen if qty available for the batch is zero. # in such case fall back various other rates. @@ -757,7 +759,7 @@ class update_entries_after(object): outgoing_rate = self.get_fallback_rate(sle) stock_value_difference = outgoing_rate * actual_qty - self.wh_data.stock_value += stock_value_difference + self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference) if self.wh_data.qty_after_transaction: self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction From 609d2fccad2a1b60a1e7ffd93f504f0e1329136d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 20 Feb 2022 11:35:53 +0530 Subject: [PATCH 64/87] fix: reset stock value if no qty --- erpnext/stock/stock_ledger.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index de6c409d7c..1b90086440 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -468,6 +468,8 @@ class update_entries_after(object): # rounding as per precision self.wh_data.stock_value = flt(self.wh_data.stock_value, self.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 self.wh_data.prev_stock_value = self.wh_data.stock_value From 6b0bc350636776fbec3edc254086462a7670649c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 20 Feb 2022 12:05:58 +0530 Subject: [PATCH 65/87] test: mixed moving average items --- .../test_stock_ledger_entry.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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 b0df45ffd4..9e819dd658 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 @@ -595,7 +595,7 @@ class TestStockLedgerEntry(ERPNextTestCase): for details in details_list: check_sle_details_against_expected(*details) - def test_mixed_valuation_batches(self): + def test_mixed_valuation_batches_fifo(self): item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0) warehouse = warehouses[0] @@ -665,6 +665,34 @@ class TestStockLedgerEntry(ERPNextTestCase): {"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []}, ])) + def test_mixed_valuation_batches_moving_average(self): + item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0, valuation_method="Moving Average") + warehouse = warehouses[0] + + make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=10, rate=10) + make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[1], + qty=10, rate=20) + make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=5, rate=15) + + new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40) + batches.append(new1.items[0].batch_no) + new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42) + batches.append(new2.items[0].batch_no) + + # consume old batch as per FIFO + make_stock_entry(item_code=item_code, source=warehouse, qty=15, batch_no=batches[0]) + # consume new batch as per batch + make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1]) + # finish all old batches + make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[1]) + + # finish all new batches + consume_new1 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2]) + self.assertSLEs(consume_new1, ([ + {"stock_value": 0}, + ])) def create_repack_entry(**args): From f38690f7037c75bb1c5a5d946d686b40392a111a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 20 Feb 2022 12:58:53 +0530 Subject: [PATCH 66/87] fix: check if Moving average item can use batchwise valuation --- erpnext/stock/doctype/batch/batch.py | 32 ++++++++++++++++++++++++++++ erpnext/stock/utils.py | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 96751d6eae..b5e56ad301 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.naming import make_autoname, revert_series_if_last +from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, get_link_to_form from frappe.utils.data import add_days from frappe.utils.jinja import render_template @@ -110,11 +111,15 @@ class Batch(Document): def validate(self): self.item_has_batch_enabled() + self.set_batchwise_valuation() def item_has_batch_enabled(self): if frappe.db.get_value("Item", self.item, "has_batch_no") == 0: frappe.throw(_("The selected item cannot have Batch")) + def set_batchwise_valuation(self): + self.use_batchwise_valuation = int(can_use_batchwise_valuation(self.item)) + def before_save(self): has_expiry_date, shelf_life_in_days = frappe.db.get_value('Item', self.item, ['has_expiry_date', 'shelf_life_in_days']) if not self.expiry_date and has_expiry_date and shelf_life_in_days: @@ -338,3 +343,30 @@ def get_pos_reserved_batch_qty(filters): flt_reserved_batch_qty = flt(reserved_batch_qty[0][0]) return flt_reserved_batch_qty + +def can_use_batchwise_valuation(item_code: str) -> bool: + """ Check if item can use batchwise valuation. + + Note: Item with existing moving average batches can't use batchwise valuation + until they are exhausted. + """ + from erpnext.stock.stock_ledger import get_valuation_method + batch = frappe.qb.DocType("Batch") + + if get_valuation_method(item_code) != "Moving Average": + return True + + batch_qty = ( + frappe.qb + .from_(batch) + .select(Sum(batch.batch_qty)) + .where( + (batch.use_batchwise_valuation == 0) + & (batch.item == item_code) + ) + ).run() + + if batch_qty and batch_qty[0][0]: + return False + + return True diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index e2bd2f197d..f85a04f944 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -261,7 +261,7 @@ def get_valuation_method(item_code): """get valuation method from item or default""" val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True) if not val_method: - val_method = frappe.db.get_value("Stock Settings", None, "valuation_method") or "FIFO" + val_method = frappe.db.get_value("Stock Settings", None, "valuation_method", cache=True) or "FIFO" return val_method def get_fifo_rate(previous_stock_queue, qty): From 75fb5616987066b83b69455b4eb59d1a715b280e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 11:08:57 +0530 Subject: [PATCH 67/87] test: force correct flag in test data --- .../doctype/stock_ledger_entry/test_stock_ledger_entry.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 9e819dd658..c65ed2888e 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 @@ -778,13 +778,15 @@ def setup_item_valuation_test(valuation_method="FIFO", suffix=None, use_batchwis ubw = use_batchwise_valuation if isinstance(use_batchwise_valuation, (list, tuple)): ubw = use_batchwise_valuation[i] - make_batch( - frappe._dict( + batch = frappe.get_doc(frappe._dict( + doctype="Batch", batch_id=batch_id, item=item.item_code, use_batchwise_valuation=ubw ) - ) + ).insert() + batch.use_batchwise_valuation = ubw + batch.db_update() return item.item_code, warehouses, batches From af9fa049c749c9f72f0b21a5960111cb6ec57c12 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 12:28:19 +0530 Subject: [PATCH 68/87] fix: batchwise valuation can only be used by FIFO/LIFO --- erpnext/stock/doctype/batch/batch.py | 24 ++------------- .../test_stock_ledger_entry.py | 30 ------------------- 2 files changed, 2 insertions(+), 52 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index b5e56ad301..93e8d41367 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -6,7 +6,6 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.naming import make_autoname, revert_series_if_last -from frappe.query_builder.functions import Sum from frappe.utils import cint, flt, get_link_to_form from frappe.utils.data import add_days from frappe.utils.jinja import render_template @@ -347,26 +346,7 @@ def get_pos_reserved_batch_qty(filters): def can_use_batchwise_valuation(item_code: str) -> bool: """ Check if item can use batchwise valuation. - Note: Item with existing moving average batches can't use batchwise valuation - until they are exhausted. - """ + Note: Moving average valuation method can not use batch_wise_valuation.""" from erpnext.stock.stock_ledger import get_valuation_method - batch = frappe.qb.DocType("Batch") - if get_valuation_method(item_code) != "Moving Average": - return True - - batch_qty = ( - frappe.qb - .from_(batch) - .select(Sum(batch.batch_qty)) - .where( - (batch.use_batchwise_valuation == 0) - & (batch.item == item_code) - ) - ).run() - - if batch_qty and batch_qty[0][0]: - return False - - return True + return get_valuation_method(item_code) != "Moving Average" 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 c65ed2888e..0864ece995 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 @@ -665,36 +665,6 @@ class TestStockLedgerEntry(ERPNextTestCase): {"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []}, ])) - def test_mixed_valuation_batches_moving_average(self): - item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0, valuation_method="Moving Average") - warehouse = warehouses[0] - - make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], - qty=10, rate=10) - make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[1], - qty=10, rate=20) - make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], - qty=5, rate=15) - - new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40) - batches.append(new1.items[0].batch_no) - new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42) - batches.append(new2.items[0].batch_no) - - # consume old batch as per FIFO - make_stock_entry(item_code=item_code, source=warehouse, qty=15, batch_no=batches[0]) - # consume new batch as per batch - make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1]) - # finish all old batches - make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[1]) - - # finish all new batches - consume_new1 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2]) - self.assertSLEs(consume_new1, ([ - {"stock_value": 0}, - ])) - - def create_repack_entry(**args): args = frappe._dict(args) repack = frappe.new_doc("Stock Entry") From 9661058cc7daf9802e054f3fcd99c7852ff935a4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 21 Feb 2022 18:16:10 +0530 Subject: [PATCH 69/87] fix: only set batchwise valuation flag if new batch --- erpnext/stock/doctype/batch/batch.json | 6 ++++-- erpnext/stock/doctype/batch/batch.py | 13 ++++--------- erpnext/stock/doctype/batch/test_batch.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index 0d28ea0919..967c5729bf 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -194,7 +194,7 @@ "fieldtype": "Column Break" }, { - "default": "1", + "default": "0", "fieldname": "use_batchwise_valuation", "fieldtype": "Check", "label": "Use Batch-wise Valuation", @@ -207,10 +207,11 @@ "image_field": "image", "links": [], "max_attachments": 5, - "modified": "2021-10-11 13:38:12.806976", + "modified": "2022-02-21 08:08:23.999236", "modified_by": "Administrator", "module": "Stock", "name": "Batch", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -231,6 +232,7 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "batch_id", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 93e8d41367..c9b4c147f1 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -117,7 +117,10 @@ class Batch(Document): frappe.throw(_("The selected item cannot have Batch")) def set_batchwise_valuation(self): - self.use_batchwise_valuation = int(can_use_batchwise_valuation(self.item)) + from erpnext.stock.stock_ledger import get_valuation_method + + if self.is_new() and get_valuation_method(self.item) != "Moving Average": + self.use_batchwise_valuation = 1 def before_save(self): has_expiry_date, shelf_life_in_days = frappe.db.get_value('Item', self.item, ['has_expiry_date', 'shelf_life_in_days']) @@ -342,11 +345,3 @@ def get_pos_reserved_batch_qty(filters): flt_reserved_batch_qty = flt(reserved_batch_qty[0][0]) return flt_reserved_batch_qty - -def can_use_batchwise_valuation(item_code: str) -> bool: - """ Check if item can use batchwise valuation. - - Note: Moving average valuation method can not use batch_wise_valuation.""" - from erpnext.stock.stock_ledger import get_valuation_method - - return get_valuation_method(item_code) != "Moving Average" diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 6495b56e92..baa03024af 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -6,6 +6,7 @@ import json import frappe from frappe.exceptions import ValidationError from frappe.utils import cint, flt +from frappe.utils.data import add_to_date, getdate from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty @@ -387,6 +388,25 @@ class TestBatch(ERPNextTestCase): assertValuation((20 * 20 + 10 * 25) / (10 + 20)) + def test_update_batch_properties(self): + item_code = "_TestBatchWiseVal" + self.make_batch_item(item_code) + + se = make_stock_entry(item_code=item_code, qty=100, rate=10, target="_Test Warehouse - _TC") + batch_no = se.items[0].batch_no + batch = frappe.get_doc("Batch", batch_no) + + expiry_date = add_to_date(batch.manufacturing_date, days=30) + + batch.expiry_date = expiry_date + batch.save() + + batch.reload() + + self.assertEqual(getdate(batch.expiry_date), getdate(expiry_date)) + + + def create_batch(item_code, rate, create_item_price_for_batch): pi = make_purchase_invoice(company="_Test Company", warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1, From e4c4dc402e75d3ec501095fa3e914553fcd07a4d Mon Sep 17 00:00:00 2001 From: Sagar Sharma Date: Mon, 21 Feb 2022 19:49:19 +0530 Subject: [PATCH 70/87] fix: JobCard TimeLog to_date (#29872) --- erpnext/manufacturing/doctype/job_card/job_card.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 8d00019b7d..9f4ace296e 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -62,7 +62,7 @@ class JobCard(Document): if self.get('time_logs'): for d in self.get('time_logs'): - if get_datetime(d.from_time) > get_datetime(d.to_time): + if d.to_time and get_datetime(d.from_time) > get_datetime(d.to_time): frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx)) data = self.get_overlap_for(d) From 87b59fc96c7bb37fcfbce097bd7c8184fce967ba Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 21 Feb 2022 22:53:29 +0530 Subject: [PATCH 71/87] fix(LMS): program enrollment does not give any feedback (#29922) --- erpnext/www/lms/macros/hero.html | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/erpnext/www/lms/macros/hero.html b/erpnext/www/lms/macros/hero.html index e72bfc8175..95ba8f7df2 100644 --- a/erpnext/www/lms/macros/hero.html +++ b/erpnext/www/lms/macros/hero.html @@ -11,7 +11,7 @@ {% if frappe.session.user == 'Guest' %} {{_('Sign Up')}} {% elif not has_access %} - + {% endif %}

@@ -20,34 +20,35 @@ From 4738367d6407e9ffc22ba2c9ef1649573608be50 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 21 Feb 2022 22:54:46 +0530 Subject: [PATCH 72/87] fix: boarding task dates not set when activity begins on is set to 0 (#29921) --- .../employee_boarding_controller.py | 4 +-- .../test_employee_onboarding.py | 32 +++++++++++++------ .../doctype/salary_slip/test_salary_slip.py | 6 ++-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py index ae2c73758c..dd02ce1748 100644 --- a/erpnext/controllers/employee_boarding_controller.py +++ b/erpnext/controllers/employee_boarding_controller.py @@ -104,11 +104,11 @@ class EmployeeBoardingController(Document): def get_task_dates(self, activity, holiday_list): start_date = end_date = None - if activity.begin_on: + if activity.begin_on is not None: start_date = add_days(self.boarding_begins_on, activity.begin_on) start_date = self.update_if_holiday(start_date, holiday_list) - if activity.duration: + if activity.duration is not None: end_date = add_days(self.boarding_begins_on, activity.begin_on + activity.duration) end_date = self.update_if_holiday(end_date, holiday_list) diff --git a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py index 2d129c8acf..0fb821ddb2 100644 --- a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py +++ b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import getdate +from frappe.utils import add_days, getdate from erpnext.hr.doctype.employee_onboarding.employee_onboarding import ( IncompleteTaskError, @@ -35,6 +35,15 @@ class TestEmployeeOnboarding(unittest.TestCase): # boarding status self.assertEqual(onboarding.boarding_status, 'Pending') + # start and end dates + start_date, end_date = frappe.db.get_value('Task', onboarding.activities[0].task, ['exp_start_date', 'exp_end_date']) + self.assertEqual(getdate(start_date), getdate(onboarding.boarding_begins_on)) + self.assertEqual(getdate(end_date), add_days(start_date, onboarding.activities[0].duration)) + + start_date, end_date = frappe.db.get_value('Task', onboarding.activities[1].task, ['exp_start_date', 'exp_end_date']) + self.assertEqual(getdate(start_date), add_days(onboarding.boarding_begins_on, onboarding.activities[0].duration)) + self.assertEqual(getdate(end_date), add_days(start_date, onboarding.activities[1].duration)) + # complete the task project = frappe.get_doc('Project', onboarding.project) for task in frappe.get_all('Task', dict(project=project.name)): @@ -57,10 +66,7 @@ class TestEmployeeOnboarding(unittest.TestCase): self.assertEqual(employee.employee_name, 'Test Researcher') def tearDown(self): - for entry in frappe.get_all('Employee Onboarding'): - doc = frappe.get_doc('Employee Onboarding', entry.name) - doc.cancel() - doc.delete() + frappe.db.rollback() def get_job_applicant(): @@ -87,23 +93,31 @@ def get_job_offer(applicant_name): def create_employee_onboarding(): applicant = get_job_applicant() job_offer = get_job_offer(applicant.name) - holiday_list = make_holiday_list() + + holiday_list = make_holiday_list('_Test Employee Boarding') + holiday_list = frappe.get_doc('Holiday List', holiday_list) + holiday_list.holidays = [] + holiday_list.save() onboarding = frappe.new_doc('Employee Onboarding') onboarding.job_applicant = applicant.name onboarding.job_offer = job_offer.name onboarding.date_of_joining = onboarding.boarding_begins_on = getdate() onboarding.company = '_Test Company' - onboarding.holiday_list = holiday_list + onboarding.holiday_list = holiday_list.name onboarding.designation = 'Researcher' onboarding.append('activities', { 'activity_name': 'Assign ID Card', 'role': 'HR User', - 'required_for_employee_creation': 1 + 'required_for_employee_creation': 1, + 'begin_on': 0, + 'duration': 1 }) onboarding.append('activities', { 'activity_name': 'Assign a laptop', - 'role': 'HR User' + 'role': 'HR User', + 'begin_on': 1, + 'duration': 1 }) onboarding.status = 'Pending' onboarding.insert() diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index daa0f8952b..6a5debf998 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -1019,13 +1019,13 @@ def setup_test(): frappe.db.set_value('HR Settings', None, 'leave_status_notification_template', None) frappe.db.set_value('HR Settings', None, 'leave_approval_notification_template', None) -def make_holiday_list(): +def make_holiday_list(holiday_list_name=None): fiscal_year = get_fiscal_year(nowdate(), company=erpnext.get_default_company()) - holiday_list = frappe.db.exists("Holiday List", "Salary Slip Test Holiday List") + holiday_list = frappe.db.exists("Holiday List", holiday_list_name or "Salary Slip Test Holiday List") if not holiday_list: holiday_list = frappe.get_doc({ "doctype": "Holiday List", - "holiday_list_name": "Salary Slip Test Holiday List", + "holiday_list_name": holiday_list_name or "Salary Slip Test Holiday List", "from_date": fiscal_year[1], "to_date": fiscal_year[2], "weekly_off": "Sunday" From d011a3f82c5cf9c1dc4fe0561194d47cff6099d0 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 22 Feb 2022 11:41:09 +0530 Subject: [PATCH 73/87] fix(Salary Slip): TypeError while clearing any amount field in components (#29931) --- erpnext/payroll/doctype/salary_slip/salary_slip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index f727ff4378..d2a39989a6 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -1268,7 +1268,7 @@ class SalarySlip(TransactionBase): for i, earning in enumerate(self.earnings): if earning.salary_component == salary_component: self.earnings[i].amount = wages_amount - self.gross_pay += self.earnings[i].amount + self.gross_pay += flt(self.earnings[i].amount, earning.precision("amount")) self.net_pay = flt(self.gross_pay) - flt(self.total_deduction) def compute_year_to_date(self): From 235fc127b3ecf943176ed9c208425f9bda100798 Mon Sep 17 00:00:00 2001 From: Marica Date: Tue, 22 Feb 2022 12:53:46 +0530 Subject: [PATCH 74/87] fix: Fetch conversion factor even if it already existed in row, on item change (#29917) * fix: Fetch conversion factor even if it already existed in row, on item change * fix: Retain manually changed conversion factor - If item code changes, reset conversion factor on client side - Keep API behavious consistent, if conversion factor is sent, same must come back - API should not ideally reset values in most cases --- erpnext/public/js/controllers/transaction.js | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 933ced0bd7..ae8c0c8c6d 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -525,6 +525,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe item.weight_per_unit = 0; item.weight_uom = ''; + item.conversion_factor = 0; if(['Sales Invoice'].includes(this.frm.doc.doctype)) { update_stock = cint(me.frm.doc.update_stock); From 7f55226a5807645db4f93c8038f1cc03a6fc0ce6 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 22 Feb 2022 16:55:43 +0530 Subject: [PATCH 75/87] fix: remove customer field value when MR is not customer provided (#29938) --- .../stock/doctype/material_request/material_request.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index b39328f85b..51209acb27 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -56,14 +56,13 @@ class MaterialRequest(BuyingController): if actual_so_qty and (flt(so_items[so_no][item]) + already_indented > actual_so_qty): frappe.throw(_("Material Request of maximum {0} can be made for Item {1} against Sales Order {2}").format(actual_so_qty - already_indented, item, so_no)) - # Validate - # --------------------- def validate(self): super(MaterialRequest, self).validate() self.validate_schedule_date() self.check_for_on_hold_or_closed_status('Sales Order', 'sales_order') self.validate_uom_is_integer("uom", "qty") + self.validate_material_request_type() if not self.status: self.status = "Draft" @@ -83,6 +82,12 @@ class MaterialRequest(BuyingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") + def validate_material_request_type(self): + """ Validate fields in accordance with selected type """ + + if self.material_request_type != "Customer Provided": + self.customer = None + def set_title(self): '''Set title as comma separated list of items''' if not self.title: From 745f7bc5f0fd014dcc837c41e2058be91166e1b4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 22 Feb 2022 17:03:11 +0530 Subject: [PATCH 76/87] docs: add human readable specifications for stock ledger (#29308) * docs: add human readable specifications for stock ledger * docs: reposting technical implementation notes --- erpnext/stock/spec/README.md | 103 ++++++++++++++++++++++++++++++++ erpnext/stock/spec/reposting.md | 38 ++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 erpnext/stock/spec/README.md create mode 100644 erpnext/stock/spec/reposting.md diff --git a/erpnext/stock/spec/README.md b/erpnext/stock/spec/README.md new file mode 100644 index 0000000000..f5a3501fe4 --- /dev/null +++ b/erpnext/stock/spec/README.md @@ -0,0 +1,103 @@ +# Implementation notes for Stock Ledger + + +## Important files + +- `stock/stock_ledger.py` +- `controllers/stock_controller.py` +- `stock/valuation.py` + +## What is in an Stock Ledger Entry (SLE)? + +Stock Ledger Entry is a single row in the Stock Ledger. It signifies some +modification of stock for a particular Item in the specified warehouse. + +- `item_code`: item for which ledger entry is made +- `warehouse`: warehouse where inventory is affected +- `actual_qty`: change in qty +- `qty_after_transaction`: quantity available after the transaction is processed +- `incoming_rate`: rate at which inventory was received. +- `is_cancelled`: if 1 then stock ledger entry is cancelled and should not be used +for any business logic except for the code that handles cancellation. +- `posting_date` & `posting_time`: Specify the temporal ordering of stock ledger + entries. Ties are broken by `creation` timestamp. +- `voucher_type`: Many transaction can create SLE, e.g. Stock Entry, Purchase + Invoice +- `voucher_no`: `name` of the transaction that created SLE +- `voucher_detail_no`: `name` of the child table row from parent transaction + that created the SLE. +- `dependant_sle_voucher_detail_no`: cross-warehouse transfers need this + reference in order to update dependent warehouse rates in case of change in + rate. +- `recalculate_rate`: if this is checked in/out rates are recomputed on + transactions. +- `valuation_rate`: current average valuation rate. +- `stock_value`: current total stock value +- `stock_value_difference`: stock value difference made between last and current + entry. This value is booked in accounting ledger. +- `stock_queue`: if FIFO/LIFO is used this represents queue/stack maintained for + computing incoming rate for inventory getting consumed. +- `batch_no`: batch no for which stock entry is made; each stock entry can only + affect one batch number. +- `serial_no`: newline separated list of serial numbers that were added (if + actual_qty > 0) or else removed. Currently multiple serial nos can have single + SLE but this will likely change in future. + + +## Implementation of Stock Ledger + +Stock Ledger Entry affects stock of combinations of (item_code, warehouse) and +optionally batch no if specified. For simplicity, lets avoid batch no. for now. + + +Stock Ledger Entry table stores stock ledger for all combinations of item_code +and warehouse. So whenever any operations are to be performed on said +item-warehouse combination stock ledger is filtered and sorted by posting +datetime. A typical query that will give you individual ledger looks like this: + +```sql +select * +from `tabStock Ledger Entry` as sle +where + is_cancelled = 0 --- cancelled entries don't affect ledger + and item_code = 'item_code' and warehouse = 'warehouse_name' +order by timestamp(posting_date, posting_time), creation +``` + +New entry is just an update to the last entry which is found by looking at last +row in the filter ledger. + + +### Serial nos + +Serial numbers do not follow any valuation method configuration and they are +consumed at rate they were produced unless they are grouped in which case they +are consumed at weighted average rate. + + +### Batch Nos + +Batches are currently NOT consumed as per batch wise valuation rate, instead +global FIFO queue for the item is used for valuation rate. + + +## Creation process of SLEs + +- SLE creation is usually triggered by Stock Transactions using a method + conventionally named `update_stock_ledger()` This might not be defined for + stock transaction and could be specified somewhere in inheritance hierarchy of + controllers. +- This method produces SLE objects which are processed by `make_sl_entries` in + `stock_ledger.py` which commits the SLE to database. +- `update_entries_after` class is used to process ONLY the inserted SLE's queue + and valuation. +- The change in qty is propagated to future entries immediately. Valuation and + queue for future entries is processed in background using repost item + valuation. + + +## Accounting impact + +- Accounting impact for stock transaction is handled by `get_gl_entries()` + method on controllers. Each transaction has different business logic for + booking the accounting impact. diff --git a/erpnext/stock/spec/reposting.md b/erpnext/stock/spec/reposting.md new file mode 100644 index 0000000000..b0d59fe9bb --- /dev/null +++ b/erpnext/stock/spec/reposting.md @@ -0,0 +1,38 @@ +# Stock Reposting + +Stock "reposting" is process of re-processing Stock Ledger Entry and GL Entries +in event of backdated stock transaction. + +*Backdated stock transaction*: Any stock transaction for which some +item-warehouse combination has a future transactions. + +## Why is this required? +Stock Ledger is stateful, it maintains queue, qty at any +point in time. So if you do a backdated transaction all future values change, +queues need to be re-evaluated etc. Watch Nabin and Rohit's conference +presentation for explanation: https://www.youtube.com/watch?v=mw3WAnekGIM + +## How is this implemented? +Whenever backdated transaction is detected, instead of +fully processing it while submitting, the processing is queued using "Repost +Item Valuation" doctype. Every hour a scheduled job runs and processes this +queue (for up to maximum of 25 minutes) + + +## Queue implementation +- "Repost item valuation" (RIV) is automatically submitted from backdated transactions. (check stock_controller.py) +- Draft and cancelled RIV are ignored. +- Keep filter of "submitted" documents when doing anything with RIVs. +- The default status is "Queued". +- When background job runs, it picks the oldest pending reposts and changes the status to "In Progress" and when it finishes it +changes to "Completed" +- There are two more status: "Failed" when reposting failed and "Skipped" when reposting is deemed not necessary so it's skipped. +- technical detail: Entry point for whole process is "repost_entries" function in repost_item_valuation.py + + +## How to identify broken stock data: +There are 4 major reports for checking broken stock data: +- Incorrect balance qty after the transaction - to check if the running total of qty isn't correct. +- Incorrect stock value report - to check incorrect value books in accounts for stock transactions +- Incorrect serial no valuation -specific to serial nos +- Stock ledger invariant check - combined report for checking qty, running total, queue, balance value etc From 1682a26fe69b9b3fa64293e692e79a553b842ca2 Mon Sep 17 00:00:00 2001 From: Subin Tom Date: Tue, 22 Feb 2022 17:20:48 +0530 Subject: [PATCH 77/87] fix: Taxjar minor fixes --- .../taxjar_integration.py | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/erpnext/erpnext_integrations/taxjar_integration.py b/erpnext/erpnext_integrations/taxjar_integration.py index a4e21579e3..14c86d5632 100644 --- a/erpnext/erpnext_integrations/taxjar_integration.py +++ b/erpnext/erpnext_integrations/taxjar_integration.py @@ -8,10 +8,6 @@ from frappe.utils import cint, flt from erpnext import get_default_company, get_region -TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") -SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head") -TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") -TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax") SUPPORTED_COUNTRY_CODES = ["AT", "AU", "BE", "BG", "CA", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GB", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK", "US"] @@ -35,12 +31,14 @@ def get_client(): if api_key and api_url: client = taxjar.Client(api_key=api_key, api_url=api_url) client.set_api_config('headers', { - 'x-api-version': '2020-08-07' + 'x-api-version': '2022-01-24' }) return client def create_transaction(doc, method): + TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") + """Create an order transaction in TaxJar""" if not TAXJAR_CREATE_TRANSACTIONS: @@ -51,6 +49,7 @@ def create_transaction(doc, method): if not client: return + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") sales_tax = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == TAX_ACCOUNT_HEAD]) if not sales_tax: @@ -79,6 +78,7 @@ def create_transaction(doc, method): def delete_transaction(doc, method): """Delete an existing TaxJar order transaction""" + TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") if not TAXJAR_CREATE_TRANSACTIONS: return @@ -92,6 +92,8 @@ def delete_transaction(doc, method): def get_tax_data(doc): + SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head") + from_address = get_company_address_details(doc) from_shipping_state = from_address.get("state") from_country_code = frappe.db.get_value("Country", from_address.country, "code") @@ -113,20 +115,20 @@ def get_tax_data(doc): to_shipping_state = get_state_code(to_address, 'Shipping') tax_dict = { - 'from_country': from_country_code, - 'from_zip': from_address.pincode, - 'from_state': from_shipping_state, - 'from_city': from_address.city, - 'from_street': from_address.address_line1, - 'to_country': to_country_code, - 'to_zip': to_address.pincode, - 'to_city': to_address.city, - 'to_street': to_address.address_line1, - 'to_state': to_shipping_state, - 'shipping': shipping, - 'amount': doc.net_total, - 'plugin': 'erpnext', - 'line_items': line_items + "from_country": from_country_code, + "from_zip": from_address.pincode, + "from_state": from_shipping_state, + "from_city": from_address.city, + "from_street": from_address.address_line1, + "to_country": to_country_code, + "to_zip": to_address.pincode, + "to_city": to_address.city, + "to_street": to_address.address_line1, + "to_state": to_shipping_state, + "shipping": shipping, + "amount": doc.net_total, + "plugin": "erpnext", + "line_items": line_items } return tax_dict @@ -156,6 +158,9 @@ def get_line_item_dict(item, docstatus): return tax_dict def set_sales_tax(doc, method): + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") + TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax") + if not TAXJAR_CALCULATE_TAX: return @@ -206,6 +211,7 @@ def set_sales_tax(doc, method): doc.run_method("calculate_taxes_and_totals") def check_for_nexus(doc, tax_dict): + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") if not frappe.db.get_value('TaxJar Nexus', {'region_code': tax_dict["to_state"]}): for item in doc.get("items"): item.tax_collectable = flt(0) @@ -218,6 +224,8 @@ def check_for_nexus(doc, tax_dict): def check_sales_tax_exemption(doc): # if the party is exempt from sales tax, then set all tax account heads to zero + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") + sales_tax_exempted = hasattr(doc, "exempt_from_sales_tax") and doc.exempt_from_sales_tax \ or frappe.db.has_column("Customer", "exempt_from_sales_tax") \ and frappe.db.get_value("Customer", doc.customer, "exempt_from_sales_tax") From 5d403449bdcbe514c33b8807b674fd23ba24d93a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 22 Feb 2022 19:24:49 +0530 Subject: [PATCH 78/87] test: move report tests to subttest (#29945) Basically failfast=False but for sub-tests --- erpnext/accounts/test/test_reports.py | 15 ++++++++------- erpnext/manufacturing/report/test_reports.py | 15 ++++++++------- erpnext/stock/report/test_reports.py | 15 ++++++++------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py index 78c109ab94..4ed966dcb9 100644 --- a/erpnext/accounts/test/test_reports.py +++ b/erpnext/accounts/test/test_reports.py @@ -39,10 +39,11 @@ class TestReports(unittest.TestCase): def test_execute_all_accounts_reports(self): """Test that all script report in stock modules are executable with supported filters""" for report, filter in REPORT_FILTER_TEST_CASES: - execute_script_report( - report_name=report, - module="Accounts", - filters=filter, - default_filters=DEFAULT_FILTERS, - optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, - ) + with self.subTest(report=report): + execute_script_report( + report_name=report, + module="Accounts", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) diff --git a/erpnext/manufacturing/report/test_reports.py b/erpnext/manufacturing/report/test_reports.py index 9f51ded6c7..e436fdca64 100644 --- a/erpnext/manufacturing/report/test_reports.py +++ b/erpnext/manufacturing/report/test_reports.py @@ -55,10 +55,11 @@ class TestManufacturingReports(unittest.TestCase): def test_execute_all_manufacturing_reports(self): """Test that all script report in manufacturing modules are executable with supported filters""" for report, filter in REPORT_FILTER_TEST_CASES: - execute_script_report( - report_name=report, - module="Manufacturing", - filters=filter, - default_filters=DEFAULT_FILTERS, - optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, - ) + with self.subTest(report=report): + execute_script_report( + report_name=report, + module="Manufacturing", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) diff --git a/erpnext/stock/report/test_reports.py b/erpnext/stock/report/test_reports.py index 525af40b41..76c20798bf 100644 --- a/erpnext/stock/report/test_reports.py +++ b/erpnext/stock/report/test_reports.py @@ -73,10 +73,11 @@ class TestReports(unittest.TestCase): def test_execute_all_stock_reports(self): """Test that all script report in stock modules are executable with supported filters""" for report, filter in REPORT_FILTER_TEST_CASES: - execute_script_report( - report_name=report, - module="Stock", - filters=filter, - default_filters=DEFAULT_FILTERS, - optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, - ) + with self.subTest(report=report): + execute_script_report( + report_name=report, + module="Stock", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) From a61790c00fa2b3c53ba49d930c7d08b3f0213b65 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 22 Feb 2022 20:58:10 +0530 Subject: [PATCH 79/87] fix: Remove unintended changes --- erpnext/controllers/accounts_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7913a39329..a94af10cde 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1954,7 +1954,8 @@ def update_bin_on_delete(row, doctype): qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) - update_bin_qty(row.item_code, row.warehouse, qty_dict) + if row.warehouse: + update_bin_qty(row.item_code, row.warehouse, qty_dict) def validate_and_delete_children(parent, data): deleted_children = [] From b0a1cd6a7bd9f0900d6f723c3b2cbf9037989fcc Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 23 Feb 2022 00:13:44 +0530 Subject: [PATCH 80/87] chore: Change heart icon to `icon-heart` and change var `icon-stroke` to accomodate changes in frappe icon - `icon-heart` got a stroke colour that needs to be overriden via var `icon-stroke - Use `icon-heart` instead of `icon-heart-active` as the latter has a color fill now --- erpnext/public/scss/shopping_cart.scss | 8 ++++---- erpnext/templates/includes/navbar/navbar_items.html | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index 4b645b9dde..666043b219 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -338,14 +338,14 @@ body.product-page { .btn-add-to-wishlist { svg use { - stroke: #F47A7A; + --icon-stroke: #F47A7A; } } .btn-view-in-wishlist { svg use { fill: #F47A7A; - stroke: none; + --icon-stroke: none; } } @@ -1022,7 +1022,7 @@ body.product-page { .not-wished { cursor: pointer; - stroke: #F47A7A !important; + --icon-stroke: #F47A7A !important; &:hover { fill: #F47A7A; @@ -1030,7 +1030,7 @@ body.product-page { } .wished { - stroke: none; + --icon-stroke: none; fill: #F47A7A !important; } diff --git a/erpnext/templates/includes/navbar/navbar_items.html b/erpnext/templates/includes/navbar/navbar_items.html index 327552117b..d7adae562e 100644 --- a/erpnext/templates/includes/navbar/navbar_items.html +++ b/erpnext/templates/includes/navbar/navbar_items.html @@ -13,7 +13,7 @@