From e82609315054d220e72effa2d1a6d649af205aa9 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 13 May 2022 14:08:22 +0530 Subject: [PATCH 01/14] fix: multiple entries for same payment term --- .../payment_terms_status_for_sales_order.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py index cb22fb6a80..91f4a5e50a 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -187,8 +187,9 @@ def get_so_with_invoices(filters): .on(soi.parent == so.name) .join(ps) .on(ps.parent == so.name) + .select(so.name) + .distinct() .select( - so.name, so.customer, so.transaction_date.as_("submitted"), ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"), From 65d55ea8fa9df485eb998a972e6b2baf5ea9a224 Mon Sep 17 00:00:00 2001 From: maharshivpatel <39730881+maharshivpatel@users.noreply.github.com> Date: Wed, 18 May 2022 11:52:31 +0530 Subject: [PATCH 02/14] fix(india): eway bill cancel api is disabled (#31055) --- erpnext/regional/india/e_invoice/einvoice.js | 67 ++++++-------------- erpnext/regional/india/e_invoice/utils.py | 14 ++-- 2 files changed, 28 insertions(+), 53 deletions(-) diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js index ea56d07d6d..4748b265dc 100644 --- a/erpnext/regional/india/e_invoice/einvoice.js +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -149,58 +149,27 @@ 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, - 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 - }, - freeze: true, - callback: () => { - frappe.show_alert({ - message: __('E-Way Bill Cancelled successfully'), - indicator: 'green' - }, 7); - frm.reload_doc(); - d.hide(); - }, - error: () => { - frappe.show_alert({ - message: __('E-Way Bill was not Cancelled'), - indicator: 'red' - }, 7); - d.hide(); - } - }); + 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.'); + + const dialog = frappe.msgprint({ + title: __('Update E-Way Bill Cancelled Status?'), + message: message, + indicator: 'orange', + primary_action: { + action: function() { + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill', + args: { doctype, docname: name }, + freeze: true, + callback: () => frm.reload_doc() && dialog.hide() + }); + } }, - 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 ed1002a129..7cc636e478 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -797,7 +797,8 @@ class GSPConnector: self.irn_details_url = self.base_url + "/enriched/ei/api/invoice/irn" self.generate_irn_url = self.base_url + "/enriched/ei/api/invoice" self.gstin_details_url = self.base_url + "/enriched/ei/api/master/gstin" - self.cancel_ewaybill_url = self.base_url + "/enriched/ei/api/ewayapi" + # cancel_ewaybill_url will only work if user have bought ewb api from adaequare. + self.cancel_ewaybill_url = self.base_url + "/enriched/ewb/ewayapi?action=CANEWB" self.generate_ewaybill_url = self.base_url + "/enriched/ei/api/ewaybill" self.get_qrcode_url = self.base_url + "/enriched/ei/others/qr/image" @@ -1185,6 +1186,7 @@ class GSPConnector: headers = self.get_headers() data = json.dumps({"ewbNo": eway_bill, "cancelRsnCode": reason, "cancelRmrk": remark}, indent=4) headers["username"] = headers["user_name"] + del headers["user_name"] try: res = self.make_request("post", self.cancel_ewaybill_url, headers, data) if res.get("success"): @@ -1358,9 +1360,13 @@ 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) +def cancel_eway_bill(doctype, docname): + # NOTE: cancel_eway_bill api is disabled by Adequare. + # gsp_connector = GSPConnector(doctype, docname) + # gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + + frappe.db.set_value(doctype, docname, "ewaybill", "") + frappe.db.set_value(doctype, docname, "eway_bill_cancelled", 1) @frappe.whitelist() From 9fb7b49b435887d7e48505830389ede0d740ccff Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 18 May 2022 11:53:00 +0530 Subject: [PATCH 03/14] fix(india): error while parsing e-invoice (#31053) --- erpnext/regional/india/e_invoice/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 7cc636e478..bcb3e4fb85 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -649,6 +649,8 @@ def make_einvoice(invoice): try: einvoice = safe_json_load(einvoice) einvoice = santize_einvoice_fields(einvoice) + except json.JSONDecodeError: + raise except Exception: show_link_to_error_log(invoice, einvoice) @@ -765,7 +767,9 @@ def safe_json_load(json_string): frappe.throw( _( "Error in input data. Please check for any special characters near following input:
{}" - ).format(snippet) + ).format(snippet), + title=_("Invalid JSON"), + exc=e, ) From 1de6b14d1541717a85599a3e1912f2050af05d6e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 19 May 2022 14:25:16 +0530 Subject: [PATCH 04/14] fix: remove "scrap %" field (#31069) This does nothing, there's scrap items table below that's actually used for specifying scrap. --- erpnext/manufacturing/doctype/bom/bom.js | 8 ++------ erpnext/manufacturing/doctype/bom_item/bom_item.json | 12 +----------- .../report/bom_explorer/bom_explorer.py | 4 +--- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 8a7634e24e..3d96f9c9c7 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -499,15 +499,11 @@ cur_frm.cscript.qty = function(doc) { cur_frm.cscript.rate = function(doc, cdt, cdn) { var d = locals[cdt][cdn]; - var scrap_items = false; - - if(cdt == 'BOM Scrap Item') { - scrap_items = true; - } + const is_scrap_item = cdt == "BOM Scrap Item"; if (d.bom_no) { frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item.")); - get_bom_material_detail(doc, cdt, cdn, scrap_items); + get_bom_material_detail(doc, cdt, cdn, is_scrap_item); } else { erpnext.bom.calculate_rm_cost(doc); erpnext.bom.calculate_scrap_materials_cost(doc); diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 3406215cbb..0a8ae7b4a7 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -33,7 +33,6 @@ "amount", "base_amount", "section_break_18", - "scrap", "qty_consumed_per_unit", "section_break_27", "has_variants", @@ -223,15 +222,6 @@ "fieldname": "section_break_18", "fieldtype": "Section Break" }, - { - "columns": 1, - "fieldname": "scrap", - "fieldtype": "Float", - "label": "Scrap %", - "oldfieldname": "scrap", - "oldfieldtype": "Currency", - "print_hide": 1 - }, { "fieldname": "qty_consumed_per_unit", "fieldtype": "Float", @@ -298,7 +288,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-24 16:57:57.020232", + "modified": "2022-05-19 02:32:43.785470", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index ac2f61c5de..2aa31be0f0 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -21,7 +21,7 @@ def get_exploded_items(bom, data, indent=0, qty=1): exploded_items = frappe.get_all( "BOM Item", filters={"parent": bom}, - fields=["qty", "bom_no", "qty", "scrap", "item_code", "item_name", "description", "uom"], + fields=["qty", "bom_no", "qty", "item_code", "item_name", "description", "uom"], ) for item in exploded_items: @@ -37,7 +37,6 @@ def get_exploded_items(bom, data, indent=0, qty=1): "qty": item.qty * qty, "uom": item.uom, "description": item.description, - "scrap": item.scrap, } ) if item.bom_no: @@ -64,5 +63,4 @@ def get_columns(): "fieldname": "description", "width": 150, }, - {"label": _("Scrap"), "fieldtype": "data", "fieldname": "scrap", "width": 100}, ] From 6d6616dbcdfec4171022bac9c0538c04a1fa5cd5 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 19 May 2022 14:25:47 +0530 Subject: [PATCH 05/14] fix: always update item_name for stock entry (#31068) If item_name is already set and for some reason becomes outdated then it's not updated in backend. Fix: always set item_name and stock_uom when fetching item details --- .../stock/doctype/stock_entry/stock_entry.py | 24 +++++++++---------- .../doctype/stock_entry/test_stock_entry.py | 18 ++++++++++++-- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 890ac476a7..5c35ed6c01 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -298,19 +298,17 @@ class StockEntry(StockController): for_update=True, ) - for f in ( - "uom", - "stock_uom", - "description", - "item_name", - "expense_account", - "cost_center", - "conversion_factor", - ): - if f == "stock_uom" or not item.get(f): - item.set(f, item_details.get(f)) - if f == "conversion_factor" and item.uom == item_details.get("stock_uom"): - item.set(f, item_details.get(f)) + reset_fields = ("stock_uom", "item_name") + for field in reset_fields: + item.set(field, item_details.get(field)) + + update_fields = ("uom", "description", "expense_account", "cost_center", "conversion_factor") + + for field in update_fields: + if not item.get(field): + item.set(field, item_details.get(field)) + if field == "conversion_factor" and item.uom == item_details.get("stock_uom"): + item.set(field, item_details.get(field)) if not item.transfer_qty and item.qty: item.transfer_qty = flt( diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 71baf9f53f..6f4c910c7f 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2,8 +2,6 @@ # License: GNU General Public License v3. See license.txt -import unittest - import frappe from frappe.permissions import add_user_permission, remove_user_permission from frappe.tests.utils import FrappeTestCase, change_settings @@ -12,6 +10,7 @@ from frappe.utils import flt, nowdate, nowtime from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.stock.doctype.item.test_item import ( create_item, + make_item, make_item_variant, set_item_variant_settings, ) @@ -1443,6 +1442,21 @@ class TestStockEntry(FrappeTestCase): self.assertEqual(mapped_se.items[0].basic_rate, 100) self.assertEqual(mapped_se.items[0].basic_amount, 200) + def test_stock_entry_item_details(self): + item = make_item() + + se = make_stock_entry( + item_code=item.name, qty=1, to_warehouse="_Test Warehouse - _TC", do_not_submit=True + ) + + self.assertEqual(se.items[0].item_name, item.item_name) + se.items[0].item_name = "wat" + se.items[0].stock_uom = "Kg" + se.save() + + self.assertEqual(se.items[0].item_name, item.item_name) + self.assertEqual(se.items[0].stock_uom, item.stock_uom) + def make_serialized_item(**args): args = frappe._dict(args) From 163085f2018b4231d126031dbaa0eaa84715ce21 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 9 May 2022 19:43:38 +0530 Subject: [PATCH 06/14] feat: payment ledger doctype created --- .../doctype/payment_ledger_entry/__init__.py | 0 .../payment_ledger_entry.js | 8 + .../payment_ledger_entry.json | 180 +++++++++++++++ .../payment_ledger_entry.py | 22 ++ .../test_payment_ledger_entry.py | 215 ++++++++++++++++++ 5 files changed, 425 insertions(+) create mode 100644 erpnext/accounts/doctype/payment_ledger_entry/__init__.py create mode 100644 erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.js create mode 100644 erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json create mode 100644 erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py create mode 100644 erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py diff --git a/erpnext/accounts/doctype/payment_ledger_entry/__init__.py b/erpnext/accounts/doctype/payment_ledger_entry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.js b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.js new file mode 100644 index 0000000000..5a7be8e5ab --- /dev/null +++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.js @@ -0,0 +1,8 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Payment Ledger Entry', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json new file mode 100644 index 0000000000..d96107678f --- /dev/null +++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json @@ -0,0 +1,180 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:PLE-{YY}-{MM}-{######}", + "creation": "2022-05-09 19:35:03.334361", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "posting_date", + "company", + "account_type", + "account", + "party_type", + "party", + "due_date", + "cost_center", + "finance_book", + "voucher_type", + "voucher_no", + "against_voucher_type", + "against_voucher_no", + "amount", + "account_currency", + "amount_in_account_currency", + "delinked" + ], + "fields": [ + { + "fieldname": "posting_date", + "fieldtype": "Date", + "label": "Posting Date" + }, + { + "fieldname": "account_type", + "fieldtype": "Select", + "label": "Account Type", + "options": "Receivable\nPayable" + }, + { + "fieldname": "account", + "fieldtype": "Link", + "label": "Account", + "options": "Account" + }, + { + "fieldname": "party_type", + "fieldtype": "Link", + "label": "Party Type", + "options": "DocType" + }, + { + "fieldname": "party", + "fieldtype": "Dynamic Link", + "label": "Party", + "options": "party_type" + }, + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Voucher Type", + "options": "DocType" + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Voucher No", + "options": "voucher_type" + }, + { + "fieldname": "against_voucher_type", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Against Voucher Type", + "options": "DocType" + }, + { + "fieldname": "against_voucher_no", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Against Voucher No", + "options": "against_voucher_type" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "Company:company:default_currency" + }, + { + "fieldname": "account_currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "amount_in_account_currency", + "fieldtype": "Currency", + "label": "Amount in Account Currency", + "options": "account_currency" + }, + { + "default": "0", + "fieldname": "delinked", + "fieldtype": "Check", + "in_list_view": 1, + "label": "DeLinked" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "due_date", + "fieldtype": "Date", + "label": "Due Date" + }, + { + "fieldname": "finance_book", + "fieldtype": "Link", + "label": "Finance Book", + "options": "Finance Book" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2022-05-19 18:04:44.609115", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Payment Ledger Entry", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Auditor", + "share": 1 + } + ], + "search_fields": "voucher_no, against_voucher_no", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py new file mode 100644 index 0000000000..43e19f4ae7 --- /dev/null +++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py @@ -0,0 +1,22 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class PaymentLedgerEntry(Document): + def validate_account(self): + valid_account = frappe.db.get_list( + "Account", + "name", + filters={"name": self.account, "account_type": self.account_type, "company": self.company}, + ignore_permissions=True, + ) + if not valid_account: + frappe.throw(_("{0} account is not of type {1}").format(self.account, self.account_type)) + + def validate(self): + self.validate_account() diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py new file mode 100644 index 0000000000..f874b75432 --- /dev/null +++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py @@ -0,0 +1,215 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest + +import frappe +from frappe import qb +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, nowdate + +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.party import get_party_account +from erpnext.stock.doctype.item.test_item import create_item + + +# class TestPaymentLedgerEntry(FrappeTestCase): +class TestPaymentLedgerEntry(unittest.TestCase): + def setUp(self): + self.create_company() + self.create_item() + self.create_customer() + self.clear_old_entries() + + # def tearDown(self): + # frappe.db.rollback() + + def create_company(self): + company_name = "_Test Payment Ledger" + company = None + if frappe.db.exists("Company", company_name): + company = frappe.get_doc("Company", company_name) + else: + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": company_name, + "country": "India", + "default_currency": "INR", + "create_chart_of_accounts_based_on": "Standard Template", + "chart_of_accounts": "Standard", + } + ) + company = company.save() + + self.company = company.name + self.cost_center = company.cost_center + self.warehouse = "All Warehouses - _PL" + self.income_account = "Sales - _PL" + self.expense_account = "Cost of Goods Sold - _PL" + self.debit_to = "Debtors - _PL" + self.creditors = "Creditors - _PL" + + # create bank account + if frappe.db.exists("Account", "HDFC - _PL"): + self.bank = "HDFC - _PL" + else: + bank_acc = frappe.get_doc( + { + "doctype": "Account", + "account_name": "HDFC", + "parent_account": "Bank Accounts - _PL", + "company": self.company, + } + ) + bank_acc.save() + self.bank = bank_acc.name + + def create_item(self): + item_name = "_Test PL Item" + item = create_item( + item_code=item_name, is_stock_item=0, company=self.company, warehouse=self.warehouse + ) + self.item = item if isinstance(item, str) else item.item_code + + def create_customer(self): + name = "_Test PL Customer" + if frappe.db.exists("Customer", name): + self.customer = name + else: + customer = frappe.new_doc("Customer") + customer.customer_name = name + customer.type = "Individual" + customer.save() + self.customer = customer.name + + def create_sales_invoice( + self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False + ): + """ + Helper function to populate default values in sales invoice + """ + sinv = create_sales_invoice( + qty=qty, + rate=rate, + company=self.company, + customer=self.customer, + item_code=self.item, + item_name=self.item, + cost_center=self.cost_center, + warehouse=self.warehouse, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + update_stock=0, + currency="INR", + is_pos=0, + is_return=0, + return_against=None, + income_account=self.income_account, + expense_account=self.expense_account, + do_not_save=do_not_save, + do_not_submit=do_not_submit, + ) + return sinv + + def create_payment_entry(self, amount=100, posting_date=nowdate()): + """ + Helper function to populate default values in payment entry + """ + payment = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.bank, + paid_amount=amount, + ) + payment.posting_date = posting_date + return payment + + def clear_old_entries(self): + doctype_list = [ + "GL Entry", + "Payment Ledger Entry", + "Sales Invoice", + "Purchase Invoice", + "Payment Entry", + "Journal Entry", + ] + for doctype in doctype_list: + qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run() + + def create_journal_entry( + self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None + ): + je = frappe.new_doc("Journal Entry") + je.posting_date = posting_date or nowdate() + je.company = self.company + je.user_remark = "test" + if not cost_center: + cost_center = self.cost_center + je.set( + "accounts", + [ + { + "account": acc1, + "cost_center": cost_center, + "debit_in_account_currency": amount if amount > 0 else 0, + "credit_in_account_currency": abs(amount) if amount < 0 else 0, + }, + { + "account": acc2, + "cost_center": cost_center, + "credit_in_account_currency": amount if amount > 0 else 0, + "debit_in_account_currency": abs(amount) if amount < 0 else 0, + }, + ], + ) + return je + + def test_create_all_types(self): + transaction_date = nowdate() + amount = 100 + # full payment using PE + si1 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + pe2 = get_payment_entry(si1.doctype, si1.name).save().submit() + + # partial payment of invoice using PE + si2 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + pe2 = get_payment_entry(si2.doctype, si2.name) + pe2.get("references")[0].allocated_amount = 50 + pe2.get("references")[0].outstanding_amount = 50 + pe2 = pe2.save().submit() + + # reconcile against return invoice + si3 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + cr_note1 = self.create_sales_invoice( + qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True + ) + cr_note1.is_return = 1 + cr_note1.return_against = si3.name + cr_note1 = cr_note1.save().submit() + + # reconcile against return invoice using JE + si4 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) + cr_note2 = self.create_sales_invoice( + qty=-1, rate=amount, posting_date=transaction_date, do_not_save=True, do_not_submit=True + ) + cr_note2.is_return = 1 + cr_note2 = cr_note2.save().submit() + je1 = self.create_journal_entry( + self.debit_to, self.debit_to, amount, posting_date=transaction_date + ) + je1.get("accounts")[0].party_type = je1.get("accounts")[1].party_type = "Customer" + je1.get("accounts")[0].party = je1.get("accounts")[1].party = self.customer + je1.get("accounts")[0].reference_type = cr_note2.doctype + je1.get("accounts")[0].reference_name = cr_note2.name + je1.get("accounts")[1].reference_type = si4.doctype + je1.get("accounts")[1].reference_name = si4.name + je1 = je1.save().submit() + + def test_dummy(self): + pass From 8e72f19bfb298c578979cc509f3b892e493d39d1 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 16 May 2022 14:33:28 +0530 Subject: [PATCH 07/14] feat: patch to migrate gl entries to payment ledger --- .../v14_0/migrate_gl_to_payment_ledger.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py new file mode 100644 index 0000000000..c2267aa9af --- /dev/null +++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py @@ -0,0 +1,38 @@ +import frappe +from frappe import qb + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_dimensions, + make_dimension_in_accounting_doctypes, +) +from erpnext.accounts.utils import create_payment_ledger_entry + + +def create_accounting_dimension_fields(): + dimensions_and_defaults = get_dimensions() + if dimensions_and_defaults: + for dimension in dimensions_and_defaults[0]: + make_dimension_in_accounting_doctypes(dimension, ["Payment Ledger Entry"]) + + +def execute(): + # create accounting dimension fields in Payment Ledger + create_accounting_dimension_fields() + + gl = qb.DocType("GL Entry") + accounts = frappe.db.get_list( + "Account", "name", filters={"account_type": ["in", ["Receivable", "Payable"]]}, as_list=True + ) + gl_entries = [] + if accounts: + # get all gl entries on receivable/payable accounts + gl_entries = ( + qb.from_(gl) + .select("*") + .where(gl.account.isin(accounts)) + .where(gl.is_cancelled == 0) + .run(as_dict=True) + ) + if gl_entries: + # create payment ledger entries for the accounts receivable/payable + create_payment_ledger_entry(gl_entries, 0) From 7b1cb6711d16e84cea410d9dd92682a5eb72f00c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 16 May 2022 14:32:58 +0530 Subject: [PATCH 08/14] refactor: include Payment ledger in accounting dimensions list --- erpnext/hooks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 813ac17ca0..1c4bbbc3fc 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -487,6 +487,7 @@ communication_doctypes = ["Customer", "Supplier"] accounting_dimension_doctypes = [ "GL Entry", + "Payment Ledger Entry", "Sales Invoice", "Purchase Invoice", "Payment Entry", From 451cf3a937fe6f95b2d245c581ecbf38cb82cf09 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 16 May 2022 14:29:58 +0530 Subject: [PATCH 09/14] refactor: helper class for ple creation and delinking Helper functions for delinking ple and for creating payment ledger entry for transactions on receivable/payable account types --- erpnext/accounts/utils.py | 102 +++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 405922e16e..1869cc7b29 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -7,7 +7,7 @@ from typing import List, Tuple import frappe import frappe.defaults -from frappe import _, throw +from frappe import _, qb, throw from frappe.model.meta import get_field_precision from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate @@ -15,6 +15,7 @@ import erpnext # imported to enable erpnext.accounts.utils.get_account_currency from erpnext.accounts.doctype.account.account import get_account_currency # noqa +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions from erpnext.stock import get_warehouse_account_map from erpnext.stock.utils import get_stock_value_on @@ -1345,3 +1346,102 @@ def check_and_delete_linked_reports(report): if icons: for icon in icons: frappe.delete_doc("Desktop Icon", icon) + + +def create_payment_ledger_entry(gl_entries, cancel=0): + if gl_entries: + ple = None + + # companies + account = qb.DocType("Account") + companies = list(set([x.company for x in gl_entries])) + + # receivable/payable account + accounts_with_types = ( + qb.from_(account) + .select(account.name, account.account_type) + .where( + (account.account_type.isin(["Receivable", "Payable"]) & (account.company.isin(companies))) + ) + .run(as_dict=True) + ) + receivable_or_payable_accounts = [y.name for y in accounts_with_types] + + def get_account_type(account): + for entry in accounts_with_types: + if entry.name == account: + return entry.account_type + + dr_or_cr = 0 + account_type = None + for gle in gl_entries: + if gle.account in receivable_or_payable_accounts: + account_type = get_account_type(gle.account) + if account_type == "Receivable": + dr_or_cr = gle.debit - gle.credit + dr_or_cr_account_currency = gle.debit_in_account_currency - gle.credit_in_account_currency + elif account_type == "Payable": + dr_or_cr = gle.credit - gle.debit + dr_or_cr_account_currency = gle.credit_in_account_currency - gle.debit_in_account_currency + + if cancel: + dr_or_cr *= -1 + dr_or_cr_account_currency *= -1 + + ple = frappe.get_doc( + { + "doctype": "Payment Ledger Entry", + "posting_date": gle.posting_date, + "company": gle.company, + "account_type": account_type, + "account": gle.account, + "party_type": gle.party_type, + "party": gle.party, + "cost_center": gle.cost_center, + "finance_book": gle.finance_book, + "due_date": gle.due_date, + "voucher_type": gle.voucher_type, + "voucher_no": gle.voucher_no, + "against_voucher_type": gle.against_voucher_type + if gle.against_voucher_type + else gle.voucher_type, + "against_voucher_no": gle.against_voucher if gle.against_voucher else gle.voucher_no, + "currency": gle.currency, + "amount": dr_or_cr, + "amount_in_account_currency": dr_or_cr_account_currency, + "delinked": True if cancel else False, + } + ) + + dimensions_and_defaults = get_dimensions() + if dimensions_and_defaults: + for dimension in dimensions_and_defaults[0]: + ple.set(dimension.fieldname, gle.get(dimension.fieldname)) + + if cancel: + delink_original_entry(ple) + ple.flags.ignore_permissions = 1 + ple.submit() + + +def delink_original_entry(pl_entry): + if pl_entry: + ple = qb.DocType("Payment Ledger Entry") + query = ( + qb.update(ple) + .set(ple.delinked, True) + .set(ple.modified, now()) + .set(ple.modified_by, frappe.session.user) + .where( + (ple.company == pl_entry.company) + & (ple.account_type == pl_entry.account_type) + & (ple.account == pl_entry.account) + & (ple.party_type == pl_entry.party_type) + & (ple.party == pl_entry.party) + & (ple.voucher_type == pl_entry.voucher_type) + & (ple.voucher_no == pl_entry.voucher_no) + & (ple.against_voucher_type == pl_entry.against_voucher_type) + & (ple.against_voucher_no == pl_entry.against_voucher_no) + ) + ) + query.run() From 59ed7c854dae6dddba2d6b547651c6044ba01c1e Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 16 May 2022 17:48:55 +0530 Subject: [PATCH 10/14] refactor: ignore linked Payment Ledger Entry in basic transactions --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 2 +- erpnext/accounts/doctype/payment_entry/payment_entry.py | 2 +- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 1 + .../accounts/doctype/purchase_invoice/purchase_invoice.py | 7 ++++++- erpnext/accounts/doctype/sales_invoice/sales_invoice.py | 7 ++++++- erpnext/buying/doctype/purchase_order/purchase_order.py | 1 + erpnext/hr/doctype/expense_claim/expense_claim.py | 2 +- erpnext/selling/doctype/sales_order/sales_order.py | 2 +- 8 files changed, 18 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index d28c3a8687..145118957b 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -94,7 +94,7 @@ class JournalEntry(AccountsController): unlink_ref_doc_from_payment_entries(self) unlink_ref_doc_from_salary_slip(self.name) - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") self.make_gl_entries(1) self.update_advance_paid() self.update_expense_claim() diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index a3a7be2958..a10a810d1d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -95,7 +95,7 @@ class PaymentEntry(AccountsController): self.set_status() def on_cancel(self): - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") self.make_gl_entries(cancel=1) self.update_expense_claim() self.update_outstanding_amounts() diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 94246e135b..9649f80dee 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -96,6 +96,7 @@ class POSInvoice(SalesInvoice): ) def on_cancel(self): + self.ignore_linked_doctypes = "Payment Ledger Entry" # run on cancel method of selling controller super(SalesInvoice, self).on_cancel() if not self.is_return and self.loyalty_program: diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index a1d86e2219..e6da6669ac 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1418,7 +1418,12 @@ class PurchaseInvoice(BuyingController): frappe.db.set(self, "status", "Cancelled") unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference) - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") + self.ignore_linked_doctypes = ( + "GL Entry", + "Stock Ledger Entry", + "Repost Item Valuation", + "Payment Ledger Entry", + ) self.update_advance_tax_references(cancel=1) def update_project(self): diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index f0880c19e3..a580d45acc 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -396,7 +396,12 @@ class SalesInvoice(SellingController): unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference) self.unlink_sales_invoice_from_timesheets() - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") + self.ignore_linked_doctypes = ( + "GL Entry", + "Stock Ledger Entry", + "Repost Item Valuation", + "Payment Ledger Entry", + ) def update_status_updater_args(self): if cint(self.update_stock): diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 9189f18373..44426ba43d 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -323,6 +323,7 @@ class PurchaseOrder(BuyingController): update_linked_doc(self.doctype, self.name, self.inter_company_order_reference) def on_cancel(self): + self.ignore_linked_doctypes = "Payment Ledger Entry" super(PurchaseOrder, self).on_cancel() if self.is_against_so(): diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py index 89d86c1bc7..589763c0a9 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/expense_claim.py @@ -105,7 +105,7 @@ class ExpenseClaim(AccountsController): def on_cancel(self): self.update_task_and_project() - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") if self.payable_account: self.make_gl_entries(cancel=True) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index b463213f50..7522e92a8a 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -232,7 +232,7 @@ class SalesOrder(SellingController): update_coupon_code_count(self.coupon_code, "used") def on_cancel(self): - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") super(SalesOrder, self).on_cancel() # Cannot cancel closed SO From e88897500717de19b595b582088cd39f24221870 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 16 May 2022 14:28:25 +0530 Subject: [PATCH 11/14] refactor: link payment ledger with gl entry creation --- erpnext/accounts/general_ledger.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 1598d914e2..b0513f16a5 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -14,6 +14,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget +from erpnext.accounts.utils import create_payment_ledger_entry class ClosedAccountingPeriod(frappe.ValidationError): @@ -34,6 +35,7 @@ def make_gl_entries( validate_disabled_accounts(gl_map) gl_map = process_gl_map(gl_map, merge_entries) if gl_map and len(gl_map) > 1: + create_payment_ledger_entry(gl_map) save_entries(gl_map, adv_adj, update_outstanding, from_repost) # Post GL Map proccess there may no be any GL Entries elif gl_map: @@ -479,6 +481,7 @@ def make_reverse_gl_entries( ).run(as_dict=1) if gl_entries: + create_payment_ledger_entry(gl_entries, cancel=1) validate_accounting_period(gl_entries) check_freezing_date(gl_entries[0]["posting_date"], adv_adj) set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"]) From dac678e3fc475e45afaea43ecbcb0584a3074555 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 16 May 2022 14:52:50 +0530 Subject: [PATCH 12/14] test: payment ledger entry --- .../test_payment_ledger_entry.py | 217 +++++++++++++++++- 1 file changed, 205 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py index f874b75432..a71b19e092 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py +++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py @@ -1,30 +1,27 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -import unittest - import frappe from frappe import qb from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_days, nowdate +from frappe.utils import nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice -from erpnext.accounts.party import get_party_account from erpnext.stock.doctype.item.test_item import create_item -# class TestPaymentLedgerEntry(FrappeTestCase): -class TestPaymentLedgerEntry(unittest.TestCase): +class TestPaymentLedgerEntry(FrappeTestCase): def setUp(self): + self.ple = qb.DocType("Payment Ledger Entry") self.create_company() self.create_item() self.create_customer() self.clear_old_entries() - # def tearDown(self): - # frappe.db.rollback() + def tearDown(self): + frappe.db.rollback() def create_company(self): company_name = "_Test Payment Ledger" @@ -170,12 +167,55 @@ class TestPaymentLedgerEntry(unittest.TestCase): ) return je - def test_create_all_types(self): + def test_payment_against_invoice(self): transaction_date = nowdate() amount = 100 + ple = self.ple + # full payment using PE si1 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) - pe2 = get_payment_entry(si1.doctype, si1.name).save().submit() + pe1 = get_payment_entry(si1.doctype, si1.name).save().submit() + + pl_entries = ( + qb.from_(ple) + .select( + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.amount, + ple.delinked, + ) + .where((ple.against_voucher_type == si1.doctype) & (ple.against_voucher_no == si1.name)) + .orderby(ple.creation) + .run(as_dict=True) + ) + + expected_values = [ + { + "voucher_type": si1.doctype, + "voucher_no": si1.name, + "against_voucher_type": si1.doctype, + "against_voucher_no": si1.name, + "amount": amount, + "delinked": 0, + }, + { + "voucher_type": pe1.doctype, + "voucher_no": pe1.name, + "against_voucher_type": si1.doctype, + "against_voucher_no": si1.name, + "amount": -amount, + "delinked": 0, + }, + ] + self.assertEqual(pl_entries[0], expected_values[0]) + self.assertEqual(pl_entries[1], expected_values[1]) + + def test_partial_payment_against_invoice(self): + ple = self.ple + transaction_date = nowdate() + amount = 100 # partial payment of invoice using PE si2 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) @@ -184,6 +224,47 @@ class TestPaymentLedgerEntry(unittest.TestCase): pe2.get("references")[0].outstanding_amount = 50 pe2 = pe2.save().submit() + pl_entries = ( + qb.from_(ple) + .select( + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.amount, + ple.delinked, + ) + .where((ple.against_voucher_type == si2.doctype) & (ple.against_voucher_no == si2.name)) + .orderby(ple.creation) + .run(as_dict=True) + ) + + expected_values = [ + { + "voucher_type": si2.doctype, + "voucher_no": si2.name, + "against_voucher_type": si2.doctype, + "against_voucher_no": si2.name, + "amount": amount, + "delinked": 0, + }, + { + "voucher_type": pe2.doctype, + "voucher_no": pe2.name, + "against_voucher_type": si2.doctype, + "against_voucher_no": si2.name, + "amount": -50, + "delinked": 0, + }, + ] + self.assertEqual(pl_entries[0], expected_values[0]) + self.assertEqual(pl_entries[1], expected_values[1]) + + def test_cr_note_against_invoice(self): + ple = self.ple + transaction_date = nowdate() + amount = 100 + # reconcile against return invoice si3 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) cr_note1 = self.create_sales_invoice( @@ -193,6 +274,47 @@ class TestPaymentLedgerEntry(unittest.TestCase): cr_note1.return_against = si3.name cr_note1 = cr_note1.save().submit() + pl_entries = ( + qb.from_(ple) + .select( + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.amount, + ple.delinked, + ) + .where((ple.against_voucher_type == si3.doctype) & (ple.against_voucher_no == si3.name)) + .orderby(ple.creation) + .run(as_dict=True) + ) + + expected_values = [ + { + "voucher_type": si3.doctype, + "voucher_no": si3.name, + "against_voucher_type": si3.doctype, + "against_voucher_no": si3.name, + "amount": amount, + "delinked": 0, + }, + { + "voucher_type": cr_note1.doctype, + "voucher_no": cr_note1.name, + "against_voucher_type": si3.doctype, + "against_voucher_no": si3.name, + "amount": -amount, + "delinked": 0, + }, + ] + self.assertEqual(pl_entries[0], expected_values[0]) + self.assertEqual(pl_entries[1], expected_values[1]) + + def test_je_against_inv_and_note(self): + ple = self.ple + transaction_date = nowdate() + amount = 100 + # reconcile against return invoice using JE si4 = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date) cr_note2 = self.create_sales_invoice( @@ -211,5 +333,76 @@ class TestPaymentLedgerEntry(unittest.TestCase): je1.get("accounts")[1].reference_name = si4.name je1 = je1.save().submit() - def test_dummy(self): - pass + pl_entries_for_invoice = ( + qb.from_(ple) + .select( + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.amount, + ple.delinked, + ) + .where((ple.against_voucher_type == si4.doctype) & (ple.against_voucher_no == si4.name)) + .orderby(ple.creation) + .run(as_dict=True) + ) + + expected_values = [ + { + "voucher_type": si4.doctype, + "voucher_no": si4.name, + "against_voucher_type": si4.doctype, + "against_voucher_no": si4.name, + "amount": amount, + "delinked": 0, + }, + { + "voucher_type": je1.doctype, + "voucher_no": je1.name, + "against_voucher_type": si4.doctype, + "against_voucher_no": si4.name, + "amount": -amount, + "delinked": 0, + }, + ] + self.assertEqual(pl_entries_for_invoice[0], expected_values[0]) + self.assertEqual(pl_entries_for_invoice[1], expected_values[1]) + + pl_entries_for_crnote = ( + qb.from_(ple) + .select( + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.amount, + ple.delinked, + ) + .where( + (ple.against_voucher_type == cr_note2.doctype) & (ple.against_voucher_no == cr_note2.name) + ) + .orderby(ple.creation) + .run(as_dict=True) + ) + + expected_values = [ + { + "voucher_type": cr_note2.doctype, + "voucher_no": cr_note2.name, + "against_voucher_type": cr_note2.doctype, + "against_voucher_no": cr_note2.name, + "amount": -amount, + "delinked": 0, + }, + { + "voucher_type": je1.doctype, + "voucher_no": je1.name, + "against_voucher_type": cr_note2.doctype, + "against_voucher_no": cr_note2.name, + "amount": amount, + "delinked": 0, + }, + ] + self.assertEqual(pl_entries_for_crnote[0], expected_values[0]) + self.assertEqual(pl_entries_for_crnote[1], expected_values[1]) From e625394488626e35f5f18af843554fe240e1f0d2 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 21 May 2022 12:40:59 +0530 Subject: [PATCH 13/14] test: simplify job card tests --- .../doctype/job_card/test_job_card.py | 103 ++++++++---------- 1 file changed, 47 insertions(+), 56 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 4647ddf05f..45d221f0f7 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -1,6 +1,9 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt + +from typing import Literal + import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import random_string @@ -17,34 +20,36 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry class TestJobCard(FrappeTestCase): def setUp(self): make_bom_for_jc_tests() + self.transfer_material_against: Literal["Work Order", "Job Card"] = "Work Order" + self.source_warehouse = None + self._work_order = None - transfer_material_against, source_warehouse = None, None + @property + def work_order(self): + """Work Order lazily created for tests.""" + if not self._work_order: + self._work_order = make_wo_order_test_record( + item="_Test FG Item 2", + qty=2, + transfer_material_against=self.transfer_material_against, + source_warehouse=self.source_warehouse, + ) + return self._work_order - tests_that_skip_setup = ("test_job_card_material_transfer_correctness",) - tests_that_transfer_against_jc = ( - "test_job_card_multiple_materials_transfer", - "test_job_card_excess_material_transfer", - "test_job_card_partial_material_transfer", - ) - - if self._testMethodName in tests_that_skip_setup: - return - - if self._testMethodName in tests_that_transfer_against_jc: - transfer_material_against = "Job Card" - source_warehouse = "Stores - _TC" - - self.work_order = make_wo_order_test_record( - item="_Test FG Item 2", - qty=2, - transfer_material_against=transfer_material_against, - source_warehouse=source_warehouse, - ) + def generate_required_stock(self, work_order: WorkOrder) -> None: + """Create twice the stock for all required items in work order.""" + for item in work_order.required_items: + make_stock_entry( + item_code=item.item_code, + target=item.source_warehouse or self.source_warehouse, + qty=item.required_qty * 2, + basic_rate=100, + ) def tearDown(self): frappe.db.rollback() - def test_job_card(self): + def test_job_card_operations(self): job_cards = frappe.get_all( "Job Card", filters={"work_order": self.work_order.name}, fields=["operation_id", "name"] @@ -58,9 +63,6 @@ class TestJobCard(FrappeTestCase): doc.operation_id = "Test Data" self.assertRaises(OperationMismatchError, doc.save) - for d in job_cards: - frappe.delete_doc("Job Card", d.name) - def test_job_card_with_different_work_station(self): job_cards = frappe.get_all( "Job Card", @@ -96,19 +98,11 @@ class TestJobCard(FrappeTestCase): ) self.assertEqual(completed_qty, job_card.for_quantity) - doc.cancel() - - for d in job_cards: - frappe.delete_doc("Job Card", d.name) - def test_job_card_overlap(self): wo2 = make_wo_order_test_record(item="_Test FG Item 2", qty=2) - jc1_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name}) - jc2_name = frappe.db.get_value("Job Card", {"work_order": wo2.name}) - - jc1 = frappe.get_doc("Job Card", jc1_name) - jc2 = frappe.get_doc("Job Card", jc2_name) + jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name}) employee = "_T-Employee-00001" # from test records @@ -137,10 +131,10 @@ class TestJobCard(FrappeTestCase): def test_job_card_multiple_materials_transfer(self): "Test transferring RMs separately against Job Card with multiple RMs." - make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100) - make_stock_entry( - item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=6, basic_rate=100 - ) + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" + + self.generate_required_stock(self.work_order) job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name}) job_card = frappe.get_doc("Job Card", job_card_name) @@ -167,22 +161,21 @@ class TestJobCard(FrappeTestCase): def test_job_card_excess_material_transfer(self): "Test transferring more than required RM against Job Card." - make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100) - make_stock_entry( - item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100 - ) + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" - job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name}) - job_card = frappe.get_doc("Job Card", job_card_name) + self.generate_required_stock(self.work_order) + + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) self.assertEqual(job_card.status, "Open") # fully transfer both RMs - transfer_entry_1 = make_stock_entry_from_jc(job_card_name) + transfer_entry_1 = make_stock_entry_from_jc(job_card.name) transfer_entry_1.insert() transfer_entry_1.submit() # transfer extra qty of both RM due to previously damaged RM - transfer_entry_2 = make_stock_entry_from_jc(job_card_name) + transfer_entry_2 = make_stock_entry_from_jc(job_card.name) # deliberately change 'For Quantity' transfer_entry_2.fg_completed_qty = 1 transfer_entry_2.items[0].qty = 5 @@ -195,7 +188,7 @@ class TestJobCard(FrappeTestCase): # Check if 'For Quantity' is negative # as 'transferred_qty' > Qty to Manufacture - transfer_entry_3 = make_stock_entry_from_jc(job_card_name) + transfer_entry_3 = make_stock_entry_from_jc(job_card.name) self.assertEqual(transfer_entry_3.fg_completed_qty, 0) job_card.append( @@ -210,17 +203,15 @@ class TestJobCard(FrappeTestCase): def test_job_card_partial_material_transfer(self): "Test partial material transfer against Job Card" + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" - make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100) - make_stock_entry( - item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100 - ) + self.generate_required_stock(self.work_order) - job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name}) - job_card = frappe.get_doc("Job Card", job_card_name) + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) # partially transfer - transfer_entry = make_stock_entry_from_jc(job_card_name) + transfer_entry = make_stock_entry_from_jc(job_card.name) transfer_entry.fg_completed_qty = 1 transfer_entry.get_items() transfer_entry.insert() @@ -232,7 +223,7 @@ class TestJobCard(FrappeTestCase): self.assertEqual(transfer_entry.items[1].qty, 3) # transfer remaining - transfer_entry_2 = make_stock_entry_from_jc(job_card_name) + transfer_entry_2 = make_stock_entry_from_jc(job_card.name) self.assertEqual(transfer_entry_2.fg_completed_qty, 1) self.assertEqual(transfer_entry_2.items[0].qty, 5) From 66cf9aa3441d709c910204be1bedeea1437d7620 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sat, 21 May 2022 12:09:47 +0530 Subject: [PATCH 14/14] fix: creation of corrective job card fails This used to fail because sub_operations is a child table that's not initalized by default till v13, in develop branch we init tables with empty list. --- .../doctype/job_card/job_card.py | 1 + .../doctype/job_card/test_job_card.py | 56 +++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index a98fc94868..b13e4e0c04 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -866,6 +866,7 @@ def make_corrective_job_card(source_name, operation=None, for_operation=None, ta target.set("time_logs", []) target.set("employee", []) target.set("items", []) + target.set("sub_operations", []) target.set_sub_operations() target.get_required_items() target.validate_time_logs() diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 45d221f0f7..25a03eaf03 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -5,14 +5,20 @@ from typing import Literal import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import random_string +from frappe.utils.data import add_to_date, now -from erpnext.manufacturing.doctype.job_card.job_card import OperationMismatchError, OverlapError +from erpnext.manufacturing.doctype.job_card.job_card import ( + OperationMismatchError, + OverlapError, + make_corrective_job_card, +) from erpnext.manufacturing.doctype.job_card.job_card import ( make_stock_entry as make_stock_entry_from_jc, ) from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record +from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -25,7 +31,7 @@ class TestJobCard(FrappeTestCase): self._work_order = None @property - def work_order(self): + def work_order(self) -> WorkOrder: """Work Order lazily created for tests.""" if not self._work_order: self._work_order = make_wo_order_test_record( @@ -268,7 +274,49 @@ class TestJobCard(FrappeTestCase): self.assertEqual(transfer_entry.items[0].item_code, "_Test Item") self.assertEqual(transfer_entry.items[0].qty, 2) - # rollback via tearDown method + @change_settings( + "Manufacturing Settings", {"add_corrective_operation_cost_in_finished_good_valuation": 1} + ) + def test_corrective_costing(self): + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + + job_card.append( + "time_logs", + {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2}, + ) + job_card.submit() + + self.work_order.reload() + original_cost = self.work_order.total_operating_cost + + # Create a corrective operation against it + corrective_action = frappe.get_doc( + doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash() + ).insert() + + corrective_job_card = make_corrective_job_card( + job_card.name, operation=corrective_action.name, for_operation=job_card.operation + ) + corrective_job_card.hour_rate = 100 + corrective_job_card.insert() + corrective_job_card.append( + "time_logs", + { + "from_time": add_to_date(now(), hours=2), + "to_time": add_to_date(now(), hours=2, minutes=30), + "completed_qty": 2, + }, + ) + corrective_job_card.submit() + + self.work_order.reload() + cost_after_correction = self.work_order.total_operating_cost + self.assertGreater(cost_after_correction, original_cost) + + corrective_job_card.cancel() + self.work_order.reload() + cost_after_cancel = self.work_order.total_operating_cost + self.assertEqual(cost_after_cancel, original_cost) def create_bom_with_multiple_operations():