From 110f28f869c1a82be5fc4f4cd13c6c2d6bf34720 Mon Sep 17 00:00:00 2001 From: noahjacob Date: Fri, 9 Apr 2021 20:01:47 +0530 Subject: [PATCH 01/13] Company field editable and is readonly after saving. --- erpnext/stock/doctype/warehouse/warehouse.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index bddb114c9de..9b9093261c2 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -70,6 +70,7 @@ "oldfieldname": "company", "oldfieldtype": "Link", "options": "Company", + "read_only_depends_on": "eval: !doc.__islocal", "remember_last_selected_value": 1, "reqd": 1, "search_index": 1 @@ -244,7 +245,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2021-02-16 17:21:52.380098", + "modified": "2021-04-09 19:54:56.263965", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", From d235fc75ad573525ee7dc022acb468e82aa22674 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 12 Apr 2021 10:23:06 +0530 Subject: [PATCH 02/13] fix: remove nonexistent method from pick list (#25279) This method is merged in calculate_rate_and_amount() during repost item valuation refactoring. Ref: https://github.com/nabinhait/erpnext/commit/e10f0a5a6cd344b0e2ea2b65a992b37e9a4594d6#diff-a160e3a8907dcdf28ce7728bb7dd45914ad8ab033489ea3de4c80fb1c4ca7fe9 --- erpnext/stock/doctype/pick_list/pick_list.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 755fa615bb0..6ab68e292ae 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -379,7 +379,6 @@ def create_stock_entry(pick_list): else: stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) - stock_entry.set_incoming_rate() stock_entry.set_actual_qty() stock_entry.calculate_rate_and_amount() From 207166fe8ba5f22ec07acc9c8f91e1f2493bbf05 Mon Sep 17 00:00:00 2001 From: Anoop <3326959+akurungadam@users.noreply.github.com> Date: Mon, 12 Apr 2021 10:25:34 +0530 Subject: [PATCH 03/13] fix(patch): copy Lab Test / Template renamed field values (#25276) reload_doc doctypes introduced since v11 --- .../v13_0/healthcare_lab_module_rename_doctypes.py | 13 +++++++++++++ ...atient_history_settings_for_standard_doctypes.py | 1 + 2 files changed, 14 insertions(+) diff --git a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py b/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py index 5920bf1f704..a78f8025747 100644 --- a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py +++ b/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py @@ -18,6 +18,7 @@ def execute(): for old_dt, new_dt in doctypes.items(): if not frappe.db.table_exists(new_dt) and frappe.db.table_exists(old_dt): + frappe.reload_doc('healthcare', 'doctype', frappe.scrub(old_dt)) frappe.rename_doc('DocType', old_dt, new_dt, force=True) frappe.reload_doc('healthcare', 'doctype', frappe.scrub(new_dt)) frappe.delete_doc_if_exists('DocType', old_dt) @@ -36,6 +37,18 @@ def execute(): SET parentfield = %(parentfield)s """.format(doctype), {'parentfield': parentfield}) + # copy renamed child table fields (fields were already renamed in old doctype json, hence sql) + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_name = test_name""") + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_event = test_event""") + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_uom = test_uom""") + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_comment = test_comment""") + frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_event = test_event""") + frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_uom = test_uom""") + frappe.db.sql("""UPDATE `tabDescriptive Test Result` SET lab_test_particulars = test_particulars""") + frappe.db.sql("""UPDATE `tabLab Test Group Template` SET lab_test_template = test_template""") + frappe.db.sql("""UPDATE `tabLab Test Group Template` SET lab_test_description = test_description""") + frappe.db.sql("""UPDATE `tabLab Test Group Template` SET lab_test_rate = test_rate""") + # rename field frappe.reload_doc('healthcare', 'doctype', 'lab_test') if frappe.db.has_column('Lab Test', 'special_toggle'): diff --git a/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py b/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py index 2d3b0969154..5316c011708 100644 --- a/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py +++ b/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py @@ -8,6 +8,7 @@ def execute(): frappe.reload_doc("healthcare", "doctype", "Therapy Session") frappe.reload_doc("healthcare", "doctype", "Inpatient Medication Order") + frappe.reload_doc("healthcare", "doctype", "Clinical Procedure") frappe.reload_doc("healthcare", "doctype", "Patient History Settings") frappe.reload_doc("healthcare", "doctype", "Patient History Standard Document Type") frappe.reload_doc("healthcare", "doctype", "Patient History Custom Document Type") From b8a270074e402c707e31537f072f2a637ce0d441 Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 12 Apr 2021 10:28:05 +0530 Subject: [PATCH 04/13] fix(pos): cannot set qty to less than zero (#25258) --- erpnext/selling/page/point_of_sale/pos_controller.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 9e3c9a5656a..8adf5bf7473 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -279,11 +279,6 @@ erpnext.PointOfSale.Controller = class { const item_row = frappe.model.get_doc(cdt, cdn); if (item_row && item_row[fieldname] != value) { - if (fieldname === 'qty' && flt(value) == 0) { - this.remove_item_from_cart(); - return; - } - const { item_code, batch_no, uom } = this.item_details.current_item; const event = { field: fieldname, From 9f73bd80401ed7e97bbba04f4d51b15f0d7e2581 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Mon, 12 Apr 2021 10:31:36 +0530 Subject: [PATCH 05/13] fix: don't delete mode of payment account details while deleting company transactions (#25217) --- erpnext/setup/doctype/company/delete_company_transactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/company/delete_company_transactions.py b/erpnext/setup/doctype/company/delete_company_transactions.py index 0df4c87f51f..933ed3cf325 100644 --- a/erpnext/setup/doctype/company/delete_company_transactions.py +++ b/erpnext/setup/doctype/company/delete_company_transactions.py @@ -27,7 +27,7 @@ def delete_company_transactions(company_name): if doctype not in ("Account", "Cost Center", "Warehouse", "Budget", "Party Account", "Employee", "Sales Taxes and Charges Template", "Purchase Taxes and Charges Template", "POS Profile", "BOM", - "Company", "Bank Account", "Item Tax Template", "Mode Of Payment", + "Company", "Bank Account", "Item Tax Template", "Mode Of Payment", "Mode of Payment Account", "Item Default", "Customer", "Supplier", "GST Account"): delete_for_doctype(doctype, company_name) From c36e48a869a45f27072e724a4738b6dc7f820125 Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Mon, 12 Apr 2021 10:55:43 +0530 Subject: [PATCH 06/13] fix: GST on freight charge in e-invoicing (#25000) * fix: GST on freight charge in e-invocing * fix: Add patch for taxable value field * fix: Handle discounts on net total * fix: Handle all types of discounts for e-invoicing * fix: Absolute taxable values * fix: Use correct tax amount --- erpnext/hooks.py | 3 +- erpnext/patches.txt | 1 + .../v12_0/create_taxable_value_field.py | 18 +++++++ erpnext/regional/india/e_invoice/utils.py | 47 +++++++++++++++---- erpnext/regional/india/setup.py | 5 +- erpnext/regional/india/utils.py | 47 ++++++++++++++++++- 6 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 erpnext/patches/v12_0/create_taxable_value_field.py diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 2e26fd2a9b1..98d5966264e 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -262,7 +262,8 @@ doc_events = { ], "on_trash": "erpnext.regional.check_deletion_permission", "validate": [ - "erpnext.regional.india.utils.validate_document_name" + "erpnext.regional.india.utils.validate_document_name", + "erpnext.regional.india.utils.update_taxable_values" ] }, "Purchase Invoice": { diff --git a/erpnext/patches.txt b/erpnext/patches.txt index bf25db3ea50..d6176fd6f63 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -763,5 +763,6 @@ erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae erpnext.patches.v13_0.setup_uae_vat_fields execute:frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext') erpnext.patches.v13_0.rename_discharge_date_in_ip_record +erpnext.patches.v12_0.create_taxable_value_field erpnext.patches.v12_0.add_gst_category_in_delivery_note erpnext.patches.v12_0.purchase_receipt_status diff --git a/erpnext/patches/v12_0/create_taxable_value_field.py b/erpnext/patches/v12_0/create_taxable_value_field.py new file mode 100644 index 00000000000..a0c9fcf4cbe --- /dev/null +++ b/erpnext/patches/v12_0/create_taxable_value_field.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = { + 'Sales Invoice Item': [ + dict(fieldname='taxable_value', label='Taxable Value', + fieldtype='Currency', insert_after='base_net_amount', hidden=1, options="Company:company:default_currency", + print_hide=1) + ] + } + + create_custom_fields(custom_fields, update=True) \ No newline at end of file diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 3dd1b36fb69..5f59d641327 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -171,10 +171,15 @@ def get_item_list(invoice): item.description = sanitize_for_json(d.item_name) item.qty = abs(item.qty) - item.discount_amount = 0 - item.unit_rate = abs(item.base_net_amount / item.qty) - item.gross_amount = abs(item.base_net_amount) - item.taxable_value = abs(item.base_net_amount) + + if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: + item.discount_amount = abs(item.base_amount - item.base_net_amount) + else: + item.discount_amount = 0 + + item.unit_rate = abs((abs(item.taxable_value) - item.discount_amount)/ item.qty) + item.gross_amount = abs(item.taxable_value) + item.discount_amount + item.taxable_value = abs(item.taxable_value) item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None @@ -211,7 +216,7 @@ def update_item_taxes(invoice, item): item_tax_rate = item_tax_detail[0] # item tax amount excluding discount amount - item_tax_amount = (item_tax_rate / 100) * item.base_net_amount + item_tax_amount = (item_tax_rate / 100) * item.taxable_value if t.account_head in gst_accounts.cess_account: item_tax_amount_after_discount = item_tax_detail[1] @@ -232,10 +237,14 @@ def get_invoice_value_details(invoice): invoice_value_details = frappe._dict(dict()) if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: - invoice_value_details.base_total = abs(invoice.base_total) - invoice_value_details.invoice_discount_amt = abs(invoice.base_discount_amount) + # Discount already applied on net total which means on items + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) + invoice_value_details.invoice_discount_amt = 0 + elif invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount: + invoice_value_details.invoice_discount_amt = invoice.base_discount_amount + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) else: - invoice_value_details.base_total = abs(invoice.base_net_total) + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) # since tax already considers discount amount invoice_value_details.invoice_discount_amt = 0 @@ -256,7 +265,11 @@ def update_invoice_taxes(invoice, invoice_value_details): invoice_value_details.total_igst_amt = 0 invoice_value_details.total_cess_amt = 0 invoice_value_details.total_other_charges = 0 + considered_rows = [] + for t in invoice.taxes: + tax_amount = t.base_tax_amount if (invoice.apply_discount_on == 'Grand Total' and invoice.discount_amount) \ + else t.base_tax_amount_after_discount_amount if t.account_head in gst_accounts_list: if t.account_head in gst_accounts.cess_account: # using after discount amt since item also uses after discount amt for cess calc @@ -264,12 +277,26 @@ def update_invoice_taxes(invoice, invoice_value_details): for tax_type in ['igst', 'cgst', 'sgst']: if t.account_head in gst_accounts[f'{tax_type}_account']: - invoice_value_details[f'total_{tax_type}_amt'] += abs(t.base_tax_amount_after_discount_amount) + + invoice_value_details[f'total_{tax_type}_amt'] += abs(tax_amount) + update_other_charges(t, invoice_value_details, gst_accounts_list, invoice, considered_rows) else: - invoice_value_details.total_other_charges += abs(t.base_tax_amount_after_discount_amount) + invoice_value_details.total_other_charges += abs(tax_amount) return invoice_value_details +def update_other_charges(tax_row, invoice_value_details, gst_accounts_list, invoice, considered_rows): + prev_row_id = cint(tax_row.row_id) - 1 + if tax_row.account_head in gst_accounts_list and prev_row_id not in considered_rows: + if tax_row.charge_type == 'On Previous Row Amount': + amount = invoice.get('taxes')[prev_row_id].tax_amount_after_discount_amount + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + if tax_row.charge_type == 'On Previous Row Total': + amount = invoice.get('taxes')[prev_row_id].base_total - invoice.base_net_total + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + def get_payment_details(invoice): payee_name = invoice.company mode_of_payment = ', '.join([d.mode_of_payment for d in invoice.payments]) diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index 7c25ce12f57..bf0712f0a55 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -127,6 +127,9 @@ def make_custom_fields(update=True): is_non_gst = dict(fieldname='is_non_gst', label='Is Non GST', fieldtype='Check', fetch_from='item_code.is_non_gst', insert_after='is_nil_exempt', print_hide=1) + taxable_value = dict(fieldname='taxable_value', label='Taxable Value', + fieldtype='Currency', insert_after='base_net_amount', hidden=1, options="Company:company:default_currency", + print_hide=1) purchase_invoice_gst_category = [ dict(fieldname='gst_section', label='GST Details', fieldtype='Section Break', @@ -460,7 +463,7 @@ def make_custom_fields(update=True): 'Supplier Quotation Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Sales Order Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Delivery Note Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], - 'Sales Invoice Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], + 'Sales Invoice Item': [hsn_sac_field, nil_rated_exempt, is_non_gst, taxable_value], 'Purchase Order Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Purchase Receipt Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], 'Purchase Invoice Item': [hsn_sac_field, nil_rated_exempt, is_non_gst], diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 3637de438cd..082a32f3ff8 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import frappe, re, json from frappe import _ import erpnext -from frappe.utils import cstr, flt, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words, getdate +from frappe.utils import cstr, flt, cint, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words, getdate from erpnext.regional.india import states, state_numbers from erpnext.controllers.taxes_and_totals import get_itemised_tax, get_itemised_taxable_amount from erpnext.controllers.accounts_controller import get_taxes_and_charges @@ -832,3 +832,48 @@ def get_regional_round_off_accounts(company, account_list): account_list.extend(gst_account_list) return account_list + +def update_taxable_values(doc, method): + country = frappe.get_cached_value('Company', doc.company, 'country') + + if country != 'India': + return + + gst_accounts = get_gst_accounts(doc.company) + + # Only considering sgst account to avoid inflating taxable value + gst_account_list = gst_accounts.get('sgst_account', []) + gst_accounts.get('sgst_account', []) \ + + gst_accounts.get('igst_account', []) + + additional_taxes = 0 + total_charges = 0 + item_count = 0 + considered_rows = [] + + for tax in doc.get('taxes'): + prev_row_id = cint(tax.row_id) - 1 + if tax.account_head in gst_account_list and prev_row_id not in considered_rows: + if tax.charge_type == 'On Previous Row Amount': + additional_taxes += doc.get('taxes')[prev_row_id].tax_amount_after_discount_amount + considered_rows.append(prev_row_id) + if tax.charge_type == 'On Previous Row Total': + additional_taxes += doc.get('taxes')[prev_row_id].base_total - doc.base_net_total + considered_rows.append(prev_row_id) + + for item in doc.get('items'): + if doc.apply_discount_on == 'Grand Total' and doc.discount_amount: + proportionate_value = item.base_amount if doc.base_total else item.qty + total_value = doc.base_total if doc.base_total else doc.total_qty + else: + proportionate_value = item.base_net_amount if doc.base_net_total else item.qty + total_value = doc.base_net_total if doc.base_net_total else doc.total_qty + + applicable_charges = flt(flt(proportionate_value * (flt(additional_taxes) / flt(total_value)), + item.precision('taxable_value'))) + item.taxable_value = applicable_charges + proportionate_value + total_charges += applicable_charges + item_count += 1 + + if total_charges != additional_taxes: + diff = additional_taxes - total_charges + doc.get('items')[item_count - 1].taxable_value += diff From 5f3d7f547c2c0cadca23d007f296c370ef37a98c Mon Sep 17 00:00:00 2001 From: Deepesh Garg <42651287+deepeshgarg007@users.noreply.github.com> Date: Mon, 12 Apr 2021 10:56:47 +0530 Subject: [PATCH 07/13] fix: Item wise tax rate for consolidated POS invoice (#25029) * fix: Item wise tax rate for consolidated POS invoice * fix: Do not alter item wise taxes for consolidated invoices * fix: Add test case * fix: Update * fix: Set opening stock for test item * fix: Add valuation rate for opening stock --- .../pos_invoice_merge_log.py | 34 +++++++++++-- .../test_pos_invoice_merge_log.py | 48 +++++++++++++++++++ erpnext/controllers/taxes_and_totals.py | 13 +++-- erpnext/stock/doctype/item/test_records.json | 2 + 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 40f77b4088d..2f5ee01baf1 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -12,6 +12,7 @@ from frappe.utils.background_jobs import enqueue from frappe.model.mapper import map_doc, map_child_doc from frappe.utils.scheduler import is_scheduler_inactive from frappe.core.page.background_jobs.background_jobs import get_info +import json from six import iteritems @@ -131,12 +132,14 @@ class POSInvoiceMergeLog(Document): if t.account_head == tax.account_head and t.cost_center == tax.cost_center: t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount) t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount) + update_item_wise_tax_detail(t, tax) found = True if not found: tax.charge_type = 'Actual' tax.included_in_print_rate = 0 tax.tax_amount = tax.tax_amount_after_discount_amount tax.base_tax_amount = tax.base_tax_amount_after_discount_amount + tax.item_wise_tax_detail = tax.item_wise_tax_detail taxes.append(tax) for payment in doc.get('payments'): @@ -172,7 +175,7 @@ class POSInvoiceMergeLog(Document): sales_invoice.posting_date = getdate(nowdate()) return sales_invoice - + def update_pos_invoices(self, invoice_docs, sales_invoice='', credit_note=''): for doc in invoice_docs: doc.load_from_db() @@ -187,6 +190,26 @@ class POSInvoiceMergeLog(Document): si.flags.ignore_validate = True si.cancel() +def update_item_wise_tax_detail(consolidate_tax_row, tax_row): + consolidated_tax_detail = json.loads(consolidate_tax_row.item_wise_tax_detail) + tax_row_detail = json.loads(tax_row.item_wise_tax_detail) + + if not consolidated_tax_detail: + consolidated_tax_detail = {} + + for item_code, tax_data in tax_row_detail.items(): + if consolidated_tax_detail.get(item_code): + consolidated_tax_data = consolidated_tax_detail.get(item_code) + consolidated_tax_detail.update({ + item_code: [consolidated_tax_data[0], consolidated_tax_data[1] + tax_data[1]] + }) + else: + consolidated_tax_detail.update({ + item_code: [tax_data[0], tax_data[1]] + }) + + consolidate_tax_row.item_wise_tax_detail = json.dumps(consolidated_tax_detail, separators=(',', ':')) + def get_all_unconsolidated_invoices(): filters = { 'consolidated_invoice': [ 'in', [ '', None ]], @@ -214,7 +237,7 @@ def consolidate_pos_invoices(pos_invoices=[], closing_entry={}): if len(invoices) >= 5 and closing_entry: closing_entry.set_status(update=True, status='Queued') - enqueue_job(create_merge_logs, invoice_by_customer, closing_entry) + enqueue_job(create_merge_logs, invoice_by_customer=invoice_by_customer, closing_entry=closing_entry) else: create_merge_logs(invoice_by_customer, closing_entry) @@ -227,7 +250,7 @@ def unconsolidate_pos_invoices(closing_entry): if len(merge_logs) >= 5: closing_entry.set_status(update=True, status='Queued') - enqueue_job(cancel_merge_logs, merge_logs, closing_entry) + enqueue_job(cancel_merge_logs, merge_logs=merge_logs, closing_entry=closing_entry) else: cancel_merge_logs(merge_logs, closing_entry) @@ -241,7 +264,7 @@ def create_merge_logs(invoice_by_customer, closing_entry={}): merge_log.set('pos_invoices', invoices) merge_log.save(ignore_permissions=True) merge_log.submit() - + if closing_entry: closing_entry.set_status(update=True, status='Submitted') closing_entry.update_opening_entry() @@ -256,7 +279,7 @@ def cancel_merge_logs(merge_logs, closing_entry={}): closing_entry.set_status(update=True, status='Cancelled') closing_entry.update_opening_entry(for_cancel=True) -def enqueue_job(job, invoice_by_customer, closing_entry): +def enqueue_job(job, merge_logs=None, invoice_by_customer=None, closing_entry=None): check_scheduler_status() job_name = closing_entry.get("name") @@ -269,6 +292,7 @@ def enqueue_job(job, invoice_by_customer, closing_entry): job_name=job_name, closing_entry=closing_entry, invoice_by_customer=invoice_by_customer, + merge_logs=merge_logs, now=frappe.conf.developer_mode or frappe.flags.in_test ) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index d880caa3c73..040a815fab3 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe import unittest +import json from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices @@ -99,4 +100,51 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") + def test_consolidated_invoice_item_taxes(self): + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + inv = create_pos_invoice(qty=1, rate=100, do_not_save=True) + + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 9 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=1, rate=100, do_not_save=True) + inv2.get('items')[0].item_code = '_Test Item 2' + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 5 + }) + inv2.insert() + inv2.submit() + + consolidate_pos_invoices() + inv.load_from_db() + + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + item_wise_tax_detail = json.loads(consolidated_invoice.get('taxes')[0].item_wise_tax_detail) + + tax_rate, amount = item_wise_tax_detail.get('_Test Item') + self.assertEqual(tax_rate, 9) + self.assertEqual(amount, 9) + + tax_rate2, amount2 = item_wise_tax_detail.get('_Test Item 2') + self.assertEqual(tax_rate2, 5) + self.assertEqual(amount2, 5) + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 5f73c55836b..7653a5dfdc4 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -147,7 +147,9 @@ class calculate_taxes_and_totals(object): validate_taxes_and_charges(tax) validate_inclusive_tax(tax, self.doc) - tax.item_wise_tax_detail = {} + if not self.doc.get('is_consolidated'): + tax.item_wise_tax_detail = {} + tax_fields = ["total", "tax_amount_after_discount_amount", "tax_amount_for_current_item", "grand_total_for_current_item", "tax_fraction_for_current_item", "grand_total_fraction_for_current_item"] @@ -338,7 +340,9 @@ class calculate_taxes_and_totals(object): current_tax_amount = tax_rate * item.qty current_tax_amount = self.get_final_current_tax_amount(tax, current_tax_amount) - self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount) + + if not self.doc.get("is_consolidated"): + self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount) return current_tax_amount @@ -440,8 +444,9 @@ class calculate_taxes_and_totals(object): self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"]) def _cleanup(self): - for tax in self.doc.get("taxes"): - tax.item_wise_tax_detail = json.dumps(tax.item_wise_tax_detail, separators=(',', ':')) + if not self.doc.get('is_consolidated'): + for tax in self.doc.get("taxes"): + tax.item_wise_tax_detail = json.dumps(tax.item_wise_tax_detail, separators=(',', ':')) def set_discount_amount(self): if self.doc.additional_discount_percentage: diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json index c1f20a47b71..6cec85288fe 100644 --- a/erpnext/stock/doctype/item/test_records.json +++ b/erpnext/stock/doctype/item/test_records.json @@ -59,6 +59,8 @@ "show_in_website": 1, "website_warehouse": "_Test Warehouse - _TC", "gst_hsn_code": "999800", + "opening_stock": 10, + "valuation_rate": 100, "item_defaults": [{ "company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC", From 42f4a00c885b03166a092abe5073d3441ae53a88 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 12 Apr 2021 12:01:20 +0530 Subject: [PATCH 08/13] fix: declare data before assigining --- erpnext/accounts/doctype/sales_invoice/sales_invoice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index b361c0c3457..26d60389f64 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -916,7 +916,7 @@ frappe.ui.form.on('Sales Invoice Timesheet', { }, callback: function(r, rt) { if(r.message){ - data = r.message; + let data = r.message; frappe.model.set_value(cdt, cdn, "billing_hours", data.billing_hours); frappe.model.set_value(cdt, cdn, "billing_amount", data.billing_amount); frappe.model.set_value(cdt, cdn, "timesheet_detail", data.timesheet_detail); From 49336b8065e498fc2ae260ec3414c792dfa24585 Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 12 Apr 2021 12:17:50 +0530 Subject: [PATCH 09/13] fix: consolidated sales invoice posting date (#25119) --- .../pos_invoice_merge_log/pos_invoice_merge_log.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 2f5ee01baf1..6d2cffcf685 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -79,8 +79,11 @@ class POSInvoiceMergeLog(Document): sales_invoice = self.merge_pos_invoice_into(sales_invoice, data) sales_invoice.is_consolidated = 1 + sales_invoice.set_posting_time = 1 + sales_invoice.posting_date = getdate(self.posting_date) sales_invoice.save() sales_invoice.submit() + self.consolidated_invoice = sales_invoice.name return sales_invoice.name @@ -92,10 +95,13 @@ class POSInvoiceMergeLog(Document): credit_note = self.merge_pos_invoice_into(credit_note, data) credit_note.is_consolidated = 1 + credit_note.set_posting_time = 1 + credit_note.posting_date = getdate(self.posting_date) # TODO: return could be against multiple sales invoice which could also have been consolidated? # credit_note.return_against = self.consolidated_invoice credit_note.save() credit_note.submit() + self.consolidated_credit_note = credit_note.name return credit_note.name @@ -171,8 +177,6 @@ class POSInvoiceMergeLog(Document): sales_invoice = frappe.new_doc('Sales Invoice') sales_invoice.customer = self.customer sales_invoice.is_pos = 1 - # date can be pos closing date? - sales_invoice.posting_date = getdate(nowdate()) return sales_invoice @@ -257,7 +261,7 @@ def unconsolidate_pos_invoices(closing_entry): def create_merge_logs(invoice_by_customer, closing_entry={}): for customer, invoices in iteritems(invoice_by_customer): merge_log = frappe.new_doc('POS Invoice Merge Log') - merge_log.posting_date = getdate(nowdate()) + merge_log.posting_date = getdate(closing_entry.get('posting_date')) merge_log.customer = customer merge_log.pos_closing_entry = closing_entry.get('name', None) From 2339de7333514f1b45ec942fe822bccbf63b7cc4 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 12 Apr 2021 16:09:55 +0530 Subject: [PATCH 10/13] fix: update scheduler check tim --- .../doctype/repost_item_valuation/repost_item_valuation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index e68360266f1..3f837805695 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -127,7 +127,7 @@ def repost_entries(): check_if_stock_and_account_balance_synced(today(), d.name) def get_repost_item_valuation_entries(): - date = add_to_date(today(), hours=-12) + date = add_to_date(today(), hours=-3) return frappe.db.sql(""" SELECT name from `tabRepost Item Valuation` WHERE status != 'Completed' and creation <= %s and docstatus = 1 From 9320316462e1fdb96843209e3e01fb8179db924e Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 12 Apr 2021 17:55:46 +0530 Subject: [PATCH 11/13] feat(india): bulk e-invoice generation (#24969) --- .../sales_invoice/regional/india_list.js | 147 ++++- erpnext/patches.txt | 5 +- .../add_company_link_to_einvoice_settings.py | 16 + .../v12_0/add_einvoice_status_field.py | 69 ++ ...add_einvoice_summary_report_permissions.py | 18 + .../update_vehicle_no_reqd_condition.py | 0 .../e_invoice_settings.json | 10 +- .../e_invoice_user/e_invoice_user.json | 11 +- erpnext/regional/india/e_invoice/einvoice.js | 53 +- erpnext/regional/india/e_invoice/utils.py | 590 +++++++++++++----- erpnext/regional/india/setup.py | 32 +- erpnext/regional/india/utils.py | 13 +- .../report/e_invoice_summary/__init__.py | 0 .../e_invoice_summary/e_invoice_summary.js | 55 ++ .../e_invoice_summary/e_invoice_summary.json | 28 + .../e_invoice_summary/e_invoice_summary.py | 106 ++++ 16 files changed, 925 insertions(+), 228 deletions(-) create mode 100644 erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py create mode 100644 erpnext/patches/v12_0/add_einvoice_status_field.py create mode 100644 erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py rename erpnext/patches/{v13_0 => v12_0}/update_vehicle_no_reqd_condition.py (100%) create mode 100644 erpnext/regional/report/e_invoice_summary/__init__.py create mode 100644 erpnext/regional/report/e_invoice_summary/e_invoice_summary.js create mode 100644 erpnext/regional/report/e_invoice_summary/e_invoice_summary.json create mode 100644 erpnext/regional/report/e_invoice_summary/e_invoice_summary.py diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js index 3e1c5228ead..ada665a0ca9 100644 --- a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js +++ b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js @@ -1,14 +1,14 @@ var globalOnload = frappe.listview_settings['Sales Invoice'].onload; -frappe.listview_settings['Sales Invoice'].onload = function (doclist) { +frappe.listview_settings['Sales Invoice'].onload = function (list_view) { // Provision in case onload event is added to sales_invoice.js in future if (globalOnload) { - globalOnload(doclist); + globalOnload(list_view); } const action = () => { - const selected_docs = doclist.get_checked_items(); - const docnames = doclist.get_checked_items(true); + const selected_docs = list_view.get_checked_items(); + const docnames = list_view.get_checked_items(true); for (let doc of selected_docs) { if (doc.docstatus !== 1) { @@ -19,7 +19,7 @@ frappe.listview_settings['Sales Invoice'].onload = function (doclist) { frappe.call({ method: 'erpnext.regional.india.utils.generate_ewb_json', args: { - 'dt': doclist.doctype, + 'dt': list_view.doctype, 'dn': docnames }, callback: function(r) { @@ -35,5 +35,140 @@ frappe.listview_settings['Sales Invoice'].onload = function (doclist) { }); }; - doclist.page.add_actions_menu_item(__('Generate E-Way Bill JSON'), action, false); + list_view.page.add_actions_menu_item(__('Generate E-Way Bill JSON'), action, false); + + const generate_irns = () => { + const docnames = list_view.get_checked_items(true); + if (docnames && docnames.length) { + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.generate_einvoices', + args: { docnames }, + freeze: true, + freeze_message: __('Generating E-Invoices...') + }); + } else { + frappe.msgprint({ + message: __('Please select at least one sales invoice to generate IRN'), + title: __('No Invoice Selected'), + indicator: 'red' + }); + } + }; + + const cancel_irns = () => { + const docnames = list_view.get_checked_items(true); + + const fields = [ + { + "label": "Reason", + "fieldname": "reason", + "fieldtype": "Select", + "reqd": 1, + "default": "1-Duplicate", + "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"] + }, + { + "label": "Remark", + "fieldname": "remark", + "fieldtype": "Data", + "reqd": 1 + } + ]; + + const d = new frappe.ui.Dialog({ + title: __("Cancel IRN"), + fields: fields, + primary_action: function() { + const data = d.get_values(); + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.cancel_irns', + args: { + doctype: list_view.doctype, + docnames, + reason: data.reason.split('-')[0], + remark: data.remark + }, + freeze: true, + freeze_message: __('Cancelling E-Invoices...'), + }); + d.hide(); + }, + primary_action_label: __('Submit') + }); + d.show(); + }; + + let einvoicing_enabled = false; + frappe.db.get_single_value("E Invoice Settings", "enable").then(enabled => { + einvoicing_enabled = enabled; + }); + + list_view.$result.on("change", "input[type=checkbox]", () => { + if (einvoicing_enabled) { + const docnames = list_view.get_checked_items(true); + // show/hide e-invoicing actions when no sales invoices are checked + if (docnames && docnames.length) { + // prevent adding actions twice if e-invoicing action group already exists + if (list_view.page.get_inner_group_button(__('E-Invoicing')).length == 0) { + list_view.page.add_inner_button(__('Generate IRNs'), generate_irns, __('E-Invoicing')); + list_view.page.add_inner_button(__('Cancel IRNs'), cancel_irns, __('E-Invoicing')); + } + } else { + list_view.page.remove_inner_button(__('Generate IRNs'), __('E-Invoicing')); + list_view.page.remove_inner_button(__('Cancel IRNs'), __('E-Invoicing')); + } + } + }); + + frappe.realtime.on("bulk_einvoice_generation_complete", (data) => { + const { failures, user, invoices } = data; + + if (invoices.length != failures.length) { + frappe.msgprint({ + message: __('{0} e-invoices generated successfully', [invoices.length]), + title: __('Bulk E-Invoice Generation Complete'), + indicator: 'orange' + }); + } + + if (failures && failures.length && user == frappe.session.user) { + let message = ` + Failed to generate IRNs for following ${failures.length} sales invoices: +
    + ${failures.map(d => `
  • ${d.docname}
  • `).join('')} +
+ `; + frappe.msgprint({ + message: message, + title: __('Bulk E-Invoice Generation Complete'), + indicator: 'orange' + }); + } + }); + + frappe.realtime.on("bulk_einvoice_cancellation_complete", (data) => { + const { failures, user, invoices } = data; + + if (invoices.length != failures.length) { + frappe.msgprint({ + message: __('{0} e-invoices cancelled successfully', [invoices.length]), + title: __('Bulk E-Invoice Cancellation Complete'), + indicator: 'orange' + }); + } + + if (failures && failures.length && user == frappe.session.user) { + let message = ` + Failed to cancel IRNs for following ${failures.length} sales invoices: +
    + ${failures.map(d => `
  • ${d.docname}
  • `).join('')} +
+ `; + frappe.msgprint({ + message: message, + title: __('Bulk E-Invoice Cancellation Complete'), + indicator: 'orange' + }); + } + }); }; \ No newline at end of file diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d6176fd6f63..7c842fcfa96 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -756,12 +756,15 @@ erpnext.patches.v13_0.update_payment_terms_outstanding erpnext.patches.v12_0.add_state_code_for_ladakh erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes -erpnext.patches.v13_0.update_vehicle_no_reqd_condition +erpnext.patches.v12_0.update_vehicle_no_reqd_condition +erpnext.patches.v12_0.add_einvoice_status_field #2021-03-17 +erpnext.patches.v12_0.add_einvoice_summary_report_permissions erpnext.patches.v13_0.setup_fields_for_80g_certificate_and_donation erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae erpnext.patches.v13_0.setup_uae_vat_fields execute:frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext') +erpnext.patches.v12_0.add_company_link_to_einvoice_settings erpnext.patches.v13_0.rename_discharge_date_in_ip_record erpnext.patches.v12_0.create_taxable_value_field erpnext.patches.v12_0.add_gst_category_in_delivery_note diff --git a/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py new file mode 100644 index 00000000000..fe284e814c8 --- /dev/null +++ b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company or not frappe.db.count('E Invoice User'): + return + + frappe.reload_doc("regional", "doctype", "e_invoice_user") + for creds in frappe.db.get_all('E Invoice User', fields=['name', 'gstin']): + company_name = frappe.db.sql(""" + select dl.link_name from `tabAddress` a, `tabDynamic Link` dl + where a.gstin = %s and dl.parent = a.name and dl.link_doctype = 'Company' + """, (creds.get('gstin'))) + if company_name[0]: + frappe.db.set_value('E Invoice User', creds.get('name'), 'company', company_name[0][0]) \ No newline at end of file diff --git a/erpnext/patches/v12_0/add_einvoice_status_field.py b/erpnext/patches/v12_0/add_einvoice_status_field.py new file mode 100644 index 00000000000..387e88588d9 --- /dev/null +++ b/erpnext/patches/v12_0/add_einvoice_status_field.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals +import json +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + # move hidden einvoice fields to a different section + custom_fields = { + 'Sales Invoice': [ + dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type', + print_hide=1, hidden=1), + + dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section', + no_copy=1, print_hide=1), + + dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), + + dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date', + no_copy=1, print_hide=1), + + dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image', + options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON', + hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1) + ] + } + create_custom_fields(custom_fields, update=True) + + if frappe.db.exists('E Invoice Settings') and frappe.db.get_single_value('E Invoice Settings', 'enable'): + frappe.db.sql(''' + UPDATE `tabSales Invoice` SET einvoice_status = 'Pending' + WHERE + posting_date >= '2021-04-01' + AND ifnull(irn, '') = '' + AND ifnull(`billing_address_gstin`, '') != ifnull(`company_gstin`, '') + AND ifnull(gst_category, '') in ('Registered Regular', 'SEZ', 'Overseas', 'Deemed Export') + ''') + + # set appropriate statuses + frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Generated' + WHERE ifnull(irn, '') != '' AND ifnull(irn_cancelled, 0) = 0''') + + frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Cancelled' + WHERE ifnull(irn_cancelled, 0) = 1''') + + # set correct acknowledgement in e-invoices + einvoices = frappe.get_all('Sales Invoice', {'irn': ['is', 'set']}, ['name', 'signed_einvoice']) + + if einvoices: + for inv in einvoices: + signed_einvoice = inv.get('signed_einvoice') + if signed_einvoice: + signed_einvoice = json.loads(signed_einvoice) + frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_no', signed_einvoice.get('AckNo'), update_modified=False) + frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_date', signed_einvoice.get('AckDt'), update_modified=False) \ No newline at end of file diff --git a/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py new file mode 100644 index 00000000000..bf8f566d32a --- /dev/null +++ b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + if frappe.db.exists('Report', 'E-Invoice Summary') and \ + not frappe.db.get_value('Custom Role', dict(report='E-Invoice Summary')): + frappe.get_doc(dict( + doctype='Custom Role', + report='E-Invoice Summary', + roles= [ + dict(role='Accounts User'), + dict(role='Accounts Manager') + ] + )).insert() \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py b/erpnext/patches/v12_0/update_vehicle_no_reqd_condition.py similarity index 100% rename from erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py rename to erpnext/patches/v12_0/update_vehicle_no_reqd_condition.py diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json index db8bda75bfd..68ed3391d04 100644 --- a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json +++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json @@ -8,6 +8,7 @@ "enable", "section_break_2", "sandbox_mode", + "applicable_from", "credentials", "auth_token", "token_expiry" @@ -48,12 +49,19 @@ "fieldname": "sandbox_mode", "fieldtype": "Check", "label": "Sandbox Mode" + }, + { + "fieldname": "applicable_from", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Applicable From", + "reqd": 1 } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-01-13 12:04:49.449199", + "modified": "2021-03-30 12:26:25.538294", "modified_by": "Administrator", "module": "Regional", "name": "E Invoice Settings", diff --git a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json index dd9d99773a3..a65b1ca7ca8 100644 --- a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json +++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json @@ -5,6 +5,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "company", "gstin", "username", "password" @@ -30,12 +31,20 @@ "in_list_view": 1, "label": "Password", "reqd": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-12-22 15:10:53.466205", + "modified": "2021-03-22 12:16:56.365616", "modified_by": "Administrator", "module": "Regional", "name": "E Invoice User", diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js index 7cd64f2fc07..c1a222adb40 100644 --- a/erpnext/regional/india/e_invoice/einvoice.js +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -1,12 +1,13 @@ erpnext.setup_einvoice_actions = (doctype) => { frappe.ui.form.on(doctype, { async refresh(frm) { - const einvoicing_enabled = await frappe.db.get_single_value("E Invoice Settings", "enable"); - const supply_type = frm.doc.gst_category; - const valid_supply_type = ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'].includes(supply_type); - const company_transaction = frm.doc.billing_address_gstin == frm.doc.company_gstin; + const res = await frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.validate_eligibility', + args: { doc: frm.doc } + }); + const invoice_eligible = res.message; - if (cint(einvoicing_enabled) == 0 || !valid_supply_type || company_transaction) return; + if (!invoice_eligible) return; const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc; @@ -109,45 +110,25 @@ erpnext.setup_einvoice_actions = (doctype) => { } if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) { - const fields = [ - { - "label": "Reason", - "fieldname": "reason", - "fieldtype": "Select", - "reqd": 1, - "default": "1-Duplicate", - "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"] - }, - { - "label": "Remark", - "fieldname": "remark", - "fieldtype": "Data", - "reqd": 1 - } - ]; const action = () => { - const d = new frappe.ui.Dialog({ - title: __('Cancel E-Way Bill'), - fields: fields, + let message = __('Cancellation of e-way bill is currently not supported. '); + message += '

'; + message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.'); + + frappe.msgprint({ + title: __('Update E-Way Bill Cancelled Status?'), + message: message, + indicator: 'orange', primary_action: function() { - const data = d.get_values(); frappe.call({ method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill', - args: { - doctype, - docname: name, - eway_bill: ewaybill, - reason: data.reason.split('-')[0], - remark: data.remark - }, + args: { doctype, docname: name }, freeze: true, - callback: () => frm.reload_doc() || d.hide(), - error: () => d.hide() + callback: () => frm.reload_doc() }); }, - primary_action_label: __('Submit') + primary_action_label: __('Yes') }); - d.show(); }; add_custom_button(__("Cancel E-Way Bill"), action); } diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 5f59d641327..fb1f46493f1 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -15,18 +15,43 @@ import traceback import io from frappe import _, bold from pyqrcode import create as qrcreate +from frappe.utils.background_jobs import enqueue +from frappe.utils.scheduler import is_scheduler_inactive +from frappe.core.page.background_jobs.background_jobs import get_info from frappe.integrations.utils import make_post_request, make_get_request from erpnext.regional.india.utils import get_gst_accounts, get_place_of_supply -from frappe.utils.data import cstr, cint, format_date, flt, time_diff_in_seconds, now_datetime, add_to_date, get_link_to_form +from frappe.utils.data import cstr, cint, format_date, flt, time_diff_in_seconds, now_datetime, add_to_date, get_link_to_form, getdate, time_diff_in_hours + +@frappe.whitelist() +def validate_eligibility(doc): + if isinstance(doc, six.string_types): + doc = json.loads(doc) + + invalid_doctype = doc.get('doctype') != 'Sales Invoice' + if invalid_doctype: + return False + + einvoicing_enabled = cint(frappe.db.get_single_value('E Invoice Settings', 'enable')) + if not einvoicing_enabled: + return False + + einvoicing_eligible_from = frappe.db.get_single_value('E Invoice Settings', 'applicable_from') or '2021-04-01' + if getdate(doc.get('posting_date')) < getdate(einvoicing_eligible_from): + return False -def validate_einvoice_fields(doc): - einvoicing_enabled = cint(frappe.db.get_value('E Invoice Settings', 'E Invoice Settings', 'enable')) - invalid_doctype = doc.doctype != 'Sales Invoice' invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'] company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin') no_taxes_applied = not doc.get('taxes') - if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction or no_taxes_applied: + if invalid_supply_type or company_transaction or no_taxes_applied: + return False + + return True + +def validate_einvoice_fields(doc): + invoice_eligible = validate_eligibility(doc) + + if not invoice_eligible: return if doc.docstatus == 0 and doc._action == 'save': @@ -35,6 +60,8 @@ def validate_einvoice_fields(doc): if len(doc.name) > 16: raise_document_name_too_long_error() + doc.einvoice_status = 'Pending' + elif doc.docstatus == 1 and doc._action == 'submit' and not doc.irn: frappe.throw(_('You must generate IRN before submitting the document.'), title=_('Missing IRN')) @@ -76,6 +103,9 @@ def get_transaction_details(invoice): )) def get_doc_details(invoice): + if getdate(invoice.posting_date) < getdate('2021-01-01'): + frappe.throw(_('IRN generation is not allowed for invoices dated before 1st Jan 2021'), title=_('Not Allowed')) + invoice_type = 'CRN' if invoice.is_return else 'INV' invoice_name = invoice.name @@ -87,56 +117,39 @@ def get_doc_details(invoice): invoice_date=invoice_date )) -def get_party_details(address_name, company_address=None, billing_address=None, shipping_address=None): - d = frappe.get_all('Address', filters={'name': address_name}, fields=['*'])[0] - - if ((not d.gstin and not shipping_address) - or not d.city - or not d.pincode - or not d.address_title - or not d.address_line1 - or not d.gst_state_number): +def validate_address_fields(address, is_shipping_address): + if ((not address.gstin and not is_shipping_address) + or not address.city + or not address.pincode + or not address.address_title + or not address.address_line1 + or not address.gst_state_number): frappe.throw( - msg=_('Address lines, city, pincode, gstin is mandatory for address {}. Please set them and try again.').format( - get_link_to_form('Address', address_name) - ), + msg=_('Address Lines, City, Pincode, GSTIN are mandatory for address {}. Please set them and try again.').format(address.name), title=_('Missing Address Fields') ) - if d.gst_state_number == 97: +def get_party_details(address_name, is_shipping_address=False): + addr = frappe.get_doc('Address', address_name) + + validate_address_fields(addr, is_shipping_address) + + if addr.gst_state_number == 97: # according to einvoice standard - pincode = 999999 + addr.pincode = 999999 party_address_details = frappe._dict(dict( - legal_name=sanitize_for_json(d.address_title), - location=sanitize_for_json(d.city), - pincode=d.pincode, - state_code=d.gst_state_number, - address_line1=sanitize_for_json(d.address_line1), - address_line2=sanitize_for_json(d.address_line2) + legal_name=sanitize_for_json(addr.address_title), + location=sanitize_for_json(addr.city), + pincode=addr.pincode, gstin=addr.gstin, + state_code=addr.gst_state_number, + address_line1=sanitize_for_json(addr.address_line1), + address_line2=sanitize_for_json(addr.address_line2) )) - if d.gstin: - party_address_details.gstin = d.gstin + return party_address_details -def get_gstin_details(gstin): - if not hasattr(frappe.local, 'gstin_cache'): - frappe.local.gstin_cache = {} - - key = gstin - details = frappe.local.gstin_cache.get(key) - if details: - return details - - details = frappe.cache().hget('gstin_cache', key) - if details: - frappe.local.gstin_cache[key] = details - return details - - if not details: - return GSPConnector.get_gstin_details(gstin) - def get_overseas_address_details(address_name): address_title, address_line1, address_line2, city = frappe.db.get_value( 'Address', address_name, ['address_title', 'address_line1', 'address_line2', 'city'] @@ -212,7 +225,7 @@ def update_item_taxes(invoice, item): is_applicable = t.tax_amount and t.account_head in gst_accounts_list if is_applicable: # this contains item wise tax rate & tax amount (incl. discount) - item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code) + item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code or item.item_name) item_tax_rate = item_tax_detail[0] # item tax amount excluding discount amount @@ -230,6 +243,9 @@ def update_item_taxes(invoice, item): if t.account_head in gst_accounts[f'{tax_type}_account']: item.tax_rate += item_tax_rate item[f'{tax_type}_amount'] += abs(item_tax_amount) + else: + # TODO: other charges per item + pass return item @@ -309,6 +325,10 @@ def get_payment_details(invoice): )) def get_return_doc_reference(invoice): + if not invoice.return_against: + frappe.throw(_('For generating IRN, reference to the original invoice is mandatory for a credit note. Please set {} field to generate e-invoice.') + .format(frappe.bold('Return Against')), title=_('Missing Field')) + invoice_date = frappe.db.get_value('Sales Invoice', invoice.return_against, 'posting_date') return frappe._dict(dict( invoice_name=invoice.return_against, invoice_date=format_date(invoice_date, 'dd/mm/yyyy') @@ -316,7 +336,11 @@ def get_return_doc_reference(invoice): def get_eway_bill_details(invoice): if invoice.is_return: - frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes'), title=_('E Invoice Validation Failed')) + frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes. Please clear fields in the Transporter Section of the invoice.'), + title=_('Invalid Fields')) + + if not invoice.distance: + frappe.throw(_('Distance is mandatory for generating e-way bill for an e-invoice.'), title=_('Missing Field')) mode_of_transport = { '': '', 'Road': '1', 'Air': '2', 'Rail': '3', 'Ship': '4' } vehicle_type = { 'Regular': 'R', 'Over Dimensional Cargo (ODC)': 'O' } @@ -334,9 +358,15 @@ def get_eway_bill_details(invoice): def validate_mandatory_fields(invoice): if not invoice.company_address: - frappe.throw(_('Company Address is mandatory to fetch company GSTIN details.'), title=_('Missing Fields')) + frappe.throw( + _('Company Address is mandatory to fetch company GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) if not invoice.customer_address: - frappe.throw(_('Customer Address is mandatory to fetch customer GSTIN details.'), title=_('Missing Fields')) + frappe.throw( + _('Customer Address is mandatory to fetch customer GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) if not frappe.db.get_value('Address', invoice.company_address, 'gstin'): frappe.throw( _('GSTIN is mandatory to fetch company GSTIN details. Please enter GSTIN in selected company address.'), @@ -348,6 +378,39 @@ def validate_mandatory_fields(invoice): title=_('Missing Fields') ) +def validate_totals(einvoice): + item_list = einvoice['ItemList'] + value_details = einvoice['ValDtls'] + + total_item_ass_value = 0 + total_item_cgst_value = 0 + total_item_sgst_value = 0 + total_item_igst_value = 0 + total_item_value = 0 + for item in item_list: + total_item_ass_value += flt(item['AssAmt']) + total_item_cgst_value += flt(item['CgstAmt']) + total_item_sgst_value += flt(item['SgstAmt']) + total_item_igst_value += flt(item['IgstAmt']) + total_item_value += flt(item['TotItemVal']) + + if abs(flt(item['AssAmt']) * flt(item['GstRt']) / 100) - (flt(item['CgstAmt']) + flt(item['SgstAmt']) + flt(item['IgstAmt'])) > 1: + frappe.throw(_('Row #{}: GST rate is invalid. Please remove tax rows with zero tax amount from taxes table.').format(item.idx)) + + if abs(flt(value_details['AssVal']) - total_item_ass_value) > 1: + frappe.throw(_('Total Taxable Value of the items is not equal to the Invoice Net Total. Please check item taxes / discounts for any correction.')) + + if abs(flt(value_details['TotInvVal']) + flt(value_details['Discount']) - total_item_value) > 1: + frappe.throw(_('Total Value of the items is not equal to the Invoice Grand Total. Please check item taxes / discounts for any correction.')) + + calculated_invoice_value = \ + flt(value_details['AssVal']) + flt(value_details['CgstVal']) \ + + flt(value_details['SgstVal']) + flt(value_details['IgstVal']) \ + + flt(value_details['OthChrg']) - flt(value_details['Discount']) + + if abs(flt(value_details['TotInvVal']) - calculated_invoice_value) > 1: + frappe.throw(_('Total Item Value + Taxes - Discount is not equal to the Invoice Grand Total. Please check taxes / discounts for any correction.')) + def make_einvoice(invoice): validate_mandatory_fields(invoice) @@ -357,12 +420,12 @@ def make_einvoice(invoice): item_list = get_item_list(invoice) doc_details = get_doc_details(invoice) invoice_value_details = get_invoice_value_details(invoice) - seller_details = get_party_details(invoice.company_address, company_address=1) + seller_details = get_party_details(invoice.company_address) if invoice.gst_category == 'Overseas': buyer_details = get_overseas_address_details(invoice.customer_address) else: - buyer_details = get_party_details(invoice.customer_address, billing_address=1) + buyer_details = get_party_details(invoice.customer_address) place_of_supply = get_place_of_supply(invoice, invoice.doctype) if place_of_supply: place_of_supply = place_of_supply.split('-')[0] @@ -370,20 +433,23 @@ def make_einvoice(invoice): place_of_supply = sanitize_for_json(invoice.billing_address_gstin)[:2] buyer_details.update(dict(place_of_supply=place_of_supply)) + seller_details.update(dict(legal_name=invoice.company)) + buyer_details.update(dict(legal_name=invoice.customer_name or invoice.customer)) + shipping_details = payment_details = prev_doc_details = eway_bill_details = frappe._dict({}) if invoice.shipping_address_name and invoice.customer_address != invoice.shipping_address_name: if invoice.gst_category == 'Overseas': shipping_details = get_overseas_address_details(invoice.shipping_address_name) else: - shipping_details = get_party_details(invoice.shipping_address_name, shipping_address=1) + shipping_details = get_party_details(invoice.shipping_address_name, is_shipping_address=True) if invoice.is_pos and invoice.base_paid_amount: payment_details = get_payment_details(invoice) - if invoice.is_return and invoice.return_against: + if invoice.is_return: prev_doc_details = get_return_doc_reference(invoice) - if invoice.transporter: + if invoice.transporter and flt(invoice.distance) and not invoice.is_return: eway_bill_details = get_eway_bill_details(invoice) # not yet implemented @@ -396,18 +462,70 @@ def make_einvoice(invoice): period_details=period_details, prev_doc_details=prev_doc_details, export_details=export_details, eway_bill_details=eway_bill_details ) - einvoice = safe_json_load(einvoice) - validations = json.loads(read_json('einv_validation')) - errors = validate_einvoice(validations, einvoice) - if errors: - message = "\n".join([ - "E Invoice: ", json.dumps(einvoice, indent=4), - "-" * 50, - "Errors: ", json.dumps(errors, indent=4) - ]) - frappe.log_error(title="E Invoice Validation Failed", message=message) - frappe.throw(errors, title=_('E Invoice Validation Failed'), as_list=1) + try: + einvoice = safe_json_load(einvoice) + einvoice = santize_einvoice_fields(einvoice) + validate_totals(einvoice) + + except Exception: + log_error(einvoice) + link_to_error_list = 'Error Log' + frappe.throw( + _('An error occurred while creating e-invoice for {}. Please check {} for more information.').format( + invoice.name, link_to_error_list), + title=_('E Invoice Creation Failed') + ) + + return einvoice + +def log_error(data=None): + if not isinstance(data, dict): + data = json.loads(data) + + seperator = "--" * 50 + err_tb = traceback.format_exc() + err_msg = str(sys.exc_info()[1]) + data = json.dumps(data, indent=4) + + message = "\n".join([ + "Error", err_msg, seperator, + "Data:", data, seperator, + "Exception:", err_tb + ]) + frappe.log_error(title=_('E Invoice Request Failed'), message=message) + +def santize_einvoice_fields(einvoice): + int_fields = ["Pin","Distance","CrDay"] + float_fields = ["Qty","FreeQty","UnitPrice","TotAmt","Discount","PreTaxVal","AssAmt","GstRt","IgstAmt","CgstAmt","SgstAmt","CesRt","CesAmt","CesNonAdvlAmt","StateCesRt","StateCesAmt","StateCesNonAdvlAmt","OthChrg","TotItemVal","AssVal","CgstVal","SgstVal","IgstVal","CesVal","StCesVal","Discount","OthChrg","RndOffAmt","TotInvVal","TotInvValFc","PaidAmt","PaymtDue","ExpDuty",] + copy = einvoice.copy() + for key, value in copy.items(): + if isinstance(value, list): + for idx, d in enumerate(value): + santized_dict = santize_einvoice_fields(d) + if santized_dict: + einvoice[key][idx] = santized_dict + else: + einvoice[key].pop(idx) + + if not einvoice[key]: + einvoice.pop(key, None) + + elif isinstance(value, dict): + santized_dict = santize_einvoice_fields(value) + if santized_dict: + einvoice[key] = santized_dict + else: + einvoice.pop(key, None) + + elif not value or value == "None": + einvoice.pop(key, None) + + elif key in float_fields: + einvoice[key] = flt(value, 2) + + elif key in int_fields: + einvoice[key] = cint(value) return einvoice @@ -423,72 +541,22 @@ def safe_json_load(json_string): snippet = json_string[start:end] frappe.throw(_("Error in input data. Please check for any special characters near following input:
{}").format(snippet)) -def validate_einvoice(validations, einvoice, errors=None): - if errors is None: - errors = [] - for fieldname, field_validation in validations.items(): - value = einvoice.get(fieldname, None) - if not value or value == "None": - # remove keys with empty values - einvoice.pop(fieldname, None) - continue - - value_type = field_validation.get("type").lower() - if value_type in ['object', 'array']: - child_validations = field_validation.get('properties') - - if isinstance(value, list): - for d in value: - validate_einvoice(child_validations, d, errors) - if not d: - # remove empty dicts - einvoice.pop(fieldname, None) - else: - validate_einvoice(child_validations, value, errors) - if not value: - # remove empty dicts - einvoice.pop(fieldname, None) - continue - - # convert to int or str - if value_type == 'string': - einvoice[fieldname] = str(value) - elif value_type == 'number': - is_integer = '.' not in str(field_validation.get('maximum')) - precision = 3 if '.999' in str(field_validation.get('maximum')) else 2 - einvoice[fieldname] = flt(value, precision) if not is_integer else cint(value) - value = einvoice[fieldname] - - max_length = field_validation.get('maxLength') - minimum = flt(field_validation.get('minimum')) - maximum = flt(field_validation.get('maximum')) - pattern_str = field_validation.get('pattern') - pattern = re.compile(pattern_str or '') - - label = field_validation.get('description') or fieldname - - if value_type == 'string' and len(value) > max_length: - errors.append(_('{} should not exceed {} characters').format(label, max_length)) - if value_type == 'number' and (value > maximum or value < minimum): - errors.append(_('{} {} should be between {} and {}').format(label, value, minimum, maximum)) - if pattern_str and not pattern.match(value): - errors.append(field_validation.get('validationMsg')) - - return errors - -class RequestFailed(Exception): pass +class RequestFailed(Exception): + pass +class CancellationNotAllowed(Exception): + pass class GSPConnector(): def __init__(self, doctype=None, docname=None): - self.e_invoice_settings = frappe.get_cached_doc('E Invoice Settings') - sandbox_mode = self.e_invoice_settings.sandbox_mode + self.doctype = doctype + self.docname = docname - self.invoice = frappe.get_cached_doc(doctype, docname) if doctype and docname else None - self.credentials = self.get_credentials() + self.set_invoice() + self.set_credentials() # authenticate url is same for sandbox & live self.authenticate_url = 'https://gsp.adaequare.com/gsp/authenticate?grant_type=token' - self.base_url = 'https://gsp.adaequare.com' if not sandbox_mode else 'https://gsp.adaequare.com/test' + self.base_url = 'https://gsp.adaequare.com' if not self.e_invoice_settings.sandbox_mode else 'https://gsp.adaequare.com/test' self.cancel_irn_url = self.base_url + '/enriched/ei/api/invoice/cancel' self.irn_details_url = self.base_url + '/enriched/ei/api/invoice/irn' @@ -497,15 +565,26 @@ class GSPConnector(): self.cancel_ewaybill_url = self.base_url + '/enriched/ewb/ewayapi?action=CANEWB' self.generate_ewaybill_url = self.base_url + '/enriched/ei/api/ewaybill' - def get_credentials(self): + def set_invoice(self): + self.invoice = None + if self.doctype and self.docname: + self.invoice = frappe.get_cached_doc(self.doctype, self.docname) + + def set_credentials(self): + self.e_invoice_settings = frappe.get_cached_doc('E Invoice Settings') + + if not self.e_invoice_settings.enable: + frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings"))) + if self.invoice: gstin = self.get_seller_gstin() - if not self.e_invoice_settings.enable: - frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings"))) - credentials = next(d for d in self.e_invoice_settings.credentials if d.gstin == gstin) + credentials_for_gstin = [d for d in self.e_invoice_settings.credentials if d.gstin == gstin] + if credentials_for_gstin: + self.credentials = credentials_for_gstin[0] + else: + frappe.throw(_('Cannot find e-invoicing credentials for selected Company GSTIN. Please check E-Invoice Settings')) else: - credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None - return credentials + self.credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None def get_seller_gstin(self): gstin = self.invoice.company_gstin or frappe.db.get_value('Address', self.invoice.company_address, 'gstin') @@ -556,7 +635,7 @@ class GSPConnector(): self.e_invoice_settings.reload() except Exception: - self.log_error(res) + log_error(res) self.raise_error(True) def get_headers(self): @@ -578,14 +657,14 @@ class GSPConnector(): if res.get('success'): return res.get('result') else: - self.log_error(res) + log_error(res) raise RequestFailed except RequestFailed: self.raise_error() except Exception: - self.log_error() + log_error() self.raise_error(True) @staticmethod @@ -603,12 +682,13 @@ class GSPConnector(): return details def generate_irn(self): - headers = self.get_headers() - einvoice = make_einvoice(self.invoice) - data = json.dumps(einvoice, indent=4) - + data = {} try: + headers = self.get_headers() + einvoice = make_einvoice(self.invoice) + data = json.dumps(einvoice, indent=4) res = self.make_request('post', self.generate_irn_url, headers, data) + if res.get('success'): self.set_einvoice_data(res.get('result')) @@ -628,12 +708,36 @@ class GSPConnector(): except RequestFailed: errors = self.sanitize_error_message(res.get('message')) + self.set_failed_status(errors=errors) self.raise_error(errors=errors) - except Exception: - self.log_error(data) + except Exception as e: + self.set_failed_status(errors=str(e)) + log_error(data) self.raise_error(True) + @staticmethod + def bulk_generate_irn(invoices): + gsp_connector = GSPConnector() + gsp_connector.doctype = 'Sales Invoice' + + failed = [] + + for invoice in invoices: + try: + gsp_connector.docname = invoice + gsp_connector.set_invoice() + gsp_connector.set_credentials() + gsp_connector.generate_irn() + + except Exception as e: + failed.append({ + 'docname': invoice, + 'message': str(e) + }) + + return failed + def get_irn_details(self, irn): headers = self.get_headers() @@ -650,21 +754,30 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error() + log_error() self.raise_error(True) def cancel_irn(self, irn, reason, remark): - headers = self.get_headers() - data = json.dumps({ - 'Irn': irn, - 'Cnlrsn': reason, - 'Cnlrem': remark - }, indent=4) - + data, res = {}, {} try: + # validate cancellation + if time_diff_in_hours(now_datetime(), self.invoice.ack_date) > 24: + frappe.throw(_('E-Invoice cannot be cancelled after 24 hours of IRN generation.'), title=_('Not Allowed'), exc=CancellationNotAllowed) + if not irn: + frappe.throw(_('IRN not found. You must generate IRN before cancelling.'), title=_('Not Allowed'), exc=CancellationNotAllowed) + + headers = self.get_headers() + data = json.dumps({ + 'Irn': irn, + 'Cnlrsn': reason, + 'Cnlrem': remark + }, indent=4) + res = self.make_request('post', self.cancel_irn_url, headers, data) - if res.get('success'): + if res.get('success') or '9999' in res.get('message'): self.invoice.irn_cancelled = 1 + self.invoice.irn_cancel_date = res.get('result')['CancelDate'] if res.get('result') else "" + self.invoice.einvoice_status = 'Cancelled' self.invoice.flags.updater_reference = { 'doctype': self.invoice.doctype, 'docname': self.invoice.name, @@ -677,12 +790,41 @@ class GSPConnector(): except RequestFailed: errors = self.sanitize_error_message(res.get('message')) + self.set_failed_status(errors=errors) self.raise_error(errors=errors) - except Exception: - self.log_error(data) + except CancellationNotAllowed as e: + self.set_failed_status(errors=str(e)) + self.raise_error(errors=str(e)) + + except Exception as e: + self.set_failed_status(errors=str(e)) + log_error(data) self.raise_error(True) + @staticmethod + def bulk_cancel_irn(invoices, reason, remark): + gsp_connector = GSPConnector() + gsp_connector.doctype = 'Sales Invoice' + + failed = [] + + for invoice in invoices: + try: + gsp_connector.docname = invoice + gsp_connector.set_invoice() + gsp_connector.set_credentials() + irn = gsp_connector.invoice.irn + gsp_connector.cancel_irn(irn, reason, remark) + + except Exception as e: + failed.append({ + 'docname': invoice, + 'message': str(e) + }) + + return failed + def generate_eway_bill(self, **kwargs): args = frappe._dict(kwargs) @@ -721,7 +863,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def cancel_eway_bill(self, eway_bill, reason, remark): @@ -753,7 +895,7 @@ class GSPConnector(): self.raise_error(errors=errors) except Exception: - self.log_error(data) + log_error(data) self.raise_error(True) def sanitize_error_message(self, message): @@ -768,6 +910,9 @@ class GSPConnector(): ] then we trim down the message by looping over errors ''' + if not message: + return [] + errors = re.findall(': [^:]+', message) for idx, e in enumerate(errors): # remove colons @@ -779,22 +924,6 @@ class GSPConnector(): return errors - def log_error(self, data={}): - if not isinstance(data, dict): - data = json.loads(data) - - seperator = "--" * 50 - err_tb = traceback.format_exc() - err_msg = str(sys.exc_info()[1]) - data = json.dumps(data, indent=4) - - message = "\n".join([ - "Error", err_msg, seperator, - "Data:", data, seperator, - "Exception:", err_tb - ]) - frappe.log_error(title=_('E Invoice Request Failed'), message=message) - def raise_error(self, raise_exception=False, errors=[]): title = _('E Invoice Request Failed') if errors: @@ -817,7 +946,10 @@ class GSPConnector(): self.invoice.ack_no = res.get('AckNo') self.invoice.ack_date = res.get('AckDt') self.invoice.signed_einvoice = dec_signed_invoice + self.invoice.ack_no = res.get('AckNo') + self.invoice.ack_date = res.get('AckDt') self.invoice.signed_qr_code = res.get('SignedQRCode') + self.invoice.einvoice_status = 'Generated' self.attach_qrcode_image() @@ -854,6 +986,17 @@ class GSPConnector(): self.invoice.flags.ignore_validate = True self.invoice.save() + def set_failed_status(self, errors=None): + frappe.db.rollback() + self.invoice.einvoice_status = 'Failed' + self.invoice.failure_description = self.get_failure_message(errors) if errors else "" + self.update_invoice() + frappe.db.commit() + + def get_failure_message(self, errors): + if isinstance(errors, list): + errors = ', '.join(errors) + return errors def sanitize_for_json(string): """Escape JSON specific characters from a string.""" @@ -883,5 +1026,114 @@ def generate_eway_bill(doctype, docname, **kwargs): @frappe.whitelist() def cancel_eway_bill(doctype, docname, eway_bill, reason, remark): - gsp_connector = GSPConnector(doctype, docname) - gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + # TODO: uncomment when eway_bill api from Adequare is enabled + # gsp_connector = GSPConnector(doctype, docname) + # gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + + # update cancelled status only, to be able to cancel irn next + frappe.db.set_value(doctype, docname, 'eway_bill_cancelled', 1) + +@frappe.whitelist() +def generate_einvoices(docnames): + docnames = json.loads(docnames) or [] + + if len(docnames) < 10: + failures = GSPConnector.bulk_generate_irn(docnames) + frappe.local.message_log = [] + + if failures: + show_bulk_action_failure_message(failures) + + success = len(docnames) - len(failures) + frappe.msgprint( + _('{} e-invoices generated successfully').format(success), + title=_('Bulk E-Invoice Generation Complete') + ) + + else: + enqueue_bulk_action(schedule_bulk_generate_irn, docnames=docnames) + +def schedule_bulk_generate_irn(docnames): + failures = GSPConnector.bulk_generate_irn(docnames) + frappe.local.message_log = [] + + frappe.publish_realtime("bulk_einvoice_generation_complete", { + "user": frappe.session.user, + "failures": failures, + "invoices": docnames + }) + +def show_bulk_action_failure_message(failures): + for doc in failures: + docname = '{0}'.format(doc.get('docname')) + message = doc.get('message').replace("'", '"') + if message[0] == '[': + errors = json.loads(message) + error_list = ''.join(['
  • {}
  • '.format(err) for err in errors]) + message = '''{} has following errors:
    +
      {}
    '''.format(docname, error_list) + else: + message = '{} - {}'.format(docname, message) + + frappe.msgprint( + message, + title=_('Bulk E-Invoice Generation Complete'), + indicator='red' + ) + +@frappe.whitelist() +def cancel_irns(docnames, reason, remark): + docnames = json.loads(docnames) or [] + + if len(docnames) < 10: + failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark) + frappe.local.message_log = [] + + if failures: + show_bulk_action_failure_message(failures) + + success = len(docnames) - len(failures) + frappe.msgprint( + _('{} e-invoices cancelled successfully').format(success), + title=_('Bulk E-Invoice Cancellation Complete') + ) + else: + enqueue_bulk_action(schedule_bulk_cancel_irn, docnames=docnames, reason=reason, remark=remark) + +def schedule_bulk_cancel_irn(docnames, reason, remark): + failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark) + frappe.local.message_log = [] + + frappe.publish_realtime("bulk_einvoice_cancellation_complete", { + "user": frappe.session.user, + "failures": failures, + "invoices": docnames + }) + +def enqueue_bulk_action(job, **kwargs): + check_scheduler_status() + + enqueue( + job, + **kwargs, + queue="long", + timeout=10000, + event="processing_bulk_einvoice_action", + now=frappe.conf.developer_mode or frappe.flags.in_test, + ) + + if job == schedule_bulk_generate_irn: + msg = _('E-Invoices will be generated in a background process.') + else: + msg = _('E-Invoices will be cancelled in a background process.') + + frappe.msgprint(msg, alert=1) + +def check_scheduler_status(): + if is_scheduler_inactive() and not frappe.flags.in_test: + frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive")) + +def job_already_enqueued(job_name): + enqueued_jobs = [d.get("job_name") for d in get_info()] + if job_name in enqueued_jobs: + return True \ No newline at end of file diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index bf0712f0a55..ec58fd2f33c 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -51,7 +51,7 @@ def create_hsn_codes(data, code_field): def add_custom_roles_for_reports(): for report_name in ('GST Sales Register', 'GST Purchase Register', - 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill'): + 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill', 'E-Invoice Summary'): if not frappe.db.get_value('Custom Role', dict(report=report_name)): frappe.get_doc(dict( @@ -418,21 +418,37 @@ def make_custom_fields(update=True): dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1, depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'), - dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='irn', no_copy=1, print_hide=1), - - dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), - dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1, depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1, depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), - dict(fieldname='signed_einvoice', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type', + print_hide=1, hidden=1), + + dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section', + no_copy=1, print_hide=1), + + dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), - dict(fieldname='signed_qr_code', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date', + no_copy=1, print_hide=1), - dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, no_copy=1, print_hide=1, read_only=1) + dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image', + options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON', + hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1) ] custom_fields = { diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 082a32f3ff8..7709ddf42cd 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -41,24 +41,25 @@ def validate_gstin_for_india(doc, method): return if len(doc.gstin) != 15: - frappe.throw(_("Invalid GSTIN! A GSTIN must have 15 characters.")) + frappe.throw(_("A GSTIN must have 15 characters."), title=_("Invalid GSTIN")) if gst_category and gst_category == 'UIN Holders': if not GSTIN_UIN_FORMAT.match(doc.gstin): - frappe.throw(_("Invalid GSTIN! The input you've entered doesn't match the GSTIN format for UIN Holders or Non-Resident OIDAR Service Providers")) + frappe.throw(_("The input you've entered doesn't match the GSTIN format for UIN Holders or Non-Resident OIDAR Service Providers"), + title=_("Invalid GSTIN")) else: if not GSTIN_FORMAT.match(doc.gstin): - frappe.throw(_("Invalid GSTIN! The input you've entered doesn't match the format of GSTIN.")) + frappe.throw(_("The input you've entered doesn't match the format of GSTIN."), title=_("Invalid GSTIN")) validate_gstin_check_digit(doc.gstin) set_gst_state_and_state_number(doc) if not doc.gst_state: - frappe.throw(_("Please Enter GST state")) + frappe.throw(_("Please enter GST state"), title=_("Invalid State")) if doc.gst_state_number != doc.gstin[:2]: - frappe.throw(_("Invalid GSTIN! First 2 digits of GSTIN should match with State number {0}.") - .format(doc.gst_state_number)) + frappe.throw(_("First 2 digits of GSTIN should match with State number {0}.") + .format(doc.gst_state_number), title=_("Invalid GSTIN")) def validate_pan_for_india(doc, method): if doc.get('country') != 'India' or not doc.pan: diff --git a/erpnext/regional/report/e_invoice_summary/__init__.py b/erpnext/regional/report/e_invoice_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js new file mode 100644 index 00000000000..4713217d83c --- /dev/null +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js @@ -0,0 +1,55 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["E-Invoice Summary"] = { + "filters": [ + { + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "fieldname": "company", + "label": __("Company"), + "default": frappe.defaults.get_user_default("Company"), + }, + { + "fieldtype": "Link", + "options": "Customer", + "fieldname": "customer", + "label": __("Customer") + }, + { + "fieldtype": "Date", + "reqd": 1, + "fieldname": "from_date", + "label": __("From Date"), + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1), + }, + { + "fieldtype": "Date", + "reqd": 1, + "fieldname": "to_date", + "label": __("To Date"), + "default": frappe.datetime.get_today(), + }, + { + "fieldtype": "Select", + "fieldname": "status", + "label": __("Status"), + "options": "\nPending\nGenerated\nCancelled\nFailed" + } + ], + + "formatter": function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + + if (column.fieldname == "einvoice_status" && value) { + if (value == 'Pending') value = `${value}`; + else if (value == 'Generated') value = `${value}`; + else if (value == 'Cancelled') value = `${value}`; + else if (value == 'Failed') value = `${value}`; + } + + return value; + } +}; diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json new file mode 100644 index 00000000000..4deb073a53d --- /dev/null +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json @@ -0,0 +1,28 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-03-12 11:23:37.312294", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "letter_head": "Logo", + "modified": "2021-03-12 12:36:48.689413", + "modified_by": "Administrator", + "module": "Regional", + "name": "E-Invoice Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Sales Invoice", + "report_name": "E-Invoice Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Administrator" + } + ] +} \ No newline at end of file diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py new file mode 100644 index 00000000000..47acf291a39 --- /dev/null +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py @@ -0,0 +1,106 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ + +def execute(filters=None): + validate_filters(filters) + + columns = get_columns() + data = get_data(filters) + + return columns, data + +def validate_filters(filters={}): + filters = frappe._dict(filters) + + if not filters.company: + frappe.throw(_('{} is mandatory for generating E-Invoice Summary Report').format(_('Company')), title=_('Invalid Filter')) + if filters.company: + # validate if company has e-invoicing enabled + pass + if not filters.from_date or not filters.to_date: + frappe.throw(_('From Date & To Date is mandatory for generating E-Invoice Summary Report'), title=_('Invalid Filter')) + if filters.from_date > filters.to_date: + frappe.throw(_('From Date must be before To Date'), title=_('Invalid Filter')) + +def get_data(filters={}): + query_filters = { + 'posting_date': ['between', [filters.from_date, filters.to_date]], + 'einvoice_status': ['is', 'set'], + 'company': filters.company + } + if filters.customer: + query_filters['customer'] = filters.customer + if filters.status: + query_filters['einvoice_status'] = filters.status + + data = frappe.get_all( + 'Sales Invoice', + filters=query_filters, + fields=[d.get('fieldname') for d in get_columns()] + ) + + return data + +def get_columns(): + return [ + { + "fieldtype": "Date", + "fieldname": "posting_date", + "label": _("Posting Date"), + "width": 0 + }, + { + "fieldtype": "Link", + "fieldname": "name", + "label": _("Sales Invoice"), + "options": "Sales Invoice", + "width": 140 + }, + { + "fieldtype": "Data", + "fieldname": "einvoice_status", + "label": _("Status"), + "width": 100 + }, + { + "fieldtype": "Link", + "fieldname": "customer", + "options": "Customer", + "label": _("Customer") + }, + { + "fieldtype": "Check", + "fieldname": "is_return", + "label": _("Is Return"), + "width": 85 + }, + { + "fieldtype": "Data", + "fieldname": "ack_no", + "label": "Ack. No.", + "width": 145 + }, + { + "fieldtype": "Data", + "fieldname": "ack_date", + "label": "Ack. Date", + "width": 165 + }, + { + "fieldtype": "Data", + "fieldname": "irn", + "label": _("IRN No."), + "width": 250 + }, + { + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "fieldname": "base_grand_total", + "label": _("Grand Total"), + "width": 120 + } + ] \ No newline at end of file From a19c98255f89d40d6defdf356a72c7e3853a96d8 Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 12 Apr 2021 19:02:28 +0530 Subject: [PATCH 12/13] fix: patch condition (#25301) --- erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py index fe284e814c8..3b560fd43a1 100644 --- a/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py +++ b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py @@ -12,5 +12,5 @@ def execute(): select dl.link_name from `tabAddress` a, `tabDynamic Link` dl where a.gstin = %s and dl.parent = a.name and dl.link_doctype = 'Company' """, (creds.get('gstin'))) - if company_name[0]: + if company_name and len(company_name) == 1: frappe.db.set_value('E Invoice User', creds.get('name'), 'company', company_name[0][0]) \ No newline at end of file From c65751d97f262637535251225bfca089a2390f10 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 12 Apr 2021 19:45:34 +0530 Subject: [PATCH 13/13] test: ignore floating point errors in test_bom --- erpnext/manufacturing/doctype/bom/test_bom.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index cd61d2a31d2..7108338dab4 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -93,15 +93,15 @@ class TestBOM(unittest.TestCase): base_raw_material_cost = raw_material_cost * flt(bom.conversion_rate, bom.precision("conversion_rate")) base_op_cost = op_cost * flt(bom.conversion_rate, bom.precision("conversion_rate")) - # test amounts in selected currency - self.assertEqual(bom.operating_cost, op_cost) - self.assertEqual(bom.raw_material_cost, raw_material_cost) - self.assertEqual(bom.total_cost, raw_material_cost + op_cost) + # test amounts in selected currency, almostEqual checks for 7 digits by default + self.assertAlmostEqual(bom.operating_cost, op_cost) + self.assertAlmostEqual(bom.raw_material_cost, raw_material_cost) + self.assertAlmostEqual(bom.total_cost, raw_material_cost + op_cost) # test amounts in selected currency - self.assertEqual(bom.base_operating_cost, base_op_cost) - self.assertEqual(bom.base_raw_material_cost, base_raw_material_cost) - self.assertEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + self.assertAlmostEqual(bom.base_operating_cost, base_op_cost) + self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) + self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) def test_bom_cost_multi_uom_multi_currency_based_on_price_list(self): frappe.db.set_value("Price List", "_Test Price List", "price_not_uom_dependent", 1)