From 2dabd2182f32802e8b7e211cdac07ef56c251eb2 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Thu, 10 Sep 2020 17:05:43 +0530 Subject: [PATCH 01/32] feat: add mpesa integration --- .../doctype/mpesa_settings/__init__.py | 0 .../doctype/mpesa_settings/mpesa_connector.py | 12 ++ .../doctype/mpesa_settings/mpesa_settings.js | 8 ++ .../mpesa_settings/mpesa_settings.json | 110 ++++++++++++++++++ .../doctype/mpesa_settings/mpesa_settings.py | 29 +++++ .../mpesa_settings/test_mpesa_settings.py | 10 ++ 6 files changed, 169 insertions(+) create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py new file mode 100644 index 0000000000..9252f5dc26 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -0,0 +1,12 @@ +import requests +import datetime + +class MpesaConnector(): + def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke", + live_url="https://safaricom.co.ke"): + self.env = env + self.app_key = app_key + self.app_secret = app_secret + self.sandbox_url = sandbox_url + self.live_url = live_url + self.authenticate() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js new file mode 100644 index 0000000000..8a1c1912cf --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Mpesa Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json new file mode 100644 index 0000000000..9c0bef1584 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json @@ -0,0 +1,110 @@ +{ + "actions": [], + "autoname": "field:payment_gateway_name", + "creation": "2020-09-10 13:21:27.398088", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "payment_gateway_name", + "consumer_key", + "consumer_secret", + "column_break_4", + "till_number", + "online_passkey", + "sandbox" + ], + "fields": [ + { + "fieldname": "payment_gateway_name", + "fieldtype": "Data", + "label": "Payment Gateway Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "consumer_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Consumer Key", + "reqd": 1 + }, + { + "fieldname": "consumer_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Consumer Secret", + "reqd": 1 + }, + { + "fieldname": "till_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Till Number", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "sandbox", + "fieldtype": "Check", + "label": "Sandbox" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "online_passkey", + "fieldtype": "Password", + "label": " Online PassKey", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-10 09:07:28.557461", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "Mpesa Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py new file mode 100644 index 0000000000..de5df1f2fd --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + + +from __future__ import unicode_literals +import json +import requests +from six.moves.urllib.parse import urlencode + +import frappe +from frappe.model.document import Document +from frappe import _ +from frappe.utils import get_url, call_hook_method, cint, flt, cstr +from frappe.integrations.utils import create_request_log, create_payment_gateway +from frappe.utils import get_request_site_address +from frappe.utils.password import get_decrypted_password +from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector + +class MpesaSettings(Document): + supported_currencies = ["KSh"] + + def validate(self): + create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) + call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name) + + def validate_transaction_currency(self, currency): + if currency not in self.supported_currencies: + frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency)) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py new file mode 100644 index 0000000000..4aa970ef8a --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestMpesaSettings(unittest.TestCase): + pass From 40d3add63c4c30c3261e25d1b55ce8dc5858c563 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 14 Sep 2020 15:14:36 +0530 Subject: [PATCH 02/32] feat(payment-gateway-account): add additional payment channel --- .../payment_gateway_account.json | 371 ++++-------------- 1 file changed, 78 insertions(+), 293 deletions(-) diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json index 8dc2628820..12e6f5ef22 100644 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json +++ b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json @@ -1,313 +1,98 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2015-12-23 21:31:52.699821", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "creation": "2015-12-23 21:31:52.699821", + "doctype": "DocType", + "editable_grid": 1, + "field_order": [ + "payment_gateway", + "payment_channel", + "is_default", + "column_break_4", + "payment_account", + "currency", + "payment_request_message", + "message", + "message_examples" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_gateway", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, + "fieldname": "payment_gateway", + "fieldtype": "Link", "in_list_view": 1, - "in_standard_filter": 0, - "label": "Payment Gateway", - "length": 0, - "no_copy": 0, - "options": "Payment Gateway", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "label": "Payment Gateway", + "options": "Payment Gateway", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "is_default", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Default", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_4", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_account", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, + "fieldname": "payment_account", + "fieldtype": "Link", "in_list_view": 1, - "in_standard_filter": 0, - "label": "Payment Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "label": "Payment Account", + "options": "Account", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fetch_from": "payment_account.account_currency", "fieldname": "currency", - "fieldtype": "Read Only", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Currency", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldtype": "Read Only", + "label": "Currency" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_request_message", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval: doc.payment_channel !== \"Phone\"", + "fieldname": "payment_request_message", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Please click on the link below to make your payment", - "fieldname": "message", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Default Payment Request Message", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Please click on the link below to make your payment", + "fieldname": "message", + "fieldtype": "Small Text", + "label": "Default Payment Request Message" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "message_examples", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Message Examples", - "length": 0, - "no_copy": 0, - "options": "
Message Example
\n\n<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p>\n\n<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p>\n\n<p> We don't want you to be spending time running around in order to pay for your Bill.
After all, life is beautiful and the time you have in hand should be spent to enjoy it!
So here are our little ways to help you get more time for life! </p>\n\n<a href=\"{{ payment_url }}\"> click here to pay </a>\n\n
\n", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "message_examples", + "fieldtype": "HTML", + "label": "Message Examples", + "options": "
Message Example
\n\n<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p>\n\n<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p>\n\n<p> We don't want you to be spending time running around in order to pay for your Bill.
After all, life is beautiful and the time you have in hand should be spent to enjoy it!
So here are our little ways to help you get more time for life! </p>\n\n<a href=\"{{ payment_url }}\"> click here to pay </a>\n\n
\n" + }, + { + "default": "Email", + "fieldname": "payment_channel", + "fieldtype": "Select", + "label": "Payment Channel", + "options": "\nEmail\nPhone" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-05-16 22:43:34.970491", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Payment Gateway Account", - "name_case": "", - "owner": "Administrator", + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-20 13:30:27.722852", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Payment Gateway Account", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file From 757fa5d010b61d9664c3f4f7bf1d651e40fecf60 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 14 Sep 2020 17:44:51 +0530 Subject: [PATCH 03/32] fix(shopping-cart-settings): filter payment gateway accounts --- .../doctype/shopping_cart_settings/shopping_cart_settings.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js index 21fa4c3065..20c6342d6c 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js +++ b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js @@ -7,6 +7,10 @@ frappe.ui.form.on("Shopping Cart Settings", { frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series; frm.refresh_field("quotation_series"); } + + frm.set_query('payment_gateway_account', function() { + return { 'filters': { 'payment_channel': "Email" } }; + }); }, enabled: function(frm) { if (frm.doc.enabled === 1) { From 27f81e06ea8e061b97eba7ccb4349a3a14aa7db0 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 15 Sep 2020 14:38:06 +0530 Subject: [PATCH 04/32] fix: create custom pos fields --- .../mpesa_settings/mpesa_custom_fields.py | 44 +++++++++++++++++++ .../doctype/mpesa_settings/mpesa_settings.py | 6 +++ erpnext/erpnext_integrations/utils.py | 20 +++++++++ 3 files changed, 70 insertions(+) create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py new file mode 100644 index 0000000000..f3410e1818 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -0,0 +1,44 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +def create_custom_pos_fields(): + """ + Create custom fields corresponding to POS Settings and POS Invoice + """ + pos_field = { + "POS Invoice": [ + { + "fieldname": "request_for_payment", + "label": "Request for Payment", + "fieldtype": "Button", + "hidden": 1, + "insert_after": "contact_email" + }, + ] + } + if not frappe.get_meta("POS Invoice").has_field("request_for_payment"): + create_custom_fields(pos_field) + + record_dict = [{ + "doctype": "POS Field", + "fieldname": "contact_mobile", + "label": "Mobile No", + "fieldtype": "Data", + "options": "Phone", + "parent": "POS Settings" + }, + { + "doctype": "POS Field", + "fieldname": "request_for_payment", + "label": "Request for Payment", + "fieldtype": "Button", + "parent": "POS Settings" + } + ] + create_pos_settings(record_dict) + +def create_pos_settings(record_dict): + for record in record_dict: + if frappe.db.exists("POS Field", {"fieldname": record.get("fieldname")}): + continue + frappe.get_doc(record).insert() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index de5df1f2fd..fb48cb5ff7 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -15,15 +15,21 @@ from frappe.utils import get_url, call_hook_method, cint, flt, cstr from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address from frappe.utils.password import get_decrypted_password +from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector +from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields class MpesaSettings(Document): supported_currencies = ["KSh"] def validate(self): create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) + create_mode_of_payment('Mpesa-' + self.payment_gateway_name) call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name) def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency)) + + def on_update(self): + create_custom_pos_fields() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 84f7f5a5d4..78a5fced77 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -3,6 +3,7 @@ import frappe from frappe import _ import base64, hashlib, hmac from six.moves.urllib.parse import urlparse +from erpnext import get_default_company def validate_webhooks_request(doctype, hmac_key, secret_key='secret'): def innerfn(fn): @@ -41,3 +42,22 @@ def get_webhook_address(connector_name, method, exclude_uri=False): server_url = '{uri.scheme}://{uri.netloc}/api/method/{endpoint}'.format(uri=urlparse(url), endpoint=endpoint) return server_url + +def create_mode_of_payment(gateway): + payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { + "payment_gateway": gateway + }, ['payment_account']) + + if not frappe.db.exists("Mode of Payment", gateway) and payment_gateway_account: + mode_of_payment = frappe.get_doc({ + "doctype": "Mode of Payment", + "mode_of_payment": gateway, + "enabled": 1, + "type": "General", + "account": { + "doctype": "Mode of Payment Account", + "company": get_default_company(), + "default_account": payment_gateway_account + } + }) + mode_of_payment.insert(ignore_permissions=True) \ No newline at end of file From 7126e179c39268fca4e4cc187276020f4d33e945 Mon Sep 17 00:00:00 2001 From: Saqib Date: Fri, 18 Sep 2020 15:27:17 +0530 Subject: [PATCH 05/32] fix: button click event not working in POS custom fields (#23358) --- .../doctype/pos_settings/pos_settings.js | 8 ++++---- .../selling/page/point_of_sale/pos_payment.js | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.js b/erpnext/accounts/doctype/pos_settings/pos_settings.js index 504941d8b6..05cb7f0b4b 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.js +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.js @@ -7,10 +7,10 @@ frappe.ui.form.on('POS Settings', { }, get_invoice_fields: function(frm) { - frappe.model.with_doctype("Sales Invoice", () => { - var fields = $.map(frappe.get_doc("DocType", "Sales Invoice").fields, function(d) { + frappe.model.with_doctype("POS Invoice", () => { + var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function(d) { if (frappe.model.no_value_type.indexOf(d.fieldtype) === -1 || - d.fieldtype === 'Table') { + ['Table', 'Button'].includes(d.fieldtype)) { return { label: d.label + ' (' + d.fieldtype + ')', value: d.fieldname }; } else { return null; @@ -25,7 +25,7 @@ frappe.ui.form.on('POS Settings', { frappe.ui.form.on("POS Field", { fieldname: function(frm, doctype, name) { var doc = frappe.get_doc(doctype, name); - var df = $.map(frappe.get_doc("DocType", "Sales Invoice").fields, function(d) { + var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function(d) { return doc.fieldname == d.fieldname ? d : null; })[0]; diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index e1c54f64a7..7f0cabed8b 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -70,13 +70,23 @@ erpnext.PointOfSale.Payment = class { this.$invoice_fields.append( `
` ); + let df_events = { + onchange: function() { frm.set_value(this.df.fieldname, this.value); } + } + if (df.fieldtype == "Button") { + df_events = { + click: function() { + if (frm.script_manager.has_handlers(df.fieldname, frm.doc.doctype)) { + frm.script_manager.trigger(df.fieldname, frm.doc.doctype, frm.doc.docname); + } + } + } + } this[`${df.fieldname}_field`] = frappe.ui.form.make_control({ df: { ...df, - onchange: function() { - frm.set_value(this.df.fieldname, this.value); - } + ...df_events }, parent: this.$invoice_fields.find(`.${df.fieldname}-field`), render_input: true, From a3ac4bf68184d11c6afbb8d6c10cfecf9b5d0658 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 18 Sep 2020 19:47:33 +0530 Subject: [PATCH 06/32] fix: create payment request via pos --- .../mode_of_payment/mode_of_payment.json | 9 ++++-- .../payment_request/payment_request.json | 18 +++++++++-- .../payment_request/payment_request.py | 30 ++++++++++++++----- .../doctype/pos_invoice/pos_invoice.py | 21 +++++++++++++ .../mpesa_settings/mpesa_custom_fields.py | 8 +++-- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json index f3df1f0bc9..27431919f4 100644 --- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json +++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:mode_of_payment", @@ -28,7 +29,7 @@ "fieldtype": "Select", "in_standard_filter": 1, "label": "Type", - "options": "Cash\nBank\nGeneral" + "options": "Cash\nBank\nGeneral\nPhone" }, { "fieldname": "accounts", @@ -45,8 +46,10 @@ ], "icon": "fa fa-credit-card", "idx": 1, - "modified": "2019-08-14 14:58:42.079115", - "modified_by": "sammish.thundiyil@gmail.com", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-18 17:57:23.835236", + "modified_by": "Administrator", "module": "Accounts", "name": "Mode of Payment", "owner": "harshada@webnotestech.com", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 8eadfd0b24..2ee356aaf4 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -48,6 +48,7 @@ "section_break_7", "payment_gateway", "payment_account", + "payment_channel", "payment_order", "amended_from" ], @@ -230,6 +231,7 @@ "label": "Recipient Message And Payment Details" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "print_format", "fieldtype": "Select", "label": "Print Format" @@ -241,6 +243,7 @@ "label": "To" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "subject", "fieldtype": "Data", "in_global_search": 1, @@ -277,16 +280,18 @@ "read_only": 1 }, { - "depends_on": "eval: doc.payment_request_type == 'Inward'", + "depends_on": "eval: doc.payment_request_type == 'Inward' || doc.payment_channel != \"Phone\"", "fieldname": "section_break_10", "fieldtype": "Section Break" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "message", "fieldtype": "Text", "label": "Message" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "message_examples", "fieldtype": "HTML", "label": "Message Examples", @@ -347,12 +352,21 @@ "options": "Payment Request", "print_hide": 1, "read_only": 1 + }, + { + "fetch_from": "payment_gateway_account.payment_channel", + "fieldname": "payment_channel", + "fieldtype": "Select", + "label": "Payment Channel", + "options": "\nEmail\nPhone", + "read_only": 1 } ], "in_create": 1, + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-07-17 14:06:42.185763", + "modified": "2020-09-18 12:24:14.178853", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Request", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index e93ec951fb..dcf302db6e 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -36,7 +36,7 @@ class PaymentRequest(Document): ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) if (hasattr(ref_doc, "order_type") \ and getattr(ref_doc, "order_type") != "Shopping Cart"): - ref_amount = get_amount(ref_doc) + ref_amount = get_amount(ref_doc, self.payment_account) if existing_payment_request_amount + flt(self.grand_total)> ref_amount: frappe.throw(_("Total Payment Request amount cannot be greater than {0} amount") @@ -76,11 +76,16 @@ class PaymentRequest(Document): or self.flags.mute_email: send_mail = False - if send_mail: + if send_mail and self.payment_channel != "Phone": self.set_payment_request_url() self.send_email() self.make_communication_entry() + elif self.payment_channel == "Phone": + controller = get_payment_gateway_controller(self.payment_gateway) + print(vars(self)) + controller.request_for_payment(**vars(self)) + def on_cancel(self): self.check_if_payment_entry_exists() self.set_as_cancelled() @@ -105,13 +110,14 @@ class PaymentRequest(Document): return False def set_payment_request_url(self): - if self.payment_account: + if self.payment_account and self.payment_channel != "Phone": self.payment_url = self.get_payment_url() if self.payment_url: self.db_set('payment_url', self.payment_url) - if self.payment_url or not self.payment_gateway_account: + if self.payment_url or not self.payment_gateway_account \ + or (self.payment_gateway_account and self.payment_channel == "Phone"): self.db_set('status', 'Initiated') def get_payment_url(self): @@ -280,7 +286,9 @@ def make_payment_request(**args): args = frappe._dict(args) ref_doc = frappe.get_doc(args.dt, args.dn) - grand_total = get_amount(ref_doc) + gateway_account = get_gateway_details(args) or frappe._dict() + + grand_total = get_amount(ref_doc, gateway_account.get("payment_account")) if args.loyalty_points and args.dt == "Sales Order": from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points)) @@ -288,8 +296,6 @@ def make_payment_request(**args): frappe.db.set_value("Sales Order", args.dn, "loyalty_amount", loyalty_amount, update_modified=False) grand_total = grand_total - loyalty_amount - gateway_account = get_gateway_details(args) or frappe._dict() - bank_account = (get_party_bank_account(args.get('party_type'), args.get('party')) if args.get('party_type') else '') @@ -314,6 +320,7 @@ def make_payment_request(**args): "payment_gateway_account": gateway_account.get("name"), "payment_gateway": gateway_account.get("payment_gateway"), "payment_account": gateway_account.get("payment_account"), + "payment_channel": gateway_account.get("payment_channel"), "payment_request_type": args.get("payment_request_type"), "currency": ref_doc.currency, "grand_total": grand_total, @@ -344,9 +351,10 @@ def make_payment_request(**args): return pr.as_dict() -def get_amount(ref_doc): +def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" dt = ref_doc.doctype + print(dt) if dt in ["Sales Order", "Purchase Order"]: grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) @@ -356,6 +364,12 @@ def get_amount(ref_doc): else: grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate + elif dt == "POS Invoice": + for pay in ref_doc.payments: + if pay.type == "Phone" and pay.account == payment_account: + grand_total = pay.amount + break + elif dt == "Fees": grand_total = ref_doc.outstanding_amount diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index ba68df7673..155b95e9d9 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -15,6 +15,7 @@ from erpnext.accounts.doctype.loyalty_program.loyalty_program import \ from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice, get_bank_cash_account, update_multi_mode_option from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos +from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from six import iteritems @@ -313,6 +314,26 @@ class POSInvoice(SalesInvoice): if not pay.account: pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account") + def create_payment_request(self): + for pay in self.payments: + + if pay.type == "Phone": + payment_gateway = frappe.db.get_value("Payment Gateway Account", { + "payment_account": pay.account, + }) + record = { + "payment_gateway": payment_gateway, + "dt": "POS Invoice", + "dn": self.name, + "payment_request_type": "Inward", + "party_type": "Customer", + "party": self.customer, + "recipient_id": self.contact_mobile, + "submit_doc": True + } + + return make_payment_request(**record) + @frappe.whitelist() def get_stock_availability(item_code, warehouse): latest_sle = frappe.db.sql("""select qty_after_transaction diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py index f3410e1818..0d3912e34d 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -25,14 +25,18 @@ def create_custom_pos_fields(): "label": "Mobile No", "fieldtype": "Data", "options": "Phone", - "parent": "POS Settings" + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" }, { "doctype": "POS Field", "fieldname": "request_for_payment", "label": "Request for Payment", "fieldtype": "Button", - "parent": "POS Settings" + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" } ] create_pos_settings(record_dict) From 97ab96c8bfec84a519dfcc8464834db3187b4cfd Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 22 Sep 2020 12:58:32 +0530 Subject: [PATCH 07/32] fix: handle api changes from callbacks --- .../payment_request/payment_request.py | 14 ++- erpnext/accounts/utils.py | 5 +- .../doctype/mpesa_settings/mpesa_connector.py | 112 +++++++++++++++++- .../doctype/mpesa_settings/mpesa_settings.js | 1 - .../doctype/mpesa_settings/mpesa_settings.py | 78 ++++++++++-- 5 files changed, 194 insertions(+), 16 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index dcf302db6e..41a135fb05 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -83,8 +83,17 @@ class PaymentRequest(Document): elif self.payment_channel == "Phone": controller = get_payment_gateway_controller(self.payment_gateway) - print(vars(self)) - controller.request_for_payment(**vars(self)) + payment_record = dict( + reference_doctype=self.reference_doctype, + reference_docname=self.reference_name, + grand_total=self.grand_total, + sender=self.email_to, + payment_request_name=self.name, + currency=self.currency, + payment_gateway=self.payment_gateway + ) + controller.validate_transaction_currency(self.currency) + controller.request_for_payment(**payment_record) def on_cancel(self): self.check_if_payment_entry_exists() @@ -354,7 +363,6 @@ def make_payment_request(**args): def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" dt = ref_doc.doctype - print(dt) if dt in ["Sales Order", "Purchase Order"]: grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 51ac7cfbfa..f6acd7236a 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -794,7 +794,7 @@ def get_children(doctype, parent, company, is_root=False): return acc -def create_payment_gateway_account(gateway): +def create_payment_gateway_account(gateway, payment_channel="Email"): from erpnext.setup.setup_wizard.operations.company_setup import create_bank_account company = frappe.db.get_value("Global Defaults", None, "default_company") @@ -829,7 +829,8 @@ def create_payment_gateway_account(gateway): "is_default": 1, "payment_gateway": gateway, "payment_account": bank_account.name, - "currency": bank_account.account_currency + "currency": bank_account.account_currency, + "payment_channel": payment_channel }).insert(ignore_permissions=True) except frappe.DuplicateEntryError: diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py index 9252f5dc26..d79cdaa539 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -1,4 +1,6 @@ +import base64 import requests +from requests.auth import HTTPBasicAuth import datetime class MpesaConnector(): @@ -7,6 +9,110 @@ class MpesaConnector(): self.env = env self.app_key = app_key self.app_secret = app_secret - self.sandbox_url = sandbox_url - self.live_url = live_url - self.authenticate() \ No newline at end of file + if env == "sandbox": + self.base_url = sandbox_url + else: + self.base_url = live_url + self.authenticate() + + def authenticate(self): + """ + To make Mpesa API calls, you will need to authenticate your app. This method is used to fetch the access token + required by Mpesa. Mpesa supports client_credentials grant type. To authorize your API calls to Mpesa, + you will need a Basic Auth over HTTPS authorization token. The Basic Auth string is a base64 encoded string + of your app's client key and client secret. + + Returns: + access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa. + """ + authenticate_uri = "/oauth/v1/generate?grant_type=client_credentials" + authenticate_url = "{0}{1}".format(self.base_url, authenticate_uri) + r = requests.get( + authenticate_url, + auth=HTTPBasicAuth(self.app_key, self.app_secret) + ) + self.authentication_token = r.json()['access_token'] + return r.json()['access_token'] + + def get_balance(self, initiator=None, security_credential=None, party_a=None, identifier_type=None, + remarks=None, queue_timeout_url=None,result_url=None): + """ + This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number). + Args: + initiator (str): Username used to authenticate the transaction. + security_credential (str): Generate from developer portal. + command_id (str): AccountBalance. + party_a (int): Till number being queried. + identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code) + remarks (str): Comments that are sent along with the transaction(maximum 100 characters). + queue_timeout_url (str): The url that handles information of timed out transactions. + result_url (str): The url that receives results from M-Pesa api call. + + Returns: + OriginatorConverstionID (str): The unique request ID for tracking a transaction. + ConversationID (str): The unique request ID returned by mpesa for each request made + ResponseDescription (str): Response Description message + """ + + payload = { + "Initiator": initiator, + "SecurityCredential": security_credential, + "CommandID": "AccountBalance", + "PartyA": party_a, + "IdentifierType": identifier_type, + "Remarks": remarks, + "QueueTimeOutURL": queue_timeout_url, + "ResultURL": result_url + } + headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"} + saf_url = "{0}{1}".format(self.base_url, "/mpesa/accountbalance/v1/query") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() + + def stk_push(self, business_shortcode=None, passcode=None, amount=None, callback_url=None, reference_code=None, + phone_number=None, description=None): + """ + This method uses Mpesa's Express API to initiate online payment on behalf of a customer. + Args: + business_shortcode (int): The short code of the organization. + passcode (str): Get from developer portal + amount (int): The amount being transacted + callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API. + reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type. + phone_number(int): The Mobile Number to receive the STK Pin Prompt. + description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters + + Success Response: + CustomerMessage(str): Messages that customers can understand. + CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request. + ResponseDescription(str): Describes Success or failure + MerchantRequestID(str): This is a global unique Identifier for any submitted payment request. + ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03 + + Error Reponse: + requestId(str): This is a unique requestID for the payment request + errorCode(str): This is a predefined code that indicates the reason for request failure. + errorMessage(str): This is a predefined code that indicates the reason for request failure. + """ + + time = str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "") + password = "{0}{1}{2}".format(str(business_shortcode), str(passcode), time) + encoded = base64.b64encode(bytes(password, encoding='utf8')) + payload = { + "BusinessShortCode": business_shortcode, + "Password": encoded.decode("utf-8"), + "Timestamp": time, + "TransactionType": "CustomerPayBillOnline", + "Amount": amount, + "PartyA": int(phone_number), + "PartyB": business_shortcode, + "PhoneNumber": int(phone_number), + "CallBackURL": callback_url, + "AccountReference": reference_code, + "TransactionDesc": description + } + headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"} + + saf_url = "{0}{1}".format(self.base_url, "/mpesa/stkpush/v1/processrequest") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 8a1c1912cf..48e0c0bd35 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -3,6 +3,5 @@ frappe.ui.form.on('Mpesa Settings', { // refresh: function(frm) { - // } }); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index fb48cb5ff7..c92c1b23bc 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -15,21 +15,85 @@ from frappe.utils import get_url, call_hook_method, cint, flt, cstr from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address from frappe.utils.password import get_decrypted_password +from frappe.utils import get_request_site_address from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields class MpesaSettings(Document): - supported_currencies = ["KSh"] - - def validate(self): - create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) - create_mode_of_payment('Mpesa-' + self.payment_gateway_name) - call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name) + supported_currencies = ["KES"] def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency)) def on_update(self): - create_custom_pos_fields() \ No newline at end of file + create_custom_pos_fields() + create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) + create_mode_of_payment('Mpesa-' + self.payment_gateway_name) + call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone") + + def request_for_payment(self, **kwargs): + response = frappe._dict(generate_stk_push(**kwargs)) + # check error response + if hasattr(response, "requestId"): + req_name = getattr(response, "requestId") + error = response + else: + # global checkout id used as request name + req_name = getattr(response, "CheckoutRequestID") + error = None + + create_request_log(kwargs, "Host", "Mpesa", req_name, error) + if error: + frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) + +def generate_stk_push(**kwargs): + args = frappe._dict(kwargs) + try: + callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction" + + mpesa_settings = frappe.get_doc("Mpesa Settings", args.payment_gateway[6:]) + env = "production" if not mpesa_settings.sandbox else "sandbox" + + connector = MpesaConnector(env=env, + app_key=mpesa_settings.consumer_key, + app_secret=mpesa_settings.get_password("consumer_secret")) + + response = connector.stk_push(business_shortcode=mpesa_settings.till_number, + passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, + callback_url=callback_url, reference_code=args.payment_request_name, + phone_number=args.sender, description="POS Payment") + + return response + + except Exception: + frappe.log_error(title=_("Mpesa Express Transaction Error")) + frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error")) + +@frappe.whitelist(allow_guest=True) +def verify_transaction(**kwargs): + """ Verify the transaction result received via callback """ + transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) + + checkout_id = getattr(transaction_response, "CheckoutRequestID") + request = frappe.get_doc("Integration Request", checkout_id) + transaction_data = frappe._dict(json.loads(request.data)) + + if transaction_response['ResultCode'] == 0: + if transaction_data.reference_doctype and transaction_data.reference_docname: + try: + frappe.get_doc(transaction_data.reference_doctype, + transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') + request.db_set('output', transaction_response) + request.db_set('status', 'Completed') + except Exception: + request.db_set('error', transaction_response) + request.db_set('status', 'Failed') + frappe.log_error(frappe.get_traceback()) + + else: + request.db_set('error', transaction_response) + request.db_set('status', 'Failed') + + frappe.publish_realtime('process_phone_payment', after_commit=True, user=request.owner, message=transaction_response) \ No newline at end of file From 4eb215badb73f6be93abde705566e1602f413b95 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Wed, 23 Sep 2020 15:55:21 +0530 Subject: [PATCH 08/32] fix: bind callback callback for realtime event --- .../doctype/payment_request/payment_request.py | 5 ++--- .../doctype/pos_invoice/pos_invoice.js | 17 +++++++++++++++++ .../selling/page/point_of_sale/pos_payment.js | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 41a135fb05..8eba647c59 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -84,11 +84,10 @@ class PaymentRequest(Document): elif self.payment_channel == "Phone": controller = get_payment_gateway_controller(self.payment_gateway) payment_record = dict( - reference_doctype=self.reference_doctype, - reference_docname=self.reference_name, + reference_doctype="Payment Request", + reference_docname=self.name, grand_total=self.grand_total, sender=self.email_to, - payment_request_name=self.name, currency=self.currency, payment_gateway=self.payment_gateway ) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 3be43044aa..bedf5e5eef 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -142,6 +142,23 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend( frm: cur_frm }) }, + + request_for_payment: function (frm) { + frm.save().then(() => { + frappe.dom.freeze(); + frappe.call({ + method: 'create_payment_request', + doc: frm.doc, + }) + .fail(() => { + frappe.dom.unfreeze(); + frappe.msgprint('Payment request failed'); + }) + .then(() => { + frappe.msgprint('Payment request sent successfully'); + }); + }); + } }) $.extend(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm })) diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 7f0cabed8b..35cd408b53 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -174,6 +174,24 @@ erpnext.PointOfSale.Payment = class { } }) + frappe.realtime.on("process_phone_payments", function(data) { + frappe.msgprint({message: 'help', title:'now'}) + // frappe.dom.unfreeze(); + // let message = data["ResultDesc"]; + // let title = __("Payment Failed"); + // const frm = me.events.get_frm(); + + // if (data["ResultCode"] == 0) { + // title = __("Payment Received"); + // $('[data-fieldname=request_for_payment]').text("Paid") + // } + + // frappe.msgprint({ + // "message": message, + // "title": title + // }); + }); + this.$payment_modes.on('click', '.shortcut', function(e) { const value = $(this).attr('data-value'); me.selected_mode.set_value(value); From a26c6b4c2d884202cd16901cfaf43a9b062c6d1f Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 25 Sep 2020 23:56:39 +0530 Subject: [PATCH 09/32] fix: fetch account balance info --- .../mpesa_settings/account_balance.html | 28 +++++ .../doctype/mpesa_settings/mpesa_settings.js | 25 ++++- .../mpesa_settings/mpesa_settings.json | 33 +++++- .../doctype/mpesa_settings/mpesa_settings.py | 104 +++++++++++++++--- 4 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html b/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html new file mode 100644 index 0000000000..2c4d4bbdec --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html @@ -0,0 +1,28 @@ + +{% if not jQuery.isEmptyObject(data) %} +
{{ __("Balance Details") }}
+ + + + + + + + + + + + {% for(const [key, value] of Object.entries(data)) { %} + + + + + + + + {% } %} + +
{{ __("Account Type") }}{{ __("Current Balance") }}{{ __("Available Balance") }}{{ __("Reserved Balance") }}{{ __("Uncleared Balance") }}
{%= key %} {%= value["current_balance"] %} {%= value["available_balance"] %} {%= value["reserved_balance"] %} {%= value["uncleared_balance"] %}
+{% else %} +

Account Balance Information Not Available.

+{% endif %} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 48e0c0bd35..239a0bc9b2 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -2,6 +2,27 @@ // For license information, please see license.txt frappe.ui.form.on('Mpesa Settings', { - // refresh: function(frm) { - // } + onload_post_render: function(frm) { + frm.events.setup_account_balance_html(frm); + }, + + get_account_balance: function(frm) { + if (!frm.initiator_name && !frm.security_credentials) return; + frappe.call({ + method: "get_account_balance_info", + doc: frm.doc + }); + }, + + setup_account_balance_html: function(frm) { + console.log(frm.doc.account_balance) + $("div").remove(".form-dashboard-section.custom"); + frm.dashboard.add_section( + frappe.render_template('account_balance', { + data: JSON.parse(frm.doc.account_balance) + }) + ); + frm.dashboard.show(); + } + }); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json index 9c0bef1584..fc7b310c08 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json @@ -9,10 +9,14 @@ "payment_gateway_name", "consumer_key", "consumer_secret", - "column_break_4", + "initiator_name", "till_number", + "sandbox", + "column_break_4", "online_passkey", - "sandbox" + "security_credential", + "get_account_balance", + "account_balance" ], "fields": [ { @@ -58,11 +62,32 @@ "fieldtype": "Password", "label": " Online PassKey", "reqd": 1 + }, + { + "fieldname": "initiator_name", + "fieldtype": "Data", + "label": "Initiator Name" + }, + { + "fieldname": "security_credential", + "fieldtype": "Small Text", + "label": "Security Credential" + }, + { + "fieldname": "account_balance", + "fieldtype": "Long Text", + "hidden": 1, + "label": "Account Balance", + "read_only": 1 + }, + { + "fieldname": "get_account_balance", + "fieldtype": "Button", + "label": "Get Account Balance" } ], - "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-10 09:07:28.557461", + "modified": "2020-09-25 20:21:38.215494", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "Mpesa Settings", diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index c92c1b23bc..3af0baaa50 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -4,17 +4,14 @@ from __future__ import unicode_literals -import json -import requests -from six.moves.urllib.parse import urlencode +from json import loads, dumps import frappe from frappe.model.document import Document from frappe import _ -from frappe.utils import get_url, call_hook_method, cint, flt, cstr +from frappe.utils import call_hook_method from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address -from frappe.utils.password import get_decrypted_password from frappe.utils import get_request_site_address from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector @@ -35,16 +32,29 @@ class MpesaSettings(Document): def request_for_payment(self, **kwargs): response = frappe._dict(generate_stk_push(**kwargs)) + self.handle_api_response("CheckoutRequestID", kwargs, response) + + def get_account_balance_info(self): + payload = dict( + reference_doctype="Mpesa Settings", + reference_docname=self.name, + doc_details=vars(self) + ) + response = frappe._dict(get_account_balance(payload)) + self.handle_api_response("ConversationID", payload, response) + + def handle_api_response(self, global_id, request_dict, response): # check error response - if hasattr(response, "requestId"): + if getattr(response, "requestId"): req_name = getattr(response, "requestId") error = response else: # global checkout id used as request name - req_name = getattr(response, "CheckoutRequestID") + req_name = getattr(response, global_id) error = None - create_request_log(kwargs, "Host", "Mpesa", req_name, error) + create_request_log(request_dict, "Host", "Mpesa", req_name, error) + if error: frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) @@ -76,24 +86,84 @@ def verify_transaction(**kwargs): """ Verify the transaction result received via callback """ transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) - checkout_id = getattr(transaction_response, "CheckoutRequestID") + checkout_id = getattr(transaction_response, "CheckoutRequestID", "") request = frappe.get_doc("Integration Request", checkout_id) - transaction_data = frappe._dict(json.loads(request.data)) + transaction_data = frappe._dict(loads(request.data)) if transaction_response['ResultCode'] == 0: if transaction_data.reference_doctype and transaction_data.reference_docname: try: frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') - request.db_set('output', transaction_response) - request.db_set('status', 'Completed') + request.process_response('error', transaction_response) except Exception: - request.db_set('error', transaction_response) - request.db_set('status', 'Failed') + request.process_response('error', transaction_response) frappe.log_error(frappe.get_traceback()) else: - request.db_set('error', transaction_response) - request.db_set('status', 'Failed') + request.process_response('error', transaction_response) - frappe.publish_realtime('process_phone_payment', after_commit=True, user=request.owner, message=transaction_response) \ No newline at end of file + frappe.publish_realtime('process_phone_payment', after_commit=True, doctype=transaction_data.reference_doctype, + docname=transaction_data.reference_docname, user=request.owner, message=transaction_response) + +def get_account_balance(request_payload): + """ Call account balance API to send the request to the Mpesa Servers """ + try: + mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname")) + env = "production" if not mpesa_settings.sandbox else "sandbox" + connector = MpesaConnector(env=env, + app_key=mpesa_settings.consumer_key, + app_secret=mpesa_settings.get_password("consumer_secret")) + + # callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" + callback_url = "https://b014ca8e7957.ngrok.io/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" + + response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url) + return response + except Exception: + frappe.log_error(title=_("Account Balance Processing Error")) + frappe.throw(title=_("Error"), message=_("Please check your configuration and try again")) + +@frappe.whitelist(allow_guest=True) +def process_balance_info(**kwargs): + + account_balance_response = frappe._dict(kwargs["Result"]) + + conversation_id = getattr(account_balance_response, "ConversationID", "") + request = frappe.get_doc("Integration Request", conversation_id) + + if request.status == "Completed": + return + + transaction_data = frappe._dict(loads(request.data)) + frappe.logger().debug(account_balance_response) + + if account_balance_response["ResultCode"] == 0: + try: + result_params = account_balance_response["ResultParameters"]["ResultParameter"] + for param in result_params: + if param["Key"] == "AccountBalance": + balance_info = param["Value"] + balance_info = convert_to_json(balance_info) + + ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) + ref_doc.db_set("account_balance", balance_info) + + request.process_response('output', account_balance_response) + except: + request.process_response('error', account_balance_response) + frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) + else: + request.process_response('error', account_balance_response) + +def convert_to_json(balance_info): + balance_dict = frappe._dict() + for account_info in balance_info.split("&"): + account_info = account_info.split('|') + balance_dict[account_info[0]] = dict( + current_balance=account_info[2], + available_balance=account_info[3], + reserved_balance=account_info[4], + uncleared_balance=account_info[5] + ) + return dumps(balance_dict) \ No newline at end of file From 8d12c3841f7956b287ead3ce2bbcd5a7ab411429 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 28 Sep 2020 19:40:42 +0530 Subject: [PATCH 10/32] fix: process transaction callback --- .../doctype/pos_invoice/pos_invoice.js | 34 +++++----- .../doctype/pos_invoice/pos_invoice.json | 8 +-- .../doctype/mpesa_settings/mpesa_connector.py | 3 + .../mpesa_settings/mpesa_custom_fields.py | 11 +++- .../doctype/mpesa_settings/mpesa_settings.js | 1 - .../doctype/mpesa_settings/mpesa_settings.py | 65 +++++++++++++------ 6 files changed, 77 insertions(+), 45 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index bedf5e5eef..c43cb794aa 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -142,23 +142,6 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend( frm: cur_frm }) }, - - request_for_payment: function (frm) { - frm.save().then(() => { - frappe.dom.freeze(); - frappe.call({ - method: 'create_payment_request', - doc: frm.doc, - }) - .fail(() => { - frappe.dom.unfreeze(); - frappe.msgprint('Payment request failed'); - }) - .then(() => { - frappe.msgprint('Payment request sent successfully'); - }); - }); - } }) $.extend(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm })) @@ -218,5 +201,22 @@ frappe.ui.form.on('POS Invoice', { } frm.set_value("loyalty_amount", loyalty_amount); } + }, + + request_for_payment: function (frm) { + frm.save().then(() => { + frappe.dom.freeze(); + frappe.call({ + method: 'create_payment_request', + doc: frm.doc, + }) + .fail(() => { + frappe.dom.unfreeze(); + frappe.msgprint('Payment request failed'); + }) + .then(() => { + frappe.msgprint('Payment request sent successfully'); + }); + }); } }); \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 4780688471..1cff3c661d 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -279,8 +279,7 @@ "fieldtype": "Check", "label": "Is Return (Credit Note)", "no_copy": 1, - "print_hide": 1, - "set_only_once": 1 + "print_hide": 1 }, { "fieldname": "column_break1", @@ -461,7 +460,7 @@ }, { "fieldname": "contact_mobile", - "fieldtype": "Small Text", + "fieldtype": "Data", "hidden": 1, "label": "Mobile No", "read_only": 1 @@ -1579,10 +1578,9 @@ } ], "icon": "fa fa-file-text", - "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-09-07 12:43:09.138720", + "modified": "2020-09-28 16:51:24.641755", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py index d79cdaa539..dced7b0397 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -6,6 +6,7 @@ import datetime class MpesaConnector(): def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke", live_url="https://safaricom.co.ke"): + """Setup configuration for Mpesa connector and generate new access token.""" self.env = env self.app_key = app_key self.app_secret = app_secret @@ -38,6 +39,7 @@ class MpesaConnector(): remarks=None, queue_timeout_url=None,result_url=None): """ This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number). + Args: initiator (str): Username used to authenticate the transaction. security_credential (str): Generate from developer portal. @@ -73,6 +75,7 @@ class MpesaConnector(): phone_number=None, description=None): """ This method uses Mpesa's Express API to initiate online payment on behalf of a customer. + Args: business_shortcode (int): The short code of the organization. passcode (str): Get from developer portal diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py index 0d3912e34d..5d32a1c8f9 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -2,9 +2,7 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def create_custom_pos_fields(): - """ - Create custom fields corresponding to POS Settings and POS Invoice - """ + """Create custom fields corresponding to POS Settings and POS Invoice.""" pos_field = { "POS Invoice": [ { @@ -14,6 +12,13 @@ def create_custom_pos_fields(): "hidden": 1, "insert_after": "contact_email" }, + { + "fieldname": "Mpesa Receipt Number", + "label": "mpesa_receipt_number", + "fieldtype": "Data", + "read_only": 1, + "insert_after": "company" + } ] } if not frappe.get_meta("POS Invoice").has_field("request_for_payment"): diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 239a0bc9b2..7742a45746 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -15,7 +15,6 @@ frappe.ui.form.on('Mpesa Settings', { }, setup_account_balance_html: function(frm) { - console.log(frm.doc.account_balance) $("div").remove(".form-dashboard-section.custom"); frm.dashboard.add_section( frappe.render_template('account_balance', { diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 3af0baaa50..8fc05c65b1 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -9,10 +9,9 @@ from json import loads, dumps import frappe from frappe.model.document import Document from frappe import _ -from frappe.utils import call_hook_method +from frappe.utils import call_hook_method, fmt_money from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address -from frappe.utils import get_request_site_address from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields @@ -27,7 +26,7 @@ class MpesaSettings(Document): def on_update(self): create_custom_pos_fields() create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) - create_mode_of_payment('Mpesa-' + self.payment_gateway_name) + create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone") call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone") def request_for_payment(self, **kwargs): @@ -44,6 +43,8 @@ class MpesaSettings(Document): self.handle_api_response("ConversationID", payload, response) def handle_api_response(self, global_id, request_dict, response): + """Response received from API calls returns a global identifier for each transaction, + this code is returned during the callback""" # check error response if getattr(response, "requestId"): req_name = getattr(response, "requestId") @@ -59,6 +60,7 @@ class MpesaSettings(Document): frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) def generate_stk_push(**kwargs): + """Generate stk push by making a API call to the stk push API.""" args = frappe._dict(kwargs) try: callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction" @@ -70,6 +72,8 @@ def generate_stk_push(**kwargs): app_key=mpesa_settings.consumer_key, app_secret=mpesa_settings.get_password("consumer_secret")) + mobile_number = sanitize_mobile_number(args.sender) + response = connector.stk_push(business_shortcode=mpesa_settings.till_number, passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, callback_url=callback_url, reference_code=args.payment_request_name, @@ -81,10 +85,15 @@ def generate_stk_push(**kwargs): frappe.log_error(title=_("Mpesa Express Transaction Error")) frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error")) +def sanitize_mobile_number(number): + """Add country code and strip leading zeroes from the phone number.""" + return "254" + str(number).lstrip("0") + @frappe.whitelist(allow_guest=True) def verify_transaction(**kwargs): - """ Verify the transaction result received via callback """ + """ Verify the transaction result received via callback from stk """ transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) + frappe.logger().debug(transaction_response) checkout_id = getattr(transaction_response, "CheckoutRequestID", "") request = frappe.get_doc("Integration Request", checkout_id) @@ -93,9 +102,13 @@ def verify_transaction(**kwargs): if transaction_response['ResultCode'] == 0: if transaction_data.reference_doctype and transaction_data.reference_docname: try: - frappe.get_doc(transaction_data.reference_doctype, + doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') - request.process_response('error', transaction_response) + + item_response = transaction_response["CallbackMetadata"]["Item"] + mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + frappe.db.set_value("POS Invoice", doc.reference_docname, "mpesa_receipt_number", mpesa_receipt) + request.process_response('output', transaction_response) except Exception: request.process_response('error', transaction_response) frappe.log_error(frappe.get_traceback()) @@ -107,7 +120,7 @@ def verify_transaction(**kwargs): docname=transaction_data.reference_docname, user=request.owner, message=transaction_response) def get_account_balance(request_payload): - """ Call account balance API to send the request to the Mpesa Servers """ + """Call account balance API to send the request to the Mpesa Servers.""" try: mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname")) env = "production" if not mpesa_settings.sandbox else "sandbox" @@ -115,8 +128,7 @@ def get_account_balance(request_payload): app_key=mpesa_settings.consumer_key, app_secret=mpesa_settings.get_password("consumer_secret")) - # callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" - callback_url = "https://b014ca8e7957.ngrok.io/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" + callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url) return response @@ -126,7 +138,8 @@ def get_account_balance(request_payload): @frappe.whitelist(allow_guest=True) def process_balance_info(**kwargs): - + """Process and store account balance information received via callback + from the account balance API call.""" account_balance_response = frappe._dict(kwargs["Result"]) conversation_id = getattr(account_balance_response, "ConversationID", "") @@ -141,10 +154,9 @@ def process_balance_info(**kwargs): if account_balance_response["ResultCode"] == 0: try: result_params = account_balance_response["ResultParameters"]["ResultParameter"] - for param in result_params: - if param["Key"] == "AccountBalance": - balance_info = param["Value"] - balance_info = convert_to_json(balance_info) + + balance_info = fetch_param_value(result_params, "AccountBalance", "Key") + balance_info = convert_to_json(balance_info) ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) ref_doc.db_set("account_balance", balance_info) @@ -157,13 +169,28 @@ def process_balance_info(**kwargs): request.process_response('error', account_balance_response) def convert_to_json(balance_info): + """ + Convert string to json + + e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00''' + => {'Working Account': {'current_balance': '481000.00', + 'available_balance': '481000.00', + 'reserved_balance': '0.00', + 'uncleared_balance': '0.00'} + """ balance_dict = frappe._dict() for account_info in balance_info.split("&"): account_info = account_info.split('|') balance_dict[account_info[0]] = dict( - current_balance=account_info[2], - available_balance=account_info[3], - reserved_balance=account_info[4], - uncleared_balance=account_info[5] + current_balance=fmt_money(account_info[2], currency="KES"), + available_balance=fmt_money(account_info[3], currency="KES"), + reserved_balance=fmt_money(account_info[4], currency="KES"), + uncleared_balance=fmt_money(account_info[5], currency="KES") ) - return dumps(balance_dict) \ No newline at end of file + return dumps(balance_dict) + +def fetch_param_value(response, key, key_field): + """Fetch the specified key from list of dictionary. Key is identified via the key field""" + for param in response: + if param[key_field] == key: + return param["Value"] \ No newline at end of file From 1bef6a530ccddf486b8cbc65fe21db1c8b97c391 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Sun, 4 Oct 2020 13:01:11 +0530 Subject: [PATCH 11/32] fix: create payment entry against payment request --- .../doctype/payment_entry/payment_entry.py | 16 ++++++------ .../payment_request/payment_request.py | 8 +++--- .../doctype/pos_invoice/pos_invoice.py | 7 ++++- erpnext/erpnext_integrations/utils.py | 8 +++--- .../selling/page/point_of_sale/pos_payment.js | 26 +++++++++---------- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 11ab02021b..b7d80ae608 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -950,12 +950,12 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre @frappe.whitelist() -def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None): +def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None, mode_of_payment=None): doc = frappe.get_doc(dt, dn) if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) - if dt in ("Sales Invoice", "Sales Order", "Dunning"): + if dt in ("Sales Invoice", "Sales Order", "Dunning", "POS Invoice"): party_type = "Customer" elif dt in ("Purchase Invoice", "Purchase Order"): party_type = "Supplier" @@ -965,7 +965,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_type = "Student" # party account - if dt == "Sales Invoice": + if dt in ["Sales Invoice", "POS Invoice"]: party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to elif dt == "Purchase Invoice": party_account = doc.credit_to @@ -984,7 +984,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account) # payment type - if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)) \ + if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning", "POS Invoice") and doc.outstanding_amount > 0)) \ or (dt=="Purchase Invoice" and doc.outstanding_amount < 0): payment_type = "Receive" else: @@ -994,7 +994,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= grand_total = outstanding_amount = 0 if party_amount: grand_total = outstanding_amount = party_amount - elif dt in ("Sales Invoice", "Purchase Invoice"): + elif dt in ("Sales Invoice", "Purchase Invoice", "POS Invoice"): if party_account_currency == doc.company_currency: grand_total = doc.base_rounded_total or doc.base_grand_total else: @@ -1021,11 +1021,11 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= outstanding_amount = grand_total - flt(doc.advance_paid) # bank or cash - bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), + bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), account=bank_account) if not bank: - bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment"), + bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), account=bank_account) paid_amount = received_amount = 0 @@ -1050,7 +1050,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= pe.company = doc.company pe.cost_center = doc.get("cost_center") pe.posting_date = nowdate() - pe.mode_of_payment = doc.get("mode_of_payment") + pe.mode_of_payment = doc.get("mode_of_payment", mode_of_payment) pe.party_type = party_type pe.party = doc.get(scrub(party_type)) pe.contact_person = doc.get("contact_person") diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 8eba647c59..d01f298445 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -165,7 +165,7 @@ class PaymentRequest(Document): ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) - if self.reference_doctype == "Sales Invoice": + if self.reference_doctype in ["Sales Invoice", "POS Invoice"]: party_account = ref_doc.debit_to elif self.reference_doctype == "Purchase Invoice": party_account = ref_doc.credit_to @@ -180,8 +180,8 @@ class PaymentRequest(Document): else: party_amount = self.grand_total - payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, - party_amount=party_amount, bank_account=self.payment_account, bank_amount=bank_amount) + payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, party_amount=party_amount, + bank_account=self.payment_account, bank_amount=bank_amount, mode_of_payment=self.mode_of_payment) payment_entry.update({ "reference_no": self.name, @@ -269,7 +269,7 @@ class PaymentRequest(Document): # if shopping cart enabled and in session if (shopping_cart_settings.enabled and hasattr(frappe.local, "session") - and frappe.local.session.user != "Guest"): + and frappe.local.session.user != "Guest") and self.payment_channel != "Phone": success_url = shopping_cart_settings.payment_success_url if success_url: diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 155b95e9d9..73cf1188e2 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -316,8 +316,13 @@ class POSInvoice(SalesInvoice): def create_payment_request(self): for pay in self.payments: - if pay.type == "Phone": + if pay.amount <= 0: + frappe.throw(_("Payment amount cannot be less than or equal to 0")) + + if not self.contact_mobile: + frappe.throw(_("Please enter the phone number first")) + payment_gateway = frappe.db.get_value("Payment Gateway Account", { "payment_account": pay.account, }) diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 78a5fced77..e278fd7807 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -43,7 +43,7 @@ def get_webhook_address(connector_name, method, exclude_uri=False): return server_url -def create_mode_of_payment(gateway): +def create_mode_of_payment(gateway, payment_type="General"): payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { "payment_gateway": gateway }, ['payment_account']) @@ -53,11 +53,11 @@ def create_mode_of_payment(gateway): "doctype": "Mode of Payment", "mode_of_payment": gateway, "enabled": 1, - "type": "General", - "account": { + "type": payment_type, + "accounts": [{ "doctype": "Mode of Payment Account", "company": get_default_company(), "default_account": payment_gateway_account - } + }] }) mode_of_payment.insert(ignore_permissions=True) \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 35cd408b53..915564c029 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -175,21 +175,21 @@ erpnext.PointOfSale.Payment = class { }) frappe.realtime.on("process_phone_payments", function(data) { - frappe.msgprint({message: 'help', title:'now'}) - // frappe.dom.unfreeze(); - // let message = data["ResultDesc"]; - // let title = __("Payment Failed"); - // const frm = me.events.get_frm(); + frappe.dom.unfreeze(); + let message = data["ResultDesc"]; + let title = __("Payment Failed"); + const frm = me.events.get_frm(); - // if (data["ResultCode"] == 0) { - // title = __("Payment Received"); - // $('[data-fieldname=request_for_payment]').text("Paid") - // } + if (data["ResultCode"] == 0) { + title = __("Payment Received"); + $('[data-fieldname=request_for_payment]').text("Paid") + cur_pos.submit() + } - // frappe.msgprint({ - // "message": message, - // "title": title - // }); + frappe.msgprint({ + "message": message, + "title": title + }); }); this.$payment_modes.on('click', '.shortcut', function(e) { From 8d54d61c283abdfc557506086467ddb5e414c1e1 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 5 Oct 2020 22:37:31 +0530 Subject: [PATCH 12/32] fix: handle payment completion via payment request --- .../doctype/payment_entry/payment_entry.py | 16 +++--- .../payment_request/payment_request.js | 2 +- .../payment_request/payment_request.py | 14 ++++-- .../doctype/pos_invoice/pos_invoice.py | 14 ++++++ .../doctype/mpesa_settings/mpesa_settings.py | 15 +++--- .../selling/page/point_of_sale/pos_payment.js | 50 +++++++++---------- 6 files changed, 66 insertions(+), 45 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index b7d80ae608..11ab02021b 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -950,12 +950,12 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre @frappe.whitelist() -def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None, mode_of_payment=None): +def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None): doc = frappe.get_doc(dt, dn) if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) - if dt in ("Sales Invoice", "Sales Order", "Dunning", "POS Invoice"): + if dt in ("Sales Invoice", "Sales Order", "Dunning"): party_type = "Customer" elif dt in ("Purchase Invoice", "Purchase Order"): party_type = "Supplier" @@ -965,7 +965,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_type = "Student" # party account - if dt in ["Sales Invoice", "POS Invoice"]: + if dt == "Sales Invoice": party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to elif dt == "Purchase Invoice": party_account = doc.credit_to @@ -984,7 +984,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account) # payment type - if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning", "POS Invoice") and doc.outstanding_amount > 0)) \ + if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)) \ or (dt=="Purchase Invoice" and doc.outstanding_amount < 0): payment_type = "Receive" else: @@ -994,7 +994,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= grand_total = outstanding_amount = 0 if party_amount: grand_total = outstanding_amount = party_amount - elif dt in ("Sales Invoice", "Purchase Invoice", "POS Invoice"): + elif dt in ("Sales Invoice", "Purchase Invoice"): if party_account_currency == doc.company_currency: grand_total = doc.base_rounded_total or doc.base_grand_total else: @@ -1021,11 +1021,11 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= outstanding_amount = grand_total - flt(doc.advance_paid) # bank or cash - bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), + bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), account=bank_account) if not bank: - bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), + bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment"), account=bank_account) paid_amount = received_amount = 0 @@ -1050,7 +1050,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= pe.company = doc.company pe.cost_center = doc.get("cost_center") pe.posting_date = nowdate() - pe.mode_of_payment = doc.get("mode_of_payment", mode_of_payment) + pe.mode_of_payment = doc.get("mode_of_payment") pe.party_type = party_type pe.party = doc.get(scrub(party_type)) pe.contact_person = doc.get("contact_person") diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index e1e43140c0..5c7218608d 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -25,7 +25,7 @@ frappe.ui.form.on("Payment Request", "onload", function(frm, dt, dn){ }) frappe.ui.form.on("Payment Request", "refresh", function(frm) { - if(frm.doc.payment_request_type == 'Inward' && + if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel === "Phone" && !in_list(["Initiated", "Paid"], frm.doc.status) && !frm.doc.__islocal && frm.doc.docstatus==1){ frm.add_custom_button(__('Resend Payment Email'), function(){ frappe.call({ diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index d01f298445..ebe8cb1330 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -86,6 +86,7 @@ class PaymentRequest(Document): payment_record = dict( reference_doctype="Payment Request", reference_docname=self.name, + payment_reference=self.reference_name, grand_total=self.grand_total, sender=self.email_to, currency=self.currency, @@ -154,10 +155,14 @@ class PaymentRequest(Document): }) def set_as_paid(self): - payment_entry = self.create_payment_entry() - self.make_invoice() + if self.payment_channel == "Phone": + self.db_set("status", "Paid") - return payment_entry + else: + payment_entry = self.create_payment_entry() + self.make_invoice() + + return payment_entry def create_payment_entry(self, submit=True): """create entry""" @@ -269,7 +274,7 @@ class PaymentRequest(Document): # if shopping cart enabled and in session if (shopping_cart_settings.enabled and hasattr(frappe.local, "session") - and frappe.local.session.user != "Guest") and self.payment_channel != "Phone": + and frappe.local.session.user != "Guest") and self.payment_channel != "Phone": success_url = shopping_cart_settings.payment_success_url if success_url: @@ -332,6 +337,7 @@ def make_payment_request(**args): "payment_request_type": args.get("payment_request_type"), "currency": ref_doc.currency, "grand_total": grand_total, + "mode_of_payment": args.mode_of_payment, "email_to": args.recipient_id or ref_doc.owner, "subject": _("Payment Request for {0}").format(args.dn), "message": gateway_account.get("message") or get_dummy_message(ref_doc), diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 73cf1188e2..1a304ca3a8 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -58,6 +58,7 @@ class POSInvoice(SalesInvoice): against_psi_doc.make_loyalty_point_entry() if self.redeem_loyalty_points and self.loyalty_points: self.apply_loyalty_points() + self.check_phone_payments() self.set_status(update=True) def on_cancel(self): @@ -70,6 +71,18 @@ class POSInvoice(SalesInvoice): against_psi_doc.delete_loyalty_point_entry() against_psi_doc.make_loyalty_point_entry() + def check_phone_payments(self): + for pay in self.payments: + if pay.type == "Phone" and pay.amount >= 0: + paid_amt = frappe.db.get_value("Payment Request", + filters=dict( + reference_doctype="POS Invoice", reference_name=self.name, + mode_of_payment=pay.mode_of_payment, status="Paid"), + fieldname="grand_total") + + if pay.amount != paid_amt: + return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment)) + def validate_stock_availablility(self): allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') @@ -333,6 +346,7 @@ class POSInvoice(SalesInvoice): "payment_request_type": "Inward", "party_type": "Customer", "party": self.customer, + "mode_of_payment": pay.mode_of_payment, "recipient_id": self.contact_mobile, "submit_doc": True } diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 8fc05c65b1..6c36c16b61 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -76,7 +76,7 @@ def generate_stk_push(**kwargs): response = connector.stk_push(business_shortcode=mpesa_settings.till_number, passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, - callback_url=callback_url, reference_code=args.payment_request_name, + callback_url=callback_url, reference_code=mpesa_settings.till_number, phone_number=args.sender, description="POS Payment") return response @@ -100,14 +100,15 @@ def verify_transaction(**kwargs): transaction_data = frappe._dict(loads(request.data)) if transaction_response['ResultCode'] == 0: - if transaction_data.reference_doctype and transaction_data.reference_docname: + if request.reference_doctype and request.reference_docname: try: - doc = frappe.get_doc(transaction_data.reference_doctype, - transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') + doc = frappe.get_doc(request.reference_doctype, + request.reference_docname) + doc.run_method("on_payment_authorized", 'Completed') item_response = transaction_response["CallbackMetadata"]["Item"] mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") - frappe.db.set_value("POS Invoice", doc.reference_docname, "mpesa_receipt_number", mpesa_receipt) + frappe.db.set_value("POS Invoice", doc.reference_name, "mpesa_receipt_number", mpesa_receipt) request.process_response('output', transaction_response) except Exception: request.process_response('error', transaction_response) @@ -116,8 +117,8 @@ def verify_transaction(**kwargs): else: request.process_response('error', transaction_response) - frappe.publish_realtime('process_phone_payment', after_commit=True, doctype=transaction_data.reference_doctype, - docname=transaction_data.reference_docname, user=request.owner, message=transaction_response) + frappe.publish_realtime('process_phone_payment', doctype="POS Invoice", + docname=transaction_data.payment_reference, user=request.owner, message=transaction_response) def get_account_balance(request_payload): """Call account balance API to send the request to the Mpesa Servers.""" diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 915564c029..b1f7de00db 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -9,8 +9,8 @@ erpnext.PointOfSale.Payment = class { } init_component() { - this.prepare_dom(); - this.initialize_numpad(); + this.prepare_dom(); + this.initialize_numpad(); this.bind_events(); this.attach_shortcuts(); @@ -18,32 +18,32 @@ erpnext.PointOfSale.Payment = class { prepare_dom() { this.wrapper.append( - `
+ `
PAYMENT METHOD
-
-
-
-
-
-
-
-
-
-
- Complete Order +
+
+
+
+
+
+
+
+
+
+ Complete Order
-
-
-
-
` - ) - this.$component = this.wrapper.find('.payment-section'); + + + +
` + ) + this.$component = this.wrapper.find('.payment-section'); this.$payment_modes = this.$component.find('.payment-modes'); this.$totals_remarks = this.$component.find('.totals-remarks'); this.$totals = this.$component.find('.totals'); @@ -174,16 +174,16 @@ erpnext.PointOfSale.Payment = class { } }) - frappe.realtime.on("process_phone_payments", function(data) { + frappe.realtime.on("process_phone_payment", function(data) { + console.log('within') frappe.dom.unfreeze(); let message = data["ResultDesc"]; let title = __("Payment Failed"); - const frm = me.events.get_frm(); if (data["ResultCode"] == 0) { title = __("Payment Received"); - $('[data-fieldname=request_for_payment]').text("Paid") - cur_pos.submit() + $('.btn.btn-xs.btn-default[data-fieldname=request_for_payment]').html(`Payment Received`) + me.events.submit_invoice(); } frappe.msgprint({ @@ -527,5 +527,5 @@ erpnext.PointOfSale.Payment = class { toggle_component(show) { show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none'); - } + } } \ No newline at end of file From 862433febb392bec6b81c1132bac363e1c9f4126 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 6 Oct 2020 10:59:59 +0530 Subject: [PATCH 13/32] fix: create custom field for mpesa receipt --- .../doctype/mpesa_settings/mpesa_custom_fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py index 5d32a1c8f9..0499e88b5e 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -13,8 +13,8 @@ def create_custom_pos_fields(): "insert_after": "contact_email" }, { - "fieldname": "Mpesa Receipt Number", - "label": "mpesa_receipt_number", + "fieldname": "mpesa_receipt_number", + "label": "Mpesa Receipt Number", "fieldtype": "Data", "read_only": 1, "insert_after": "company" From faa0cce7a76613bd7a8277e2d94a8d2e3f2f3add Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 6 Oct 2020 13:58:54 +0530 Subject: [PATCH 14/32] fix: pass mobile number to generate the stk push --- .../doctype/mpesa_settings/mpesa_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 6c36c16b61..15606acc95 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -77,7 +77,7 @@ def generate_stk_push(**kwargs): response = connector.stk_push(business_shortcode=mpesa_settings.till_number, passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, callback_url=callback_url, reference_code=mpesa_settings.till_number, - phone_number=args.sender, description="POS Payment") + phone_number=mobile_number, description="POS Payment") return response From 51228b5ee3ea5543486f68f036b21a2ab9165e3d Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 13 Oct 2020 17:10:16 +0530 Subject: [PATCH 15/32] fix: make response handling more descriptive --- .../doctype/payment_request/payment_request.py | 2 +- .../doctype/mpesa_settings/mpesa_settings.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index ebe8cb1330..51c090cea8 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -186,7 +186,7 @@ class PaymentRequest(Document): party_amount = self.grand_total payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, party_amount=party_amount, - bank_account=self.payment_account, bank_amount=bank_amount, mode_of_payment=self.mode_of_payment) + bank_account=self.payment_account, bank_amount=bank_amount) payment_entry.update({ "reference_no": self.name, diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 15606acc95..8fe1972d14 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -109,13 +109,13 @@ def verify_transaction(**kwargs): item_response = transaction_response["CallbackMetadata"]["Item"] mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") frappe.db.set_value("POS Invoice", doc.reference_name, "mpesa_receipt_number", mpesa_receipt) - request.process_response('output', transaction_response) + request.handle_success(transaction_response) except Exception: - request.process_response('error', transaction_response) + request.handle_failure(transaction_response) frappe.log_error(frappe.get_traceback()) else: - request.process_response('error', transaction_response) + request.handle_failure(transaction_response) frappe.publish_realtime('process_phone_payment', doctype="POS Invoice", docname=transaction_data.payment_reference, user=request.owner, message=transaction_response) @@ -162,12 +162,12 @@ def process_balance_info(**kwargs): ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) ref_doc.db_set("account_balance", balance_info) - request.process_response('output', account_balance_response) + request.handle_success(account_balance_response) except: - request.process_response('error', account_balance_response) + request.handle_failure(account_balance_response) frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) else: - request.process_response('error', account_balance_response) + request.handle_failure(account_balance_response) def convert_to_json(balance_info): """ From cab5d22ef5cd9643e83f93c1e5005f4f09dac2fb Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 16 Oct 2020 11:38:07 +0530 Subject: [PATCH 16/32] fix: add a realtime publish to refresh the dashboard content --- .../doctype/mpesa_settings/mpesa_settings.js | 6 ++++++ .../doctype/mpesa_settings/mpesa_settings.py | 1 + erpnext/selling/page/point_of_sale/pos_payment.js | 1 - 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 7742a45746..c24a104fca 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -6,6 +6,12 @@ frappe.ui.form.on('Mpesa Settings', { frm.events.setup_account_balance_html(frm); }, + refresh: function(frm) { + frappe.realtime.on("refresh_mpesa_dashboard", function(){ + frm.reload_doc(); + }); + }, + get_account_balance: function(frm) { if (!frm.initiator_name && !frm.security_credentials) return; frappe.call({ diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 8fe1972d14..9d34a543e1 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -163,6 +163,7 @@ def process_balance_info(**kwargs): ref_doc.db_set("account_balance", balance_info) request.handle_success(account_balance_response) + frappe.publish_realtime("refresh_mpesa_dashboard") except: request.handle_failure(account_balance_response) frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index b1f7de00db..2053903943 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -175,7 +175,6 @@ erpnext.PointOfSale.Payment = class { }) frappe.realtime.on("process_phone_payment", function(data) { - console.log('within') frappe.dom.unfreeze(); let message = data["ResultDesc"]; let title = __("Payment Failed"); From c66fce4965bf26b010677ddc2fee1c4729077695 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 16 Oct 2020 12:03:08 +0530 Subject: [PATCH 17/32] fix: reload current pos doc after updating the information via callback --- .../doctype/mpesa_settings/mpesa_connector.py | 5 +---- .../doctype/mpesa_settings/mpesa_settings.py | 14 ++++++-------- erpnext/selling/page/point_of_sale/pos_payment.js | 1 + 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py index dced7b0397..d33b0a7089 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -18,10 +18,7 @@ class MpesaConnector(): def authenticate(self): """ - To make Mpesa API calls, you will need to authenticate your app. This method is used to fetch the access token - required by Mpesa. Mpesa supports client_credentials grant type. To authorize your API calls to Mpesa, - you will need a Basic Auth over HTTPS authorization token. The Basic Auth string is a base64 encoded string - of your app's client key and client secret. + This method is used to fetch the access token required by Mpesa. Returns: access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa. diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 9d34a543e1..1d318bbf04 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -43,8 +43,7 @@ class MpesaSettings(Document): self.handle_api_response("ConversationID", payload, response) def handle_api_response(self, global_id, request_dict, response): - """Response received from API calls returns a global identifier for each transaction, - this code is returned during the callback""" + """Response received from API calls returns a global identifier for each transaction, this code is returned during the callback.""" # check error response if getattr(response, "requestId"): req_name = getattr(response, "requestId") @@ -91,7 +90,7 @@ def sanitize_mobile_number(number): @frappe.whitelist(allow_guest=True) def verify_transaction(**kwargs): - """ Verify the transaction result received via callback from stk """ + """Verify the transaction result received via callback from stk.""" transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) frappe.logger().debug(transaction_response) @@ -139,8 +138,7 @@ def get_account_balance(request_payload): @frappe.whitelist(allow_guest=True) def process_balance_info(**kwargs): - """Process and store account balance information received via callback - from the account balance API call.""" + """Process and store account balance information received via callback from the account balance API call.""" account_balance_response = frappe._dict(kwargs["Result"]) conversation_id = getattr(account_balance_response, "ConversationID", "") @@ -164,7 +162,7 @@ def process_balance_info(**kwargs): request.handle_success(account_balance_response) frappe.publish_realtime("refresh_mpesa_dashboard") - except: + except Exception: request.handle_failure(account_balance_response) frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) else: @@ -172,7 +170,7 @@ def process_balance_info(**kwargs): def convert_to_json(balance_info): """ - Convert string to json + Convert string to json. e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00''' => {'Working Account': {'current_balance': '481000.00', @@ -192,7 +190,7 @@ def convert_to_json(balance_info): return dumps(balance_dict) def fetch_param_value(response, key, key_field): - """Fetch the specified key from list of dictionary. Key is identified via the key field""" + """Fetch the specified key from list of dictionary. Key is identified via the key field.""" for param in response: if param[key_field] == key: return param["Value"] \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 2053903943..ec886d7957 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -176,6 +176,7 @@ erpnext.PointOfSale.Payment = class { frappe.realtime.on("process_phone_payment", function(data) { frappe.dom.unfreeze(); + cur_frm.reload_doc(); let message = data["ResultDesc"]; let title = __("Payment Failed"); From ebb8ee3dc6a6c623e82659f76f6dac4d4200321e Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 19 Oct 2020 18:27:40 +0530 Subject: [PATCH 18/32] fix(payment gateway account): add patch to set the payment channel as email --- .../doctype/payment_request/payment_request.js | 2 +- erpnext/patches.txt | 1 + ...ayment_channel_in_payment_gateway_account.py | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index 5c7218608d..901ef1987b 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -25,7 +25,7 @@ frappe.ui.form.on("Payment Request", "onload", function(frm, dt, dn){ }) frappe.ui.form.on("Payment Request", "refresh", function(frm) { - if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel === "Phone" && + if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel !== "Phone" && !in_list(["Initiated", "Paid"], frm.doc.status) && !frm.doc.__islocal && frm.doc.docstatus==1){ frm.add_custom_button(__('Resend Payment Email'), function(){ frappe.call({ diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6087ce29aa..43564d8267 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -729,3 +729,4 @@ erpnext.patches.v13_0.setting_custom_roles_for_some_regional_reports erpnext.patches.v13_0.rename_issue_doctype_fields erpnext.patches.v13_0.change_default_pos_print_format erpnext.patches.v13_0.set_youtube_video_id +erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account \ No newline at end of file diff --git a/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py b/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py new file mode 100644 index 0000000000..edca238393 --- /dev/null +++ b/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + """Set the payment gateway account as Email for all the existing payment channel.""" + doc_meta = frappe.get_meta("Payment Gateway Account") + if doc_meta.get_field("payment_channel"): + return + + frappe.reload_doc("Accounts", "doctype", "Payment Gateway Account") + set_payment_channel_as_email() + +def set_payment_channel_as_email(): + frappe.db.sql(""" + UPDATE `tabPayment Gateway Account` + SET `payment_channel` = "Email" + """) \ No newline at end of file From c8a04fec35880e13e39b5c0b1fd9417eba356713 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Wed, 21 Oct 2020 14:07:00 +0530 Subject: [PATCH 19/32] fix(mpesa-settings): add test cases to verify transactions --- .../doctype/mpesa_settings/mpesa_settings.py | 30 ++- .../mpesa_settings/test_mpesa_settings.py | 236 +++++++++++++++++- 2 files changed, 254 insertions(+), 12 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 1d318bbf04..dea4d81770 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -26,11 +26,19 @@ class MpesaSettings(Document): def on_update(self): create_custom_pos_fields() create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) - create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone") call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone") + # required to fetch the bank account details from the payment gateway account + frappe.db.commit() + create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone") + def request_for_payment(self, **kwargs): - response = frappe._dict(generate_stk_push(**kwargs)) + if frappe.flags.in_test: + from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_payment_request_response_payload + response = frappe._dict(get_payment_request_response_payload()) + else: + response = frappe._dict(generate_stk_push(**kwargs)) + self.handle_api_response("CheckoutRequestID", kwargs, response) def get_account_balance_info(self): @@ -39,7 +47,13 @@ class MpesaSettings(Document): reference_docname=self.name, doc_details=vars(self) ) - response = frappe._dict(get_account_balance(payload)) + + if frappe.flags.in_test: + from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_test_account_balance_response + response = frappe._dict(get_test_account_balance_response()) + else: + response = frappe._dict(get_account_balance(payload)) + self.handle_api_response("ConversationID", payload, response) def handle_api_response(self, global_id, request_dict, response): @@ -92,7 +106,6 @@ def sanitize_mobile_number(number): def verify_transaction(**kwargs): """Verify the transaction result received via callback from stk.""" transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) - frappe.logger().debug(transaction_response) checkout_id = getattr(transaction_response, "CheckoutRequestID", "") request = frappe.get_doc("Integration Request", checkout_id) @@ -148,14 +161,13 @@ def process_balance_info(**kwargs): return transaction_data = frappe._dict(loads(request.data)) - frappe.logger().debug(account_balance_response) if account_balance_response["ResultCode"] == 0: try: result_params = account_balance_response["ResultParameters"]["ResultParameter"] balance_info = fetch_param_value(result_params, "AccountBalance", "Key") - balance_info = convert_to_json(balance_info) + balance_info = format_string_to_json(balance_info) ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) ref_doc.db_set("account_balance", balance_info) @@ -168,15 +180,15 @@ def process_balance_info(**kwargs): else: request.handle_failure(account_balance_response) -def convert_to_json(balance_info): +def format_string_to_json(balance_info): """ - Convert string to json. + Format string to json. e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00''' => {'Working Account': {'current_balance': '481000.00', 'available_balance': '481000.00', 'reserved_balance': '0.00', - 'uncleared_balance': '0.00'} + 'uncleared_balance': '0.00'}} """ balance_dict = frappe._dict() for account_info in balance_info.split("&"): diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 4aa970ef8a..55ccff30fe 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -2,9 +2,239 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt from __future__ import unicode_literals - -# import frappe +from json import dumps +import frappe import unittest +from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import process_balance_info, verify_transaction +from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice class TestMpesaSettings(unittest.TestCase): - pass + def test_creation_of_payment_gateway(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="_Test") + + mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test") + self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"})) + self.assertTrue(mode_of_payment.name) + self.assertEquals(mode_of_payment.type, "Phone") + + def test_processing_of_account_balance(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="_Account Balance") + mpesa_doc.get_account_balance_info() + + callback_response = get_account_balance_callback_payload() + process_balance_info(**callback_response) + integration_request = frappe.get_doc("Integration Request", "AG_20200927_00007cdb1f9fb6494315") + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEquals(integration_request.status, "Completed") + + # test formatting of account balance received as string to json with appropriate currency symbol + mpesa_doc.reload() + self.assertEquals(mpesa_doc.account_balance, dumps({ + "Working Account": { + "current_balance": "Sh 481,000.00", + "available_balance": "Sh 481,000.00", + "reserved_balance": "Sh 0.00", + "uncleared_balance": "Sh 0.00" + } + })) + + def test_processing_of_callback_payload(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="Payment") + mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") + frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") + + pos_invoice = create_pos_invoice(do_not_submit=1) + pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 500}) + pos_invoice.contact_mobile = "093456543894" + pos_invoice.currency = "KES" + pos_invoice.save() + + pr = pos_invoice.create_payment_request() + # test payment request creation + self.assertEquals(pr.payment_gateway, "Mpesa-Payment") + + callback_response = get_payment_callback_payload() + verify_transaction(**callback_response) + # test creation of integration request + integration_request = frappe.get_doc("Integration Request", "ws_CO_061020201133231972") + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEquals(integration_request.status, "Completed") + + pos_invoice.reload() + integration_request.reload() + self.assertEquals(pos_invoice.mpesa_receipt_number, "LGR7OWQX0R") + self.assertEquals(integration_request.status, "Completed") + +def create_mpesa_settings(payment_gateway_name="Express"): + if frappe.db.exists("Mpesa Settings", payment_gateway_name): + return frappe.get_doc("Mpesa Settings", payment_gateway_name) + + doc = frappe.get_doc(dict( + doctype="Mpesa Settings", + payment_gateway_name=payment_gateway_name, + consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", + consumer_secret="VI1oS3oBGPJfh3JyvLHw", + online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd", + till_number="174379" + )) + + doc.insert(ignore_permissions=True) + return doc + +def get_test_account_balance_response(): + """Response received after calling the account balance API.""" + return { + "ResultType":0, + "ResultCode":0, + "ResultDesc":"The service request has been accepted successfully.", + "OriginatorConversationID":"10816-694520-2", + "ConversationID":"AG_20200927_00007cdb1f9fb6494315", + "TransactionID":"LGR0000000", + "ResultParameters":{ + "ResultParameter":[ + { + "Key":"ReceiptNo", + "Value":"LGR919G2AV" + }, + { + "Key":"Conversation ID", + "Value":"AG_20170727_00004492b1b6d0078fbe" + }, + { + "Key":"FinalisedTime", + "Value":20170727101415 + }, + { + "Key":"Amount", + "Value":10 + }, + { + "Key":"TransactionStatus", + "Value":"Completed" + }, + { + "Key":"ReasonType", + "Value":"Salary Payment via API" + }, + { + "Key":"TransactionReason" + }, + { + "Key":"DebitPartyCharges", + "Value":"Fee For B2C Payment|KES|33.00" + }, + { + "Key":"DebitAccountType", + "Value":"Utility Account" + }, + { + "Key":"InitiatedTime", + "Value":20170727101415 + }, + { + "Key":"Originator Conversation ID", + "Value":"19455-773836-1" + }, + { + "Key":"CreditPartyName", + "Value":"254708374149 - John Doe" + }, + { + "Key":"DebitPartyName", + "Value":"600134 - Safaricom157" + } + ] + }, + "ReferenceData":{ + "ReferenceItem":{ + "Key":"Occasion", + "Value":"aaaa" + } + } + } + +def get_payment_request_response_payload(): + """Response received after successfully calling the stk push process request API.""" + return { + "MerchantRequestID": "8071-27184008-1", + "CheckoutRequestID": "ws_CO_061020201133231972", + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + { "Name": "Amount", "Value": 500.0 }, + { "Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R" }, + { "Name": "TransactionDate", "Value": 20201006113336 }, + { "Name": "PhoneNumber", "Value": 254723575670 } + ] + } + } + + +def get_payment_callback_payload(): + """Response received from the server as callback after calling the stkpush process request API.""" + return { + "Body":{ + "stkCallback":{ + "MerchantRequestID":"19465-780693-1", + "CheckoutRequestID":"ws_CO_061020201133231972", + "ResultCode":0, + "ResultDesc":"The service request is processed successfully.", + "CallbackMetadata":{ + "Item":[ + { + "Name":"Amount", + "Value":500 + }, + { + "Name":"MpesaReceiptNumber", + "Value":"LGR7OWQX0R" + }, + { + "Name":"Balance" + }, + { + "Name":"TransactionDate", + "Value":20170727154800 + }, + { + "Name":"PhoneNumber", + "Value":254721566839 + } + ] + } + } + } + } + +def get_account_balance_callback_payload(): + """Response received from the server as callback after calling the account balance API.""" + return { + "Result":{ + "ResultType": 0, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "OriginatorConversationID": "16470-170099139-1", + "ConversationID": "AG_20200927_00007cdb1f9fb6494315", + "TransactionID": "OIR0000000", + "ResultParameters": { + "ResultParameter": [ + { + "Key": "AccountBalance", + "Value": "Working Account|KES|481000.00|481000.00|0.00|0.00" + }, + { "Key": "BOCompletedTime", "Value": 20200927234123 } + ] + }, + "ReferenceData": { + "ReferenceItem": { + "Key": "QueueTimeoutURL", + "Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit" + } + } + } + } \ No newline at end of file From 1a6d82a447d7348ca2a06d343e8c7b5203e198fa Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Thu, 22 Oct 2020 01:07:49 +0530 Subject: [PATCH 20/32] fix(payment-request): only consider paid transactions --- erpnext/accounts/doctype/payment_request/payment_request.py | 2 +- .../doctype/mpesa_settings/mpesa_settings.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 51c090cea8..cb58f89dfb 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -400,7 +400,7 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): reference_doctype = %s and reference_name = %s and docstatus = 1 - and status != 'Paid' + and status = 'Paid' """, (ref_dt, ref_dn)) return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index c24a104fca..a7e6dec4d3 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -21,6 +21,7 @@ frappe.ui.form.on('Mpesa Settings', { }, setup_account_balance_html: function(frm) { + if (!frm.doc.account_balance) return; $("div").remove(".form-dashboard-section.custom"); frm.dashboard.add_section( frappe.render_template('account_balance', { From fa842e87746153212be733a8b7c78775c8e7168b Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 21 Oct 2020 11:04:00 +0530 Subject: [PATCH 21/32] fix: incorrect backflush qty in manufacture entry --- .../doctype/work_order/test_work_order.py | 36 +++++++++++++++++++ .../delivery_note/test_delivery_note.py | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 5 ++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 7010f296e4..5f8a13428c 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -193,6 +193,42 @@ class TestWorkOrder(unittest.TestCase): self.assertEqual(cint(bin1_on_end_production.projected_qty), cint(bin1_on_end_production.projected_qty)) + def test_backflush_qty_for_overpduction_manufacture(self): + cancel_stock_entry = [] + allow_overproduction("overproduction_percentage_for_work_order", 30) + wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100) + ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0) + ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=240, basic_rate=1000.0) + + cancel_stock_entry.extend([ste1.name, ste2.name]) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 50)) + s1.submit() + cancel_stock_entry.append(s1.name) + + self.assertEqual(s1.items[0].qty, 50) + self.assertEqual(s1.items[1].qty, 100) + cancel_stock_entry.reverse() + for ste in cancel_stock_entry: + doc = frappe.get_doc("Stock Entry", ste) + doc.cancel() + + allow_overproduction("overproduction_percentage_for_work_order", 0) + def test_reserved_qty_for_stopped_production(self): test_stock_entry.make_stock_entry(item_code="_Test Item", target= self.warehouse, qty=100, basic_rate=100) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 4b04a0a8c3..43aed5d3f2 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -57,7 +57,7 @@ class TestDeliveryNote(unittest.TestCase): sle = frappe.get_doc("Stock Ledger Entry", {"voucher_type": "Delivery Note", "voucher_no": dn.name}) - self.assertEqual(sle.stock_value_difference, -1*stock_queue[0][1]) + self.assertEqual(sle.stock_value_difference, flt(-1*stock_queue[0][1])) self.assertFalse(get_gl_entries("Delivery Note", dn.name)) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d456f986ea..141e6021c0 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1115,7 +1115,10 @@ class StockEntry(StockController): for d in backflushed_materials.get(item.item_code): if d.get(item.warehouse): if (qty > req_qty): - qty-= d.get(item.warehouse) + qty = (qty/trans_qty) * flt(self.fg_completed_qty) + + if cint(frappe.get_cached_value('UOM', item.stock_uom, 'must_be_whole_number')): + qty = frappe.utils.ceil(qty) if qty > 0: self.add_to_stock_entry_detail({ From efcc489967561056d918bacfd324e06883f09cad Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 23 Oct 2020 11:55:03 +0530 Subject: [PATCH 22/32] fix: Cashier Closing Type Issue --- erpnext/accounts/doctype/cashier_closing/cashier_closing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/cashier_closing/cashier_closing.py b/erpnext/accounts/doctype/cashier_closing/cashier_closing.py index 6de62ee577..7ad1d3ab83 100644 --- a/erpnext/accounts/doctype/cashier_closing/cashier_closing.py +++ b/erpnext/accounts/doctype/cashier_closing/cashier_closing.py @@ -23,13 +23,13 @@ class CashierClosing(Document): where posting_date=%s and posting_time>=%s and posting_time<=%s and owner=%s """, (self.date, self.from_time, self.time, self.user)) self.outstanding_amount = flt(values[0][0] if values else 0) - + def make_calculations(self): total = 0.00 for i in self.payments: total += flt(i.amount) - self.net_amount = total + self.outstanding_amount + self.expense - self.custody + self.returns + self.net_amount = total + self.outstanding_amount + flt(self.expense) - flt(self.custody) + flt(self.returns) def validate_time(self): if self.from_time >= self.time: From e03a02d9c0ede3e6d021e8baae47777bc04ba411 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 23 Oct 2020 13:38:55 +0530 Subject: [PATCH 23/32] fix: show descriptive message for missing fields --- .../doctype/payment_request/test_payment_request.py | 1 + .../doctype/mpesa_settings/mpesa_settings.js | 4 +++- .../doctype/mpesa_settings/test_mpesa_settings.py | 6 +++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 8a10e2cbd9..747bad8bb5 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -127,6 +127,7 @@ class TestPaymentRequest(unittest.TestCase): recipient_id="nabin@erpnext.com", return_doc=1) pr1.grand_total = 200 pr1.submit() + pr1.set_as_paid() # Make a 2nd Payment Request pr2 = make_payment_request(dt="Sales Order", dn=so.name, diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index a7e6dec4d3..636aa99de4 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -13,7 +13,9 @@ frappe.ui.form.on('Mpesa Settings', { }, get_account_balance: function(frm) { - if (!frm.initiator_name && !frm.security_credentials) return; + if (!frm.initiator_name && !frm.security_credentials) { + frappe.throw(__("Please set the initiator name and the security credential")); + } frappe.call({ method: "get_account_balance_info", doc: frm.doc diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 55ccff30fe..4e86d365e3 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -10,7 +10,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv class TestMpesaSettings(unittest.TestCase): def test_creation_of_payment_gateway(self): - mpesa_doc = create_mpesa_settings(payment_gateway_name="_Test") + create_mpesa_settings(payment_gateway_name="_Test") mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test") self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"})) @@ -41,7 +41,7 @@ class TestMpesaSettings(unittest.TestCase): })) def test_processing_of_callback_payload(self): - mpesa_doc = create_mpesa_settings(payment_gateway_name="Payment") + create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") @@ -73,7 +73,7 @@ def create_mpesa_settings(payment_gateway_name="Express"): if frappe.db.exists("Mpesa Settings", payment_gateway_name): return frappe.get_doc("Mpesa Settings", payment_gateway_name) - doc = frappe.get_doc(dict( + doc = frappe.get_doc(dict( #nosec doctype="Mpesa Settings", payment_gateway_name=payment_gateway_name, consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", From 558879fad78619916aa57cbecc49f483b488df20 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 22 Oct 2020 13:51:09 +0530 Subject: [PATCH 24/32] fix: Translation Syntax --- erpnext/stock/doctype/delivery_note/test_delivery_note.py | 2 +- erpnext/stock/doctype/stock_entry/stock_entry.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 43aed5d3f2..0168613415 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -57,7 +57,7 @@ class TestDeliveryNote(unittest.TestCase): sle = frappe.get_doc("Stock Ledger Entry", {"voucher_type": "Delivery Note", "voucher_no": dn.name}) - self.assertEqual(sle.stock_value_difference, flt(-1*stock_queue[0][1])) + self.assertEqual(sle.stock_value_difference, flt(-1*stock_queue[0][1], 2)) self.assertFalse(get_gl_entries("Delivery Note", dn.name)) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 141e6021c0..f9057572ef 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1321,8 +1321,10 @@ class StockEntry(StockController): for sr in get_serial_nos(item.serial_no): sales_order = frappe.db.get_value("Serial No", sr, "sales_order") if sales_order: - frappe.throw(_("Item {0} (Serial No: {1}) cannot be consumed as is reserverd\ - to fullfill Sales Order {2}.").format(item.item_code, sr, sales_order)) + msg = (_("(Serial No: {0}) cannot be consumed as it's reserverd to fullfill Sales Order {1}.") + .format(sr, sales_order)) + + frappe.throw(_("Item {0} {1}").format(item.item_code, msg)) def update_transferred_qty(self): if self.purpose == 'Material Transfer' and self.outgoing_stock_entry: From 675f79920e99013917bb271415c230f3baf4a534 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 23 Oct 2020 15:41:05 +0530 Subject: [PATCH 25/32] fix: consider the existing paid payment request for phone payment channel --- .../accounts/doctype/payment_request/payment_request.py | 8 +++++++- .../doctype/payment_request/test_payment_request.py | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index cb58f89dfb..1b97050eb1 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -393,6 +393,10 @@ def get_amount(ref_doc, payment_account=None): frappe.throw(_("Payment Entry is already created")) def get_existing_payment_request_amount(ref_dt, ref_dn): + """ + Get the existing payment request which are unpaid or partially paid for payment channel other than Phone + and get the summation of existing paid payment request for Phone payment channel. + """ existing_payment_request_amount = frappe.db.sql(""" select sum(grand_total) from `tabPayment Request` @@ -400,7 +404,9 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): reference_doctype = %s and reference_name = %s and docstatus = 1 - and status = 'Paid' + and (status != 'Paid' + or (payment_channel = 'Phone' + and status = 'Paid')) """, (ref_dt, ref_dn)) return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 747bad8bb5..8a10e2cbd9 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -127,7 +127,6 @@ class TestPaymentRequest(unittest.TestCase): recipient_id="nabin@erpnext.com", return_doc=1) pr1.grand_total = 200 pr1.submit() - pr1.set_as_paid() # Make a 2nd Payment Request pr2 = make_payment_request(dt="Sales Order", dn=so.name, From 9d94f785e907596dc29da9b62074fe96d437242d Mon Sep 17 00:00:00 2001 From: Marica Date: Fri, 23 Oct 2020 18:15:55 +0530 Subject: [PATCH 26/32] fix: Hide Ex-Employees from Employee Tree and minor message UX (#23711) * fix: Hide Ex-Employees from Employee Tree and minor message UX * fix: translation syntax --- erpnext/hr/doctype/employee/employee.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index 85eaa5e27a..dfc600ca3c 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -56,7 +56,7 @@ class Employee(NestedSet): if existing_user_id: remove_user_permission( "Employee", self.name, existing_user_id) - + def after_rename(self, old, new, merge): self.db_set("employee", new) @@ -181,8 +181,11 @@ class Employee(NestedSet): ) if reports_to: link_to_employees = [frappe.utils.get_link_to_form('Employee', employee.name, label=employee.employee_name) for employee in reports_to] - throw(_("Employee status cannot be set to 'Left' as following employees are currently reporting to this employee: ") - + ', '.join(link_to_employees), EmployeeLeftValidationError) + message = _("The following employees are currently still reporting to {0}:").format(frappe.bold(self.employee_name)) + message += "

  • " + "
  • ".join(link_to_employees) + message += "

" + message += _("Please make sure the employees above report to another Active employee.") + throw(message, EmployeeLeftValidationError, _("Cannot Relieve Employee")) if not self.relieving_date: throw(_("Please enter relieving date.")) @@ -215,7 +218,7 @@ class Employee(NestedSet): def validate_preferred_email(self): if self.prefered_contact_email and not self.get(scrub(self.prefered_contact_email)): - frappe.msgprint(_("Please enter " + self.prefered_contact_email)) + frappe.msgprint(_("Please enter {0}").format(self.prefered_contact_email)) def validate_onboarding_process(self): employee_onboarding = frappe.get_all("Employee Onboarding", @@ -417,9 +420,9 @@ def get_employee_emails(employee_list): @frappe.whitelist() def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False): - filters = [] + filters = [['status', '!=', 'Left']] if company and company != 'All Companies': - filters = [['company', '=', company]] + filters.append(['company', '=', company]) fields = ['name as value', 'employee_name as title'] From 93bbc52a689da046dfa4c921af464042df40ff4c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 23 Oct 2020 18:18:03 +0530 Subject: [PATCH 27/32] feat: added sequence id in routing for the completion of operations sequentially (#23641) * feat: added sequence id in routing for the completion of operations sequentially * fix: translation syntax --- erpnext/manufacturing/doctype/bom/bom.py | 31 +++---- .../doctype/bom_operation/bom_operation.json | 12 ++- .../doctype/job_card/job_card.json | 11 ++- .../doctype/job_card/job_card.py | 51 ++++++++--- .../doctype/operation/test_operation.py | 20 +++++ .../production_plan/test_production_plan.py | 4 +- .../manufacturing/doctype/routing/routing.js | 7 ++ .../manufacturing/doctype/routing/routing.py | 17 +++- .../doctype/routing/test_routing.py | 84 ++++++++++++++++++- .../doctype/work_order/work_order.py | 3 +- .../work_order_operation.json | 11 ++- .../doctype/workstation/test_workstation.py | 11 ++- 12 files changed, 225 insertions(+), 37 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 71d49a9537..2ab1b98707 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -55,10 +55,11 @@ class BOM(WebsiteGenerator): conflicting_bom = frappe.get_doc("BOM", name) if conflicting_bom.item != self.item: + msg = (_("A BOM with name {0} already exists for item {1}.") + .format(frappe.bold(name), frappe.bold(conflicting_bom.item))) - frappe.throw(_("""A BOM with name {0} already exists for item {1}. -
Did you rename the item? Please contact Administrator / Tech support - """).format(frappe.bold(name), frappe.bold(conflicting_bom.item))) + frappe.throw(_("{0}{1} Did you rename the item? Please contact Administrator / Tech support") + .format(msg, "
")) self.name = name @@ -72,6 +73,7 @@ class BOM(WebsiteGenerator): self.validate_uom_is_interger() self.set_bom_material_details() self.validate_materials() + self.set_routing_operations() self.validate_operations() self.calculate_cost() self.update_cost(update_parent=False, from_child_bom=True, save=False) @@ -111,18 +113,13 @@ class BOM(WebsiteGenerator): def get_routing(self): if self.routing: self.set("operations", []) - for d in frappe.get_all("BOM Operation", fields = ["*"], - filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="idx"): - child = self.append('operations', { - "operation": d.operation, - "workstation": d.workstation, - "description": d.description, - "time_in_mins": d.time_in_mins, - "batch_size": d.batch_size, - "operating_cost": d.operating_cost, - "idx": d.idx - }) - child.hour_rate = flt(d.hour_rate / self.conversion_rate, 2) + fields = ["sequence_id", "operation", "workstation", "description", + "time_in_mins", "batch_size", "operating_cost", "idx", "hour_rate"] + + for row in frappe.get_all("BOM Operation", fields = fields, + filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="sequence_id, idx"): + child = self.append('operations', row) + child.hour_rate = flt(row.hour_rate / self.conversion_rate, 2) def set_bom_material_details(self): for item in self.get("items"): @@ -571,6 +568,10 @@ class BOM(WebsiteGenerator): if act_pbom and act_pbom[0][0]: frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs")) + def set_routing_operations(self): + if self.routing and self.with_operations and not self.operations: + self.get_routing() + def validate_operations(self): if self.with_operations and not self.get('operations'): frappe.throw(_("Operations cannot be left blank")) diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 0350e2cb37..07464e3e76 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -1,10 +1,12 @@ { + "actions": [], "creation": "2013-02-22 01:27:49", "doctype": "DocType", "document_type": "Setup", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "sequence_id", "operation", "workstation", "description", @@ -106,11 +108,19 @@ "fieldname": "batch_size", "fieldtype": "Int", "label": "Batch Size" + }, + { + "depends_on": "eval:doc.parenttype == \"Routing\"", + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence ID" } ], "idx": 1, + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2020-06-16 17:01:11.128420", + "links": [], + "modified": "2020-10-13 18:14:10.018774", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 087ab6b484..575e719043 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -36,6 +36,7 @@ "items", "more_information", "operation_id", + "sequence_id", "transferred_qty", "requested_qty", "column_break_20", @@ -297,10 +298,18 @@ "fieldname": "operation_row_number", "fieldtype": "Select", "label": "Operation Row Number" + }, + { + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence Id", + "print_hide": 1, + "read_only": 1 } ], "is_submittable": 1, - "modified": "2020-08-24 15:21:21.398267", + "links": [], + "modified": "2020-10-14 12:58:25.327897", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 8855e0acf5..4dfa78bf21 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import datetime -from frappe import _ +from frappe import _, bold from frappe.model.mapper import get_mapped_doc from frappe.model.document import Document from frappe.utils import (flt, cint, time_diff_in_hours, get_datetime, getdate, @@ -16,12 +16,14 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings class OverlapError(frappe.ValidationError): pass class OperationMismatchError(frappe.ValidationError): pass +class OperationSequenceError(frappe.ValidationError): pass class JobCard(Document): def validate(self): self.validate_time_logs() self.set_status() self.validate_operation_id() + self.validate_sequence_id() def validate_time_logs(self): self.total_completed_qty = 0.0 @@ -196,14 +198,14 @@ class JobCard(Document): def validate_job_card(self): if not self.time_logs: frappe.throw(_("Time logs are required for {0} {1}") - .format(frappe.bold("Job Card"), get_link_to_form("Job Card", self.name))) + .format(bold("Job Card"), get_link_to_form("Job Card", self.name))) if self.for_quantity and self.total_completed_qty != self.for_quantity: - total_completed_qty = frappe.bold(_("Total Completed Qty")) - qty_to_manufacture = frappe.bold(_("Qty to Manufacture")) + total_completed_qty = bold(_("Total Completed Qty")) + qty_to_manufacture = bold(_("Qty to Manufacture")) - frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})" - .format(total_completed_qty, frappe.bold(self.total_completed_qty), qty_to_manufacture,frappe.bold(self.for_quantity)))) + frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})") + .format(total_completed_qty, bold(self.total_completed_qty), qty_to_manufacture,bold(self.for_quantity))) def update_work_order(self): if not self.work_order: @@ -213,10 +215,7 @@ class JobCard(Document): from_time_list, to_time_list = [], [] field = "operation_id" - data = frappe.get_all('Job Card', - fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"], - filters = {"docstatus": 1, "work_order": self.work_order, field: self.get(field)}) - + data = self.get_current_operation_data() if data and len(data) > 0: for_quantity = data[0].completed_qty time_in_mins = data[0].time_in_mins @@ -246,6 +245,11 @@ class JobCard(Document): wo.set_actual_dates() wo.save() + def get_current_operation_data(self): + return frappe.get_all('Job Card', + fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"], + filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id}) + def set_transferred_qty(self, update_status=False): if not self.items: self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0 @@ -310,9 +314,32 @@ class JobCard(Document): def validate_operation_id(self): if (self.get("operation_id") and self.get("operation_row_number") and self.operation and self.work_order and frappe.get_cached_value("Work Order Operation", self.operation_row_number, "name") != self.operation_id): - work_order = frappe.bold(get_link_to_form("Work Order", self.work_order)) + work_order = bold(get_link_to_form("Work Order", self.work_order)) frappe.throw(_("Operation {0} does not belong to the work order {1}") - .format(frappe.bold(self.operation), work_order), OperationMismatchError) + .format(bold(self.operation), work_order), OperationMismatchError) + + def validate_sequence_id(self): + if not (self.work_order and self.sequence_id): return + + current_operation_qty = 0.0 + data = self.get_current_operation_data() + if data and len(data) > 0: + current_operation_qty = flt(data[0].completed_qty) + + current_operation_qty += flt(self.total_completed_qty) + + data = frappe.get_all("Work Order Operation", + fields = ["operation", "status", "completed_qty"], + filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ('<', self.sequence_id)}, + order_by = "sequence_id, idx") + + message = "Job Card {0}: As per the sequence of the operations in the work order {1}".format(bold(self.name), + bold(get_link_to_form("Work Order", self.work_order))) + + for row in data: + if row.status != "Completed" and row.completed_qty < current_operation_qty: + frappe.throw(_("{0}, complete the operation {1} before the operation {2}.") + .format(message, bold(row.operation), bold(self.operation)), OperationSequenceError) @frappe.whitelist() def get_operation_details(work_order, operation): diff --git a/erpnext/manufacturing/doctype/operation/test_operation.py b/erpnext/manufacturing/doctype/operation/test_operation.py index 17d206a4e1..0067231701 100644 --- a/erpnext/manufacturing/doctype/operation/test_operation.py +++ b/erpnext/manufacturing/doctype/operation/test_operation.py @@ -9,3 +9,23 @@ test_records = frappe.get_test_records('Operation') class TestOperation(unittest.TestCase): pass + +def make_operation(*args, **kwargs): + args = args if args else kwargs + if isinstance(args, tuple): + args = args[0] + + args = frappe._dict(args) + + try: + doc = frappe.get_doc({ + "doctype": "Operation", + "name": args.operation, + "workstation": args.workstation + }) + + doc.insert() + + return doc + except frappe.DuplicateEntryError: + return frappe.get_doc("Operation", args.operation) \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index d020bc83fa..fa9d080cca 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -237,7 +237,9 @@ def make_bom(**args): 'item': args.item, 'currency': args.currency or 'USD', 'quantity': args.quantity or 1, - 'company': args.company or '_Test Company' + 'company': args.company or '_Test Company', + 'routing': args.routing, + 'with_operations': args.with_operations or 0 }) for item in args.raw_materials: diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index d7589fa390..741a3f01fd 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -2,6 +2,13 @@ // For license information, please see license.txt frappe.ui.form.on('Routing', { + setup: function(frm) { + frappe.meta.get_docfield("BOM Operation", "sequence_id", + frm.doc.name).in_list_view = true; + + frm.fields_dict.operations.grid.refresh(); + }, + calculate_operating_cost: function(frm, child) { const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, 2); frappe.model.set_value(child.doctype, child.name, "operating_cost", operating_cost); diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py index ecd0ba8be8..8312d7436c 100644 --- a/erpnext/manufacturing/doctype/routing/routing.py +++ b/erpnext/manufacturing/doctype/routing/routing.py @@ -3,7 +3,22 @@ # For license information, please see license.txt from __future__ import unicode_literals +import frappe +from frappe.utils import cint +from frappe import _ from frappe.model.document import Document class Routing(Document): - pass + def validate(self): + self.set_routing_id() + + def set_routing_id(self): + sequence_id = 0 + for row in self.operations: + if not row.sequence_id: + row.sequence_id = sequence_id + 1 + elif sequence_id and row.sequence_id and cint(sequence_id) > cint(row.sequence_id): + frappe.throw(_("At row #{0}: the sequence id {1} cannot be less than previous row sequence id {2}") + .format(row.idx, row.sequence_id, sequence_id)) + + sequence_id = row.sequence_id \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index 53ad152732..73d05a6157 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -4,6 +4,88 @@ from __future__ import unicode_literals import unittest +import frappe +from frappe.test_runner import make_test_records +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.manufacturing.doctype.operation.test_operation import make_operation +from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError +from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation +from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record class TestRouting(unittest.TestCase): - pass + def test_sequence_id(self): + item_code = "Test Routing Item - A" + operations = [{"operation": "Test Operation A", "workstation": "Test Workstation A", "time_in_mins": 30}, + {"operation": "Test Operation B", "workstation": "Test Workstation A", "time_in_mins": 20}] + + make_test_records("UOM") + + setup_operations(operations) + routing_doc = create_routing(routing_name="Testing Route", operations=operations) + bom_doc = setup_bom(item_code=item_code, routing=routing_doc.name) + wo_doc = make_wo_order_test_record(production_item = item_code, bom_no=bom_doc.name) + + for row in routing_doc.operations: + self.assertEqual(row.sequence_id, row.idx) + + for data in frappe.get_all("Job Card", + filters={"work_order": wo_doc.name}, order_by="sequence_id desc"): + job_card_doc = frappe.get_doc("Job Card", data.name) + job_card_doc.time_logs[0].completed_qty = 10 + if job_card_doc.sequence_id != 1: + self.assertRaises(OperationSequenceError, job_card_doc.save) + else: + job_card_doc.save() + self.assertEqual(job_card_doc.total_completed_qty, 10) + + wo_doc.cancel() + wo_doc.delete() + +def setup_operations(rows): + for row in rows: + make_workstation(row) + make_operation(row) + +def create_routing(**args): + args = frappe._dict(args) + + doc = frappe.new_doc("Routing") + doc.update(args) + + if not args.do_not_save: + try: + for operation in args.operations: + doc.append("operations", operation) + + doc.insert() + except frappe.DuplicateEntryError: + doc = frappe.get_doc("Routing", args.routing_name) + + return doc + +def setup_bom(**args): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + args = frappe._dict(args) + + if not frappe.db.exists('Item', args.item_code): + make_item(args.item_code, { + 'is_stock_item': 1 + }) + + if not args.raw_materials: + if not frappe.db.exists('Item', "Test Extra Item 1"): + make_item("Test Extra Item N-1", { + 'is_stock_item': 1, + }) + + args.raw_materials = ['Test Extra Item N-1'] + + name = frappe.db.get_value('BOM', {'item': args.item_code}, 'name') + if not name: + bom_doc = make_bom(item = args.item_code, raw_materials = args.get("raw_materials"), + routing = args.routing, with_operations=1) + else: + bom_doc = frappe.get_doc("BOM", name) + + return bom_doc \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 7f8341f4c2..cc93bf9fd6 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -378,7 +378,7 @@ class WorkOrder(Document): select operation, description, workstation, idx, base_hour_rate as hour_rate, time_in_mins, - "Pending" as status, parent as bom, batch_size + "Pending" as status, parent as bom, batch_size, sequence_id from `tabBOM Operation` where @@ -865,6 +865,7 @@ def create_job_card(work_order, row, qty=0, enable_capacity_planning=False, auto 'bom_no': work_order.bom_no, 'project': work_order.project, 'company': work_order.company, + 'sequence_id': row.get("sequence_id"), 'wip_warehouse': work_order.wip_warehouse }) diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 3f5e18e813..8c5cde9a13 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -8,6 +8,7 @@ "details", "operation", "bom", + "sequence_id", "description", "col_break1", "completed_qty", @@ -187,11 +188,19 @@ "fieldtype": "Int", "label": "Batch Size", "read_only": 1 + }, + { + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence ID", + "print_hide": 1, + "read_only": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2019-12-03 19:24:29.594189", + "modified": "2020-10-14 12:58:49.241252", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index 8266cf7b77..c6699bee48 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -21,17 +21,22 @@ class TestWorkstation(unittest.TestCase): self.assertRaises(WorkstationHolidayError, check_if_within_operating_hours, "_Test Workstation 1", "Operation 1", "2013-02-01 10:00:00", "2013-02-02 20:00:00") -def make_workstation(**args): +def make_workstation(*args, **kwargs): + args = args if args else kwargs + if isinstance(args, tuple): + args = args[0] + args = frappe._dict(args) + workstation_name = args.workstation_name or args.workstation try: doc = frappe.get_doc({ "doctype": "Workstation", - "workstation_name": args.workstation_name + "workstation_name": workstation_name }) doc.insert() return doc except frappe.DuplicateEntryError: - return frappe.get_doc("Workstation", args.workstation_name) \ No newline at end of file + return frappe.get_doc("Workstation", workstation_name) \ No newline at end of file From b39c17dd55a3b10934a6d69be3ae4565ac767941 Mon Sep 17 00:00:00 2001 From: Michelle Alva <50285544+michellealva@users.noreply.github.com> Date: Fri, 23 Oct 2020 18:22:26 +0530 Subject: [PATCH 28/32] fix: Typo/Grammatical fixes in Settings (#23612) * fix(Stock Settings): Type/Grammatical fixes in Settings * fix: grammatical fixes in manufacturing settings * fix: typo fix in accounting settings * fix: typo fixes in HR settings * fix: typo fixes in buying settings * fix: typo fixes in Selling Settings * Update erpnext/accounts/doctype/accounts_settings/accounts_settings.json Co-authored-by: Sagar Vora * Apply suggestions from code review Co-authored-by: Sagar Vora * Apply suggestions from code review Co-authored-by: Sagar Vora * Update accounts_settings.json Co-authored-by: Marica Co-authored-by: Nabin Hait Co-authored-by: Rushabh Mehta Co-authored-by: Sagar Vora --- .../accounts_settings/accounts_settings.json | 26 +++---- .../buying_settings/buying_settings.json | 13 ++-- .../hr/doctype/hr_settings/hr_settings.json | 78 ++++++------------- .../manufacturing_settings.json | 21 ++--- .../selling_settings/selling_settings.json | 21 ++--- .../stock_settings/stock_settings.json | 25 +++--- 6 files changed, 78 insertions(+), 106 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 4d3cbea6bc..41f9ce030a 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -40,7 +40,7 @@ "fields": [ { "default": "1", - "description": "If enabled, the system will post accounting entries for inventory automatically.", + "description": "If enabled, the system will post accounting entries for inventory automatically", "fieldname": "auto_accounting_for_stock", "fieldtype": "Check", "hidden": 1, @@ -48,23 +48,23 @@ "label": "Make Accounting Entry For Every Stock Movement" }, { - "description": "Accounting entry frozen up to this date, nobody can do / modify entry except role specified below.", + "description": "Accounting entries are frozen up to this date. Nobody can create or modify entries except users with the role specified below", "fieldname": "acc_frozen_upto", "fieldtype": "Date", "in_list_view": 1, - "label": "Accounts Frozen Upto" + "label": "Accounts Frozen Till Date" }, { "description": "Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts", "fieldname": "frozen_accounts_modifier", "fieldtype": "Link", "in_list_view": 1, - "label": "Role Allowed to Set Frozen Accounts & Edit Frozen Entries", + "label": "Role Allowed to Set Frozen Accounts and Edit Frozen Entries", "options": "Role" }, { "default": "Billing Address", - "description": "Address used to determine Tax Category in transactions.", + "description": "Address used to determine Tax Category in transactions", "fieldname": "determine_address_tax_category_from", "fieldtype": "Select", "label": "Determine Address Tax Category From", @@ -75,7 +75,7 @@ "fieldtype": "Column Break" }, { - "description": "Role that is allowed to submit transactions that exceed credit limits set.", + "description": "This role is allowed to submit transactions that exceed credit limits", "fieldname": "credit_controller", "fieldtype": "Link", "in_list_view": 1, @@ -127,7 +127,7 @@ "default": "0", "fieldname": "show_inclusive_tax_in_print", "fieldtype": "Check", - "label": "Show Inclusive Tax In Print" + "label": "Show Inclusive Tax in Print" }, { "fieldname": "column_break_12", @@ -165,7 +165,7 @@ }, { "default": "0", - "description": "Only select if you have setup Cash Flow Mapper documents", + "description": "Only select this if you have set up the Cash Flow Mapper documents", "fieldname": "use_custom_cash_flow", "fieldtype": "Check", "label": "Use Custom Cash Flow Format" @@ -177,7 +177,7 @@ "label": "Automatically Fetch Payment Terms" }, { - "description": "Percentage you are allowed to bill more against the amount ordered. For example: If the order value is $100 for an item and tolerance is set as 10% then you are allowed to bill for $110.", + "description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ", "fieldname": "over_billing_allowance", "fieldtype": "Currency", "label": "Over Billing Allowance (%)" @@ -199,7 +199,7 @@ }, { "default": "0", - "description": "If this is unchecked direct GL Entries will be created to book Deferred Revenue/Expense", + "description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense", "fieldname": "book_deferred_entries_via_journal_entry", "fieldtype": "Check", "label": "Book Deferred Entries Via Journal Entry" @@ -214,7 +214,7 @@ }, { "default": "Days", - "description": "If \"Months\" is selected then fixed amount will be booked as deferred revenue or expense for each month irrespective of number of days in a month. Will be prorated if deferred revenue or expense is not booked for an entire month.", + "description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month", "fieldname": "book_deferred_entries_based_on", "fieldtype": "Select", "label": "Book Deferred Entries Based On", @@ -226,7 +226,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-10-07 14:58:50.325577", + "modified": "2020-10-13 11:32:52.268826", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", @@ -254,4 +254,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index a0ab2a00f9..618212da80 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -46,26 +46,26 @@ { "fieldname": "po_required", "fieldtype": "Select", - "label": "Purchase Order Required for Purchase Invoice & Receipt Creation", + "label": "Is Purchase Order Required for Purchase Invoice & Receipt Creation?", "options": "No\nYes" }, { "fieldname": "pr_required", "fieldtype": "Select", - "label": "Purchase Receipt Required for Purchase Invoice Creation", + "label": "Is Purchase Receipt Required for Purchase Invoice Creation?", "options": "No\nYes" }, { "default": "0", "fieldname": "maintain_same_rate", "fieldtype": "Check", - "label": "Maintain same rate throughout purchase cycle" + "label": "Maintain Same Rate Throughout the Purchase Cycle" }, { "default": "0", "fieldname": "allow_multiple_items", "fieldtype": "Check", - "label": "Allow Item to be added multiple times in a transaction" + "label": "Allow Item To Be Added Multiple Times in a Transaction" }, { "fieldname": "subcontract", @@ -93,9 +93,10 @@ ], "icon": "fa fa-cog", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-05-15 14:49:32.513611", + "modified": "2020-10-13 12:00:23.276329", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", @@ -113,4 +114,4 @@ ], "sort_field": "modified", "sort_order": "DESC" -} \ No newline at end of file +} diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json index c42e1d72fc..4374d2911a 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.json +++ b/erpnext/hr/doctype/hr_settings/hr_settings.json @@ -28,144 +28,110 @@ { "fieldname": "employee_settings", "fieldtype": "Section Break", - "label": "Employee Settings", - "show_days": 1, - "show_seconds": 1 + "label": "Employee Settings" }, { "description": "Enter retirement age in years", "fieldname": "retirement_age", "fieldtype": "Data", - "label": "Retirement Age", - "show_days": 1, - "show_seconds": 1 + "label": "Retirement Age" }, { "default": "Naming Series", - "description": "Employee record is created using selected field. ", + "description": "Employee records are created using the selected field", "fieldname": "emp_created_by", "fieldtype": "Select", - "label": "Employee Records to be created by", - "options": "Naming Series\nEmployee Number\nFull Name", - "show_days": 1, - "show_seconds": 1 + "label": "Employee Records to Be Created By", + "options": "Naming Series\nEmployee Number\nFull Name" }, { "fieldname": "column_break_4", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "default": "0", - "description": "Don't send Employee Birthday Reminders", + "description": "Don't send employee birthday reminders", "fieldname": "stop_birthday_reminders", "fieldtype": "Check", - "label": "Stop Birthday Reminders", - "show_days": 1, - "show_seconds": 1 + "label": "Stop Birthday Reminders" }, { "default": "1", "fieldname": "expense_approver_mandatory_in_expense_claim", "fieldtype": "Check", - "label": "Expense Approver Mandatory In Expense Claim", - "show_days": 1, - "show_seconds": 1 + "label": "Expense Approver Mandatory In Expense Claim" }, { "collapsible": 1, "fieldname": "leave_settings", "fieldtype": "Section Break", - "label": "Leave Settings", - "show_days": 1, - "show_seconds": 1 + "label": "Leave Settings" }, { "fieldname": "leave_approval_notification_template", "fieldtype": "Link", "label": "Leave Approval Notification Template", - "options": "Email Template", - "show_days": 1, - "show_seconds": 1 + "options": "Email Template" }, { "fieldname": "leave_status_notification_template", "fieldtype": "Link", "label": "Leave Status Notification Template", - "options": "Email Template", - "show_days": 1, - "show_seconds": 1 + "options": "Email Template" }, { "fieldname": "column_break_18", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "default": "1", "fieldname": "leave_approver_mandatory_in_leave_application", "fieldtype": "Check", - "label": "Leave Approver Mandatory In Leave Application", - "show_days": 1, - "show_seconds": 1 + "label": "Leave Approver Mandatory In Leave Application" }, { "default": "0", "fieldname": "show_leaves_of_all_department_members_in_calendar", "fieldtype": "Check", - "label": "Show Leaves Of All Department Members In Calendar", - "show_days": 1, - "show_seconds": 1 + "label": "Show Leaves Of All Department Members In Calendar" }, { "collapsible": 1, "fieldname": "hiring_settings", "fieldtype": "Section Break", - "label": "Hiring Settings", - "show_days": 1, - "show_seconds": 1 + "label": "Hiring Settings" }, { "default": "0", "fieldname": "check_vacancies", "fieldtype": "Check", - "label": "Check Vacancies On Job Offer Creation", - "show_days": 1, - "show_seconds": 1 + "label": "Check Vacancies On Job Offer Creation" }, { "default": "0", "fieldname": "auto_leave_encashment", "fieldtype": "Check", - "label": "Auto Leave Encashment", - "show_days": 1, - "show_seconds": 1 + "label": "Auto Leave Encashment" }, { "default": "0", "fieldname": "restrict_backdated_leave_application", "fieldtype": "Check", - "label": "Restrict Backdated Leave Application", - "show_days": 1, - "show_seconds": 1 + "label": "Restrict Backdated Leave Applications" }, { "depends_on": "eval:doc.restrict_backdated_leave_application == 1", "fieldname": "role_allowed_to_create_backdated_leave_application", "fieldtype": "Link", "label": "Role Allowed to Create Backdated Leave Application", - "options": "Role", - "show_days": 1, - "show_seconds": 1 + "options": "Role" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2020-06-04 15:15:09.865476", + "modified": "2020-10-13 11:49:46.168027", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", @@ -183,4 +149,4 @@ ], "sort_field": "modified", "sort_order": "ASC" -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 86fa7a8901..b7634da87c 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2014-11-27 14:12:07.542534", "doctype": "DocType", "document_type": "Document", @@ -36,7 +37,7 @@ { "default": "0", "depends_on": "eval:!doc.disable_capacity_planning", - "description": "Plan time logs outside Workstation Working Hours.", + "description": "Plan time logs outside Workstation working hours", "fieldname": "allow_overtime", "fieldtype": "Check", "label": "Allow Overtime" @@ -56,17 +57,17 @@ { "default": "30", "depends_on": "eval:!doc.disable_capacity_planning", - "description": "Try planning operations for X days in advance.", + "description": "Plan operations X days in advance", "fieldname": "capacity_planning_for_days", "fieldtype": "Int", "label": "Capacity Planning For (Days)" }, { "depends_on": "eval:!doc.disable_capacity_planning", - "description": "Default 10 mins", + "description": "Default: 10 mins", "fieldname": "mins_between_operations", "fieldtype": "Int", - "label": "Time Between Operations (in mins)" + "label": "Time Between Operations (Mins)" }, { "fieldname": "section_break_6", @@ -92,14 +93,14 @@ }, { "default": "0", - "description": "Allow multiple Material Consumption against a Work Order", + "description": "Allow multiple material consumptions against a Work Order", "fieldname": "material_consumption", "fieldtype": "Check", "label": "Allow Multiple Material Consumption" }, { "default": "0", - "description": "Update BOM cost automatically via Scheduler, based on latest valuation rate / price list rate / last purchase rate of raw materials.", + "description": "Update BOM cost automatically via scheduler, based on the latest Valuation Rate/Price List Rate/Last Purchase Rate of raw materials", "fieldname": "update_bom_costs_automatically", "fieldtype": "Check", "label": "Update BOM Cost Automatically" @@ -135,7 +136,7 @@ { "fieldname": "over_production_for_sales_and_work_order_section", "fieldtype": "Section Break", - "label": "Over Production for Sales and Work Order" + "label": "Overproduction for Sales and Work Order" }, { "fieldname": "raw_materials_consumption_section", @@ -157,8 +158,10 @@ } ], "icon": "icon-wrench", + "index_web_pages_for_search": 1, "issingle": 1, - "modified": "2019-11-26 13:10:45.569341", + "links": [], + "modified": "2020-10-13 10:55:43.996581", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", @@ -175,4 +178,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index dcbc0748f7..4044f09c85 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -63,7 +63,7 @@ }, { "default": "15", - "description": "Auto close Opportunity after 15 days", + "description": "Auto close Opportunity after the no. of days mentioned above", "fieldname": "close_opportunity_after_days", "fieldtype": "Int", "label": "Close Opportunity After Days" @@ -80,18 +80,18 @@ { "fieldname": "so_required", "fieldtype": "Select", - "label": "Sales Order Required for Sales Invoice & Delivery Note Creation", + "label": "Is Sales Order Required for Sales Invoice & Delivery Note Creation?", "options": "No\nYes" }, { "fieldname": "dn_required", "fieldtype": "Select", - "label": "Delivery Note Required for Sales Invoice Creation", + "label": "Is Delivery Note Required for Sales Invoice Creation?", "options": "No\nYes" }, { "default": "Each Transaction", - "description": "How often should project and company be updated based on Sales Transactions.", + "description": "How often should Project and Company be updated based on Sales Transactions?", "fieldname": "sales_update_frequency", "fieldtype": "Select", "label": "Sales Update Frequency", @@ -108,38 +108,39 @@ "default": "0", "fieldname": "editable_price_list_rate", "fieldtype": "Check", - "label": "Allow user to edit Price List Rate in transactions" + "label": "Allow User to Edit Price List Rate in Transactions" }, { "default": "0", "fieldname": "allow_multiple_items", "fieldtype": "Check", - "label": "Allow Item to be added multiple times in a transaction" + "label": "Allow Item to Be Added Multiple Times in a Transaction" }, { "default": "0", "fieldname": "allow_against_multiple_purchase_orders", "fieldtype": "Check", - "label": "Allow multiple Sales Orders against a Customer's Purchase Order" + "label": "Allow Multiple Sales Orders Against a Customer's Purchase Order" }, { "default": "0", "fieldname": "validate_selling_price", "fieldtype": "Check", - "label": "Validate Selling Price for Item against Purchase Rate or Valuation Rate" + "label": "Validate Selling Price for Item Against Purchase Rate or Valuation Rate" }, { "default": "0", "fieldname": "hide_tax_id", "fieldtype": "Check", - "label": "Hide Customer's Tax Id from Sales Transactions" + "label": "Hide Customer's Tax ID from Sales Transactions" } ], "icon": "fa fa-cog", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-06-01 13:58:35.637858", + "modified": "2020-10-13 12:12:56.784014", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 9c5d3d8340..067659f64a 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -82,7 +82,7 @@ "options": "FIFO\nMoving Average" }, { - "description": "Percentage you are allowed to receive or deliver more against the quantity ordered. For example: If you have ordered 100 units. and your Allowance is 10% then you are allowed to receive 110 units.", + "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", "fieldname": "over_delivery_receipt_allowance", "fieldtype": "Float", "label": "Over Delivery/Receipt Allowance (%)" @@ -91,7 +91,7 @@ "default": "Stop", "fieldname": "action_if_quality_inspection_is_not_submitted", "fieldtype": "Select", - "label": "Action if Quality inspection is not submitted", + "label": "Action If Quality Inspection Is Not Submitted", "options": "Stop\nWarn" }, { @@ -114,7 +114,7 @@ "default": "0", "fieldname": "auto_insert_price_list_rate_if_missing", "fieldtype": "Check", - "label": "Auto insert Price List rate if missing" + "label": "Auto Insert Price List Rate If Missing" }, { "default": "0", @@ -130,13 +130,13 @@ "default": "1", "fieldname": "automatically_set_serial_nos_based_on_fifo", "fieldtype": "Check", - "label": "Automatically Set Serial Nos based on FIFO" + "label": "Automatically Set Serial Nos Based on FIFO" }, { "default": "1", "fieldname": "set_qty_in_transactions_based_on_serial_no_input", "fieldtype": "Check", - "label": "Set Qty in Transactions based on Serial No Input" + "label": "Set Qty in Transactions Based on Serial No Input" }, { "fieldname": "auto_material_request", @@ -147,13 +147,13 @@ "default": "0", "fieldname": "auto_indent", "fieldtype": "Check", - "label": "Raise Material Request when stock reaches re-order level" + "label": "Raise Material Request When Stock Reaches Re-order Level" }, { "default": "0", "fieldname": "reorder_email_notify", "fieldtype": "Check", - "label": "Notify by Email on creation of automatic Material Request" + "label": "Notify by Email on Creation of Automatic Material Request" }, { "fieldname": "freeze_stock_entries", @@ -168,12 +168,12 @@ { "fieldname": "stock_frozen_upto_days", "fieldtype": "Int", - "label": "Freeze Stocks Older Than [Days]" + "label": "Freeze Stocks Older Than (Days)" }, { "fieldname": "stock_auth_role", "fieldtype": "Link", - "label": "Role Allowed to edit frozen stock", + "label": "Role Allowed to Edit Frozen Stock", "options": "Role" }, { @@ -203,20 +203,21 @@ "default": "0", "fieldname": "allow_from_dn", "fieldtype": "Check", - "label": "Allow Material Transfer From Delivery Note and Sales Invoice" + "label": "Allow Material Transfer from Delivery Note to Sales Invoice" }, { "default": "0", "fieldname": "allow_from_pr", "fieldtype": "Check", - "label": "Allow Material Transfer From Purchase Receipt and Purchase Invoice" + "label": "Allow Material Transfer from Purchase Receipt to Purchase Invoice" } ], "icon": "icon-cog", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-06-20 11:39:15.344112", + "modified": "2020-10-13 10:33:29.147682", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", From 758a68da3523f1c90a09d989ac7014c99890f494 Mon Sep 17 00:00:00 2001 From: Marica Date: Fri, 23 Oct 2020 19:26:43 +0530 Subject: [PATCH 29/32] fix: Sequence Matcher shouldn't get None input (#23539) Co-authored-by: Nabin Hait --- .../page/bank_reconciliation/bank_reconciliation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py index a168cd1a7d..8abe20c00a 100644 --- a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py +++ b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py @@ -243,7 +243,11 @@ def check_amount_vs_description(amount_matching, description_matching): continue if "reference_no" in am_match and "reference_no" in des_match: - if difflib.SequenceMatcher(lambda x: x == " ", am_match["reference_no"], des_match["reference_no"]).ratio() > 70: + # Sequence Matcher does not handle None as input + am_reference = am_match["reference_no"] or "" + des_reference = des_match["reference_no"] or "" + + if difflib.SequenceMatcher(lambda x: x == " ", am_reference, des_reference).ratio() > 70: if am_match not in result: result.append(am_match) if result: From b4448114cf844723c56defd9650cb8115c8b074d Mon Sep 17 00:00:00 2001 From: Saqib Date: Fri, 23 Oct 2020 19:32:37 +0530 Subject: [PATCH 30/32] feat: opening invoice creation tool with background jobs (#23595) * feat: opening invoice creation tool with background jobs * fix: tests * fix: codacy * fix: sider * fix: codacy Co-authored-by: Nabin Hait --- .../opening_invoice_creation_tool.js | 41 +++-- .../opening_invoice_creation_tool.py | 174 ++++++++++-------- .../test_opening_invoice_creation_tool.py | 2 +- 3 files changed, 131 insertions(+), 86 deletions(-) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js index 699eb08e17..3ce5701823 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js @@ -6,7 +6,7 @@ frappe.ui.form.on('Opening Invoice Creation Tool', { frm.set_query('party_type', 'invoices', function(doc, cdt, cdn) { return { filters: { - 'name': ['in', 'Customer,Supplier'] + 'name': ['in', 'Customer, Supplier'] } }; }); @@ -14,29 +14,46 @@ frappe.ui.form.on('Opening Invoice Creation Tool', { if (frm.doc.company) { frm.trigger('setup_company_filters'); } + + frappe.realtime.on('opening_invoice_creation_progress', data => { + if (!frm.doc.import_in_progress) { + frm.dashboard.reset(); + frm.doc.import_in_progress = true; + } + if (data.user != frappe.session.user) return; + if (data.count == data.total) { + setTimeout((title) => { + frm.doc.import_in_progress = false; + frm.clear_table("invoices"); + frm.refresh_fields(); + frm.page.clear_indicator(); + frm.dashboard.hide_progress(title); + frappe.msgprint(__("Opening {0} Invoice created", [frm.doc.invoice_type])); + }, 1500, data.title); + return; + } + + frm.dashboard.show_progress(data.title, (data.count / data.total) * 100, data.message); + frm.page.set_indicator(__('In Progress'), 'orange'); + }); }, refresh: function(frm) { frm.disable_save(); - frm.trigger("make_dashboard"); + !frm.doc.import_in_progress && frm.trigger("make_dashboard"); frm.page.set_primary_action(__('Create Invoices'), () => { let btn_primary = frm.page.btn_primary.get(0); return frm.call({ doc: frm.doc, - freeze: true, btn: $(btn_primary), method: "make_invoices", - freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]), - callback: (r) => { - if(!r.exc){ - frappe.msgprint(__("Opening {0} Invoice created", [frm.doc.invoice_type])); - frm.clear_table("invoices"); - frm.refresh_fields(); - frm.reload_doc(); - } - } + freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]) }); }); + + if (frm.doc.create_missing_party) { + frm.set_df_property("party", "fieldtype", "Data", frm.doc.name, "invoices"); + } }, setup_company_filters: function(frm) { diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index a53417eedf..d51856a8a4 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -4,9 +4,12 @@ from __future__ import unicode_literals import frappe +import traceback +from json import dumps from frappe import _, scrub from frappe.utils import flt, nowdate from frappe.model.document import Document +from frappe.utils.background_jobs import enqueue from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions @@ -61,67 +64,48 @@ class OpeningInvoiceCreationTool(Document): prepare_invoice_summary(doctype, invoices) return invoices_summary, max_count - - def make_invoices(self): - names = [] - mandatory_error_msg = _("Row {0}: {1} is required to create the Opening {2} Invoices") + + def validate_company(self): if not self.company: frappe.throw(_("Please select the Company")) + + def set_missing_values(self, row): + row.qty = row.qty or 1.0 + row.temporary_opening_account = row.temporary_opening_account or get_temporary_opening_account(self.company) + row.party_type = "Customer" if self.invoice_type == "Sales" else "Supplier" + row.item_name = row.item_name or _("Opening Invoice Item") + row.posting_date = row.posting_date or nowdate() + row.due_date = row.due_date or nowdate() - company_details = frappe.get_cached_value('Company', self.company, - ["default_currency", "default_letter_head"], as_dict=1) or {} + def validate_mandatory_invoice_fields(self, row): + if not frappe.db.exists(row.party_type, row.party): + if self.create_missing_party: + self.add_party(row.party_type, row.party) + else: + frappe.throw(_("Row #{}: {} {} does not exist.").format(row.idx, frappe.bold(row.party_type), frappe.bold(row.party))) + mandatory_error_msg = _("Row #{0}: {1} is required to create the Opening {2} Invoices") + for d in ("Party", "Outstanding Amount", "Temporary Opening Account"): + if not row.get(scrub(d)): + frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type)) + + def get_invoices(self): + invoices = [] for row in self.invoices: - if not row.qty: - row.qty = 1.0 - - # always mandatory fields for the invoices - if not row.temporary_opening_account: - row.temporary_opening_account = get_temporary_opening_account(self.company) - row.party_type = "Customer" if self.invoice_type == "Sales" else "Supplier" - - # Allow to create invoice even if no party present in customer or supplier. - if not frappe.db.exists(row.party_type, row.party): - if self.create_missing_party: - self.add_party(row.party_type, row.party) - else: - frappe.throw(_("{0} {1} does not exist.").format(frappe.bold(row.party_type), frappe.bold(row.party))) - - if not row.item_name: - row.item_name = _("Opening Invoice Item") - if not row.posting_date: - row.posting_date = nowdate() - if not row.due_date: - row.due_date = nowdate() - - for d in ("Party", "Outstanding Amount", "Temporary Opening Account"): - if not row.get(scrub(d)): - frappe.throw(mandatory_error_msg.format(row.idx, _(d), self.invoice_type)) - - args = self.get_invoice_dict(row=row) - if not args: + if not row: continue - + self.set_missing_values(row) + self.validate_mandatory_invoice_fields(row) + invoice = self.get_invoice_dict(row) + company_details = frappe.get_cached_value('Company', self.company, ["default_currency", "default_letter_head"], as_dict=1) or {} if company_details: - args.update({ + invoice.update({ "currency": company_details.get("default_currency"), "letter_head": company_details.get("default_letter_head") }) + invoices.append(invoice) - doc = frappe.get_doc(args).insert() - doc.submit() - names.append(doc.name) - - if len(self.invoices) > 5: - frappe.publish_realtime( - "progress", dict( - progress=[row.idx, len(self.invoices)], - title=_('Creating {0}').format(doc.doctype) - ), - user=frappe.session.user - ) - - return names + return invoices def add_party(self, party_type, party): party_doc = frappe.new_doc(party_type) @@ -140,14 +124,12 @@ class OpeningInvoiceCreationTool(Document): def get_invoice_dict(self, row=None): def get_item_dict(): - default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos") - cost_center = row.get('cost_center') or frappe.get_cached_value('Company', - self.company, "cost_center") - + cost_center = row.get('cost_center') or frappe.get_cached_value('Company', self.company, "cost_center") if not cost_center: - frappe.throw( - _("Please set the Default Cost Center in {0} company.").format(frappe.bold(self.company)) - ) + frappe.throw(_("Please set the Default Cost Center in {0} company.").format(frappe.bold(self.company))) + + income_expense_account_field = "income_account" if row.party_type == "Customer" else "expense_account" + default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos") rate = flt(row.outstanding_amount) / flt(row.qty) return frappe._dict({ @@ -161,18 +143,9 @@ class OpeningInvoiceCreationTool(Document): "cost_center": cost_center }) - if not row: - return None - - party_type = "Customer" - income_expense_account_field = "income_account" - if self.invoice_type == "Purchase": - party_type = "Supplier" - income_expense_account_field = "expense_account" - item = get_item_dict() - args = frappe._dict({ + invoice = frappe._dict({ "items": [item], "is_opening": "Yes", "set_posting_time": 1, @@ -180,21 +153,76 @@ class OpeningInvoiceCreationTool(Document): "cost_center": self.cost_center, "due_date": row.due_date, "posting_date": row.posting_date, - frappe.scrub(party_type): row.party, + frappe.scrub(row.party_type): row.party, + "is_pos": 0, "doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice" }) accounting_dimension = get_accounting_dimensions() - for dimension in accounting_dimension: - args.update({ + invoice.update({ dimension: item.get(dimension) }) - if self.invoice_type == "Sales": - args["is_pos"] = 0 + return invoice - return args + def make_invoices(self): + self.validate_company() + invoices = self.get_invoices() + if len(invoices) < 50: + return start_import(invoices) + else: + from frappe.core.page.background_jobs.background_jobs import get_info + from frappe.utils.scheduler import is_scheduler_inactive + + if is_scheduler_inactive() and not frappe.flags.in_test: + frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive")) + + enqueued_jobs = [d.get("job_name") for d in get_info()] + if self.name not in enqueued_jobs: + enqueue( + start_import, + queue="default", + timeout=6000, + event="opening_invoice_creation", + job_name=self.name, + invoices=invoices, + now=frappe.conf.developer_mode or frappe.flags.in_test + ) + +def start_import(invoices): + errors = 0 + names = [] + for idx, d in enumerate(invoices): + try: + publish(idx, len(invoices), d.doctype) + doc = frappe.get_doc(d) + doc.insert() + doc.submit() + frappe.db.commit() + names.append(doc.name) + except Exception: + errors += 1 + frappe.db.rollback() + message = "\n".join(["Data:", dumps(d, default=str, indent=4), "--" * 50, "\nException:", traceback.format_exc()]) + frappe.log_error(title="Error while creating Opening Invoice", message=message) + frappe.db.commit() + if errors: + frappe.msgprint(_("You had {} errors while creating opening invoices. Check {} for more details") + .format(errors, "Error Log"), indicator="red", title=_("Error Occured")) + return names + +def publish(index, total, doctype): + if total < 5: return + frappe.publish_realtime( + "opening_invoice_creation_progress", + dict( + title=_("Opening Invoice Creation In Progress"), + message=_('Creating {} out of {} {}').format(index + 1, total, doctype), + user=frappe.session.user, + count=index+1, + total=total + )) @frappe.whitelist() def get_temporary_opening_account(company=None): diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 3bfc10dda5..54229f5247 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -44,7 +44,7 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase): 0: ["_Test Supplier", 300, "Overdue"], 1: ["_Test Supplier 1", 250, "Overdue"], } - self.check_expected_values(invoices, expected_value, invoice_type="Purchase", ) + self.check_expected_values(invoices, expected_value, "Purchase") def get_opening_invoice_creation_dict(**args): party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier" From 0bd576aa446dd2c3d3cf556e608c543d65c65f25 Mon Sep 17 00:00:00 2001 From: Marica Date: Fri, 23 Oct 2020 19:38:30 +0530 Subject: [PATCH 31/32] fix: RFQ UX fixes (#23382) * chore: RFQ UX fixes * fix: RFQ to SUpplier Quotation Dialog * fix: Remove 'No Quote' functionality - No Quote in Suppliers table is removed - It's use everywhere has been removed too (tests, server side files, DOM event) * chore: More Info section and Project field Co-authored-by: Nabin Hait --- .../request_for_quotation.js | 352 +++++++++--------- .../request_for_quotation.json | 33 +- .../request_for_quotation.py | 56 +-- .../test_request_for_quotation.py | 6 +- .../test_request_for_quotation_for_status.js | 4 - .../request_for_quotation_item.json | 14 +- .../request_for_quotation_supplier.json | 31 +- .../supplier_quotation/supplier_quotation.py | 7 +- 8 files changed, 235 insertions(+), 268 deletions(-) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 0ab39b6cee..f56c9b4f0c 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -29,14 +29,12 @@ frappe.ui.form.on("Request for Quotation",{ refresh: function(frm, cdt, cdn) { if (frm.doc.docstatus === 1) { - frm.add_custom_button(__('Create'), - function(){ frm.trigger("make_suppplier_quotation") }, __("Supplier Quotation")); - frm.add_custom_button(__("View"), - function(){ frappe.set_route('List', 'Supplier Quotation', - {'request_for_quotation': frm.doc.name}) }, __("Supplier Quotation")); + frm.add_custom_button(__('Supplier Quotation'), + function(){ frm.trigger("make_suppplier_quotation") }, __("Create")); - frm.add_custom_button(__("Send Supplier Emails"), function() { + + frm.add_custom_button(__("Send Emails to Suppliers"), function() { frappe.call({ method: 'erpnext.buying.doctype.request_for_quotation.request_for_quotation.send_supplier_emails', freeze: true, @@ -47,150 +45,82 @@ frappe.ui.form.on("Request for Quotation",{ frm.reload_doc(); } }); - }); + }, __("Tools")); + + frm.add_custom_button(__('Download PDF'), () => { + var suppliers = []; + const fields = [{ + fieldtype: 'Link', + label: __('Select a Supplier'), + fieldname: 'supplier', + options: 'Supplier', + reqd: 1, + get_query: () => { + return { + filters: [ + ["Supplier", "name", "in", frm.doc.suppliers.map((row) => {return row.supplier;})] + ] + } + } + }]; + + frappe.prompt(fields, data => { + var child = locals[cdt][cdn] + + var w = window.open( + frappe.urllib.get_full_url("/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?" + +"doctype="+encodeURIComponent(frm.doc.doctype) + +"&name="+encodeURIComponent(frm.doc.name) + +"&supplier="+encodeURIComponent(data.supplier) + +"&no_letterhead=0")); + if(!w) { + frappe.msgprint(__("Please enable pop-ups")); return; + } + }, + 'Download PDF for Supplier', + 'Download'); + }, + __("Tools")); + + frm.page.set_inner_btn_group_as_primary(__('Create')); } }, - get_suppliers_button: function (frm) { - var doc = frm.doc; - var dialog = new frappe.ui.Dialog({ - title: __("Get Suppliers"), - fields: [ - { - "fieldtype": "Select", "label": __("Get Suppliers By"), - "fieldname": "search_type", - "options": ["Tag","Supplier Group"], - "reqd": 1, - onchange() { - if(dialog.get_value('search_type') == 'Tag'){ - frappe.call({ - method: 'erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_supplier_tag', - }).then(r => { - dialog.set_df_property("tag", "options", r.message) - }); - } - } - }, - { - "fieldtype": "Link", "label": __("Supplier Group"), - "fieldname": "supplier_group", - "options": "Supplier Group", - "reqd": 0, - "depends_on": "eval:doc.search_type == 'Supplier Group'" - }, - { - "fieldtype": "Select", "label": __("Tag"), - "fieldname": "tag", - "reqd": 0, - "depends_on": "eval:doc.search_type == 'Tag'", - }, - { - "fieldtype": "Button", "label": __("Add All Suppliers"), - "fieldname": "add_suppliers" - }, - ] - }); - - dialog.fields_dict.add_suppliers.$input.click(function() { - var args = dialog.get_values(); - if(!args) return; - dialog.hide(); - - //Remove blanks - for (var j = 0; j < frm.doc.suppliers.length; j++) { - if(!frm.doc.suppliers[j].hasOwnProperty("supplier")) { - frm.get_field("suppliers").grid.grid_rows[j].remove(); - } - } - - function load_suppliers(r) { - if(r.message) { - for (var i = 0; i < r.message.length; i++) { - var exists = false; - if (r.message[i].constructor === Array){ - var supplier = r.message[i][0]; - } else { - var supplier = r.message[i].name; - } - - for (var j = 0; j < doc.suppliers.length;j++) { - if (supplier === doc.suppliers[j].supplier) { - exists = true; - } - } - if(!exists) { - var d = frm.add_child('suppliers'); - d.supplier = supplier; - frm.script_manager.trigger("supplier", d.doctype, d.name); - } - } - } - frm.refresh_field("suppliers"); - } - - if (args.search_type === "Tag" && args.tag) { - return frappe.call({ - type: "GET", - method: "frappe.desk.doctype.tag.tag.get_tagged_docs", - args: { - "doctype": "Supplier", - "tag": args.tag - }, - callback: load_suppliers - }); - } else if (args.supplier_group) { - return frappe.call({ - method: "frappe.client.get_list", - args: { - doctype: "Supplier", - order_by: "name", - fields: ["name"], - filters: [["Supplier", "supplier_group", "=", args.supplier_group]] - - }, - callback: load_suppliers - }); - } - }); - dialog.show(); - - }, make_suppplier_quotation: function(frm) { var doc = frm.doc; var dialog = new frappe.ui.Dialog({ - title: __("For Supplier"), + title: __("Create Supplier Quotation"), fields: [ { "fieldtype": "Select", "label": __("Supplier"), "fieldname": "supplier", "options": doc.suppliers.map(d => d.supplier), "reqd": 1, "default": doc.suppliers.length === 1 ? doc.suppliers[0].supplier_name : "" }, - { "fieldtype": "Button", "label": __('Create Supplier Quotation'), - "fieldname": "make_supplier_quotation", "cssClass": "btn-primary" }, - ] + ], + primary_action_label: __("Create"), + primary_action: (args) => { + if(!args) return; + dialog.hide(); + + return frappe.call({ + type: "GET", + method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq", + args: { + "source_name": doc.name, + "for_supplier": args.supplier + }, + freeze: true, + callback: function(r) { + if(!r.exc) { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + } + }); + } }); - dialog.fields_dict.make_supplier_quotation.$input.click(function() { - var args = dialog.get_values(); - if(!args) return; - dialog.hide(); - return frappe.call({ - type: "GET", - method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq", - args: { - "source_name": doc.name, - "for_supplier": args.supplier - }, - freeze: true, - callback: function(r) { - if(!r.exc) { - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", r.message.doctype, r.message.name); - } - } - }); - }); dialog.show() }, @@ -273,42 +203,6 @@ frappe.ui.form.on("Request for Quotation Supplier",{ }) }, - download_pdf: function(frm, cdt, cdn) { - var child = locals[cdt][cdn] - - var w = window.open( - frappe.urllib.get_full_url("/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?" - +"doctype="+encodeURIComponent(frm.doc.doctype) - +"&name="+encodeURIComponent(frm.doc.name) - +"&supplier_idx="+encodeURIComponent(child.idx) - +"&no_letterhead=0")); - if(!w) { - frappe.msgprint(__("Please enable pop-ups")); return; - } - }, - no_quote: function(frm, cdt, cdn) { - var d = locals[cdt][cdn]; - if (d.no_quote) { - if (d.quote_status != __('Received')) { - frappe.model.set_value(cdt, cdn, 'quote_status', 'No Quote'); - } else { - frappe.msgprint(__("Cannot set a received RFQ to No Quote")); - frappe.model.set_value(cdt, cdn, 'no_quote', 0); - } - } else { - d.quote_status = __('Pending'); - frm.call({ - method:"update_rfq_supplier_status", - doc: frm.doc, - args: { - sup_name: d.supplier - }, - callback: function(r) { - frm.refresh_field("suppliers"); - } - }); - } - } }) erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.extend({ @@ -332,7 +226,8 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e per_ordered: ["<", 99.99] } }) - }, __("Get items from")); + }, __("Get Items From")); + // Get items from Opportunity this.frm.add_custom_button(__('Opportunity'), function() { @@ -344,7 +239,8 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e company: me.frm.doc.company }, }) - }, __("Get items from")); + }, __("Get Items From")); + // Get items from open Material Requests based on supplier this.frm.add_custom_button(__('Possible Supplier'), function() { // Create a dialog window for the user to pick their supplier @@ -382,8 +278,13 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e } } d.show(); - }, __("Get items from")); + }, __("Get Items From")); + // Get Suppliers + this.frm.add_custom_button(__('Get Suppliers'), + function() { + me.get_suppliers_button(me.frm); + }); } }, @@ -393,9 +294,108 @@ erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.e tc_name: function() { this.get_terms(); - } -}); + }, + get_suppliers_button: function (frm) { + var doc = frm.doc; + var dialog = new frappe.ui.Dialog({ + title: __("Get Suppliers"), + fields: [ + { + "fieldtype": "Select", "label": __("Get Suppliers By"), + "fieldname": "search_type", + "options": ["Tag","Supplier Group"], + "reqd": 1, + onchange() { + if(dialog.get_value('search_type') == 'Tag'){ + frappe.call({ + method: 'erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_supplier_tag', + }).then(r => { + dialog.set_df_property("tag", "options", r.message) + }); + } + } + }, + { + "fieldtype": "Link", "label": __("Supplier Group"), + "fieldname": "supplier_group", + "options": "Supplier Group", + "reqd": 0, + "depends_on": "eval:doc.search_type == 'Supplier Group'" + }, + { + "fieldtype": "Select", "label": __("Tag"), + "fieldname": "tag", + "reqd": 0, + "depends_on": "eval:doc.search_type == 'Tag'", + } + ], + primary_action_label: __("Add Suppliers"), + primary_action : (args) => { + if(!args) return; + dialog.hide(); + + //Remove blanks + for (var j = 0; j < frm.doc.suppliers.length; j++) { + if(!frm.doc.suppliers[j].hasOwnProperty("supplier")) { + frm.get_field("suppliers").grid.grid_rows[j].remove(); + } + } + + function load_suppliers(r) { + if(r.message) { + for (var i = 0; i < r.message.length; i++) { + var exists = false; + if (r.message[i].constructor === Array){ + var supplier = r.message[i][0]; + } else { + var supplier = r.message[i].name; + } + + for (var j = 0; j < doc.suppliers.length;j++) { + if (supplier === doc.suppliers[j].supplier) { + exists = true; + } + } + if(!exists) { + var d = frm.add_child('suppliers'); + d.supplier = supplier; + frm.script_manager.trigger("supplier", d.doctype, d.name); + } + } + } + frm.refresh_field("suppliers"); + } + + if (args.search_type === "Tag" && args.tag) { + return frappe.call({ + type: "GET", + method: "frappe.desk.doctype.tag.tag.get_tagged_docs", + args: { + "doctype": "Supplier", + "tag": args.tag + }, + callback: load_suppliers + }); + } else if (args.supplier_group) { + return frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Supplier", + order_by: "name", + fields: ["name"], + filters: [["Supplier", "supplier_group", "=", args.supplier_group]] + + }, + callback: load_suppliers + }); + } + } + }); + + dialog.show(); + }, +}); // for backward compatibility: combine new and previous states $.extend(cur_frm.cscript, new erpnext.buying.RequestforQuotationController({frm: cur_frm})); diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index 5f01f6e24c..4e09a7ef05 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -12,9 +12,10 @@ "vendor", "column_break1", "transaction_date", + "status", + "amended_from", "suppliers_section", "suppliers", - "get_suppliers_button", "items_section", "items", "link_to_mrs", @@ -31,11 +32,7 @@ "terms", "printing_settings", "select_print_heading", - "letter_head", - "more_info", - "status", - "column_break3", - "amended_from" + "letter_head" ], "fields": [ { @@ -83,6 +80,7 @@ "width": "50%" }, { + "default": "Today", "fieldname": "transaction_date", "fieldtype": "Date", "in_list_view": 1, @@ -99,16 +97,11 @@ { "fieldname": "suppliers", "fieldtype": "Table", - "label": "Supplier Detail", + "label": "Suppliers", "options": "Request for Quotation Supplier", "print_hide": 1, "reqd": 1 }, - { - "fieldname": "get_suppliers_button", - "fieldtype": "Button", - "label": "Get Suppliers" - }, { "fieldname": "items_section", "fieldtype": "Section Break", @@ -144,6 +137,7 @@ "print_hide": 1 }, { + "allow_on_submit": 1, "fetch_from": "email_template.response", "fetch_if_empty": 1, "fieldname": "message_for_supplier", @@ -206,14 +200,6 @@ "options": "Letter Head", "print_hide": 1 }, - { - "collapsible": 1, - "fieldname": "more_info", - "fieldtype": "Section Break", - "label": "More Information", - "oldfieldtype": "Section Break", - "options": "fa fa-file-text" - }, { "fieldname": "status", "fieldtype": "Select", @@ -227,10 +213,6 @@ "reqd": 1, "search_index": 1 }, - { - "fieldname": "column_break3", - "fieldtype": "Column Break" - }, { "fieldname": "amended_from", "fieldtype": "Link", @@ -275,9 +257,10 @@ } ], "icon": "fa fa-shopping-cart", + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-10-01 14:54:50.888729", + "modified": "2020-10-16 17:49:09.561929", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 7784a7b524..a51498e935 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -28,6 +28,10 @@ class RequestforQuotation(BuyingController): super(RequestforQuotation, self).set_qty_as_per_stock_uom() self.update_email_id() + if self.docstatus < 1: + # after amend and save, status still shows as cancelled, until submit + frappe.db.set(self, 'status', 'Draft') + def validate_duplicate_supplier(self): supplier_list = [d.supplier for d in self.suppliers] if len(supplier_list) != len(set(supplier_list)): @@ -82,7 +86,7 @@ class RequestforQuotation(BuyingController): # make new user if required update_password_link, contact = self.update_supplier_contact(rfq_supplier, self.get_link()) - self.update_supplier_part_no(rfq_supplier) + self.update_supplier_part_no(rfq_supplier.supplier) self.supplier_rfq_mail(rfq_supplier, update_password_link, self.get_link()) rfq_supplier.email_sent = 1 if not rfq_supplier.contact: @@ -93,11 +97,11 @@ class RequestforQuotation(BuyingController): # RFQ link for supplier portal return get_url("/rfq/" + self.name) - def update_supplier_part_no(self, args): - self.vendor = args.supplier + def update_supplier_part_no(self, supplier): + self.vendor = supplier for item in self.items: item.supplier_part_no = frappe.db.get_value('Item Supplier', - {'parent': item.item_code, 'supplier': args.supplier}, 'supplier_part_no') + {'parent': item.item_code, 'supplier': supplier}, 'supplier_part_no') def update_supplier_contact(self, rfq_supplier, link): '''Create a new user for the supplier if not set in contact''' @@ -197,23 +201,22 @@ class RequestforQuotation(BuyingController): def update_rfq_supplier_status(self, sup_name=None): for supplier in self.suppliers: if sup_name == None or supplier.supplier == sup_name: - if supplier.quote_status != _('No Quote'): - quote_status = _('Received') - for item in self.items: - sqi_count = frappe.db.sql(""" - SELECT - COUNT(sqi.name) as count - FROM - `tabSupplier Quotation Item` as sqi, - `tabSupplier Quotation` as sq - WHERE sq.supplier = %(supplier)s - AND sqi.docstatus = 1 - AND sqi.request_for_quotation_item = %(rqi)s - AND sqi.parent = sq.name""", - {"supplier": supplier.supplier, "rqi": item.name}, as_dict=1)[0] - if (sqi_count.count) == 0: - quote_status = _('Pending') - supplier.quote_status = quote_status + quote_status = _('Received') + for item in self.items: + sqi_count = frappe.db.sql(""" + SELECT + COUNT(sqi.name) as count + FROM + `tabSupplier Quotation Item` as sqi, + `tabSupplier Quotation` as sq + WHERE sq.supplier = %(supplier)s + AND sqi.docstatus = 1 + AND sqi.request_for_quotation_item = %(rqi)s + AND sqi.parent = sq.name""", + {"supplier": supplier.supplier, "rqi": item.name}, as_dict=1)[0] + if (sqi_count.count) == 0: + quote_status = _('Pending') + supplier.quote_status = quote_status @frappe.whitelist() @@ -322,16 +325,15 @@ def create_rfq_items(sq_doc, supplier, data): }) @frappe.whitelist() -def get_pdf(doctype, name, supplier_idx): - doc = get_rfq_doc(doctype, name, supplier_idx) +def get_pdf(doctype, name, supplier): + doc = get_rfq_doc(doctype, name, supplier) if doc: download_pdf(doctype, name, doc=doc) -def get_rfq_doc(doctype, name, supplier_idx): - if cint(supplier_idx): +def get_rfq_doc(doctype, name, supplier): + if supplier: doc = frappe.get_doc(doctype, name) - args = doc.get('suppliers')[cint(supplier_idx) - 1] - doc.update_supplier_part_no(args) + doc.update_supplier_part_no(supplier) return doc @frappe.whitelist() diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index ea38129a70..36f87b0b84 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -25,14 +25,10 @@ class TestRequestforQuotation(unittest.TestCase): sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get('suppliers')[0].supplier) sq.submit() - # No Quote first supplier quotation - rfq.get('suppliers')[1].no_quote = 1 - rfq.get('suppliers')[1].quote_status = 'No Quote' - rfq.update_rfq_supplier_status() #rfq.get('suppliers')[1].supplier) self.assertEqual(rfq.get('suppliers')[0].quote_status, 'Received') - self.assertEqual(rfq.get('suppliers')[1].quote_status, 'No Quote') + self.assertEqual(rfq.get('suppliers')[1].quote_status, 'Pending') def test_make_supplier_quotation(self): rfq = make_request_for_quotation() diff --git a/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js b/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js index 1a9cd351dc..2e1652de73 100644 --- a/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js +++ b/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js @@ -84,9 +84,6 @@ QUnit.test("Test: Request for Quotation", function (assert) { cur_frm.fields_dict.suppliers.grid.grid_rows[0].toggle_view(); }, () => frappe.timeout(1), - () => { - frappe.click_check('No Quote'); - }, () => frappe.timeout(1), () => { cur_frm.cur_grid.toggle_view(); @@ -125,7 +122,6 @@ QUnit.test("Test: Request for Quotation", function (assert) { () => frappe.timeout(1), () => { assert.ok(cur_frm.fields_dict.suppliers.grid.grid_rows[1].doc.quote_status == "Received"); - assert.ok(cur_frm.fields_dict.suppliers.grid.grid_rows[0].doc.no_quote == 1); }, () => done() ]); diff --git a/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json b/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json index 408f49f523..e07f4626b8 100644 --- a/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json +++ b/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json @@ -27,10 +27,11 @@ "stock_qty", "warehouse_and_reference", "warehouse", - "project_name", "col_break4", "material_request", "material_request_item", + "section_break_24", + "project_name", "section_break_23", "page_break" ], @@ -161,7 +162,7 @@ { "fieldname": "project_name", "fieldtype": "Link", - "label": "Project Name", + "label": "Project", "options": "Project", "print_hide": 1 }, @@ -249,11 +250,18 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_24", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-06-12 19:10:36.333441", + "modified": "2020-09-24 17:26:46.276934", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation Item", diff --git a/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json b/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json index ce9316f987..96d7e2dbcf 100644 --- a/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json +++ b/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json @@ -9,19 +9,19 @@ "email_sent", "supplier", "contact", - "no_quote", "quote_status", "column_break_3", "supplier_name", - "email_id", - "download_pdf" + "email_id" ], "fields": [ { "allow_on_submit": 1, + "columns": 2, "default": "1", "fieldname": "send_email", "fieldtype": "Check", + "in_list_view": 1, "label": "Send Email" }, { @@ -35,7 +35,7 @@ "read_only": 1 }, { - "columns": 4, + "columns": 2, "fieldname": "supplier", "fieldtype": "Link", "in_list_view": 1, @@ -45,7 +45,7 @@ }, { "allow_on_submit": 1, - "columns": 3, + "columns": 2, "fieldname": "contact", "fieldtype": "Link", "in_list_view": 1, @@ -55,19 +55,11 @@ }, { "allow_on_submit": 1, - "default": "0", - "depends_on": "eval:doc.docstatus >= 1 && doc.quote_status != 'Received'", - "fieldname": "no_quote", - "fieldtype": "Check", - "label": "No Quote" - }, - { - "allow_on_submit": 1, - "depends_on": "eval:doc.docstatus >= 1 && !doc.no_quote", + "depends_on": "eval:doc.docstatus >= 1", "fieldname": "quote_status", "fieldtype": "Select", "label": "Quote Status", - "options": "Pending\nReceived\nNo Quote", + "options": "Pending\nReceived", "read_only": 1 }, { @@ -90,17 +82,12 @@ "in_list_view": 1, "label": "Email Id", "no_copy": 1 - }, - { - "allow_on_submit": 1, - "fieldname": "download_pdf", - "fieldtype": "Button", - "label": "Download PDF" } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-28 19:31:11.855588", + "modified": "2020-10-16 12:23:41.769820", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation Supplier", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index baf245735a..ae5611f3c4 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -91,12 +91,7 @@ class SupplierQuotation(BuyingController): for my_item in self.items) if include_me else 0 if (sqi_count.count + self_count) == 0: quote_status = _('Pending') - if quote_status == _('Received') and doc_sup.quote_status == _('No Quote'): - frappe.msgprint(_("{0} indicates that {1} will not provide a quotation, but all items \ - have been quoted. Updating the RFQ quote status.").format(doc.name, self.supplier)) - frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'quote_status', quote_status) - frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'no_quote', 0) - elif doc_sup.quote_status != _('No Quote'): + frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'quote_status', quote_status) def get_list_context(context=None): From 3ad9393ff8aba8c1b2e4aacc4ef95a5ddf96d4f0 Mon Sep 17 00:00:00 2001 From: Marica Date: Fri, 23 Oct 2020 19:40:55 +0530 Subject: [PATCH 32/32] fix: SO to PO flow improvement (#23357) * fix: SO to PO flow improvement * fix: Dont map shipping_address - shipping_address is a text field in SO and link field in PO - Drop shipping case handles its mapping - normal case doesnt need to map * fix: Hide/Add rows depending on Against Default Supplier * fix: Removed Default Supplier Select field from popup - removed Default Supplier Select field from popup - only loop through suppliers of selected items if via default supplier - only check for items in selected items * fix: Sales Order Drop Shipping Test * fix: (translation)Multi line to single line strings Co-authored-by: Nabin Hait --- .../doctype/sales_order/sales_order.js | 161 +++++++++++------- .../doctype/sales_order/sales_order.py | 140 ++++++++------- .../doctype/sales_order/test_sales_order.py | 89 ++++------ 3 files changed, 216 insertions(+), 174 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 7b46fb6fca..989bd33e42 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -162,7 +162,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( // sales invoice if(flt(doc.per_billed, 6) < 100) { - this.frm.add_custom_button(__('Invoice'), () => me.make_sales_invoice(), __('Create')); + this.frm.add_custom_button(__('Sales Invoice'), () => me.make_sales_invoice(), __('Create')); } // material request @@ -554,19 +554,32 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( }, make_purchase_order: function(){ + let pending_items = this.frm.doc.items.some((item) =>{ + let pending_qty = flt(item.stock_qty) - flt(item.ordered_qty); + return pending_qty > 0; + }) + if(!pending_items){ + frappe.throw({message: __("Purchase Order already created for all Sales Order items"), title: __("Note")}); + } + var me = this; var dialog = new frappe.ui.Dialog({ - title: __("For Supplier"), + title: __("Select Items"), fields: [ - {"fieldtype": "Link", "label": __("Supplier"), "fieldname": "supplier", "options":"Supplier", - "description": __("Leave the field empty to make purchase orders for all suppliers"), - "get_query": function () { - return { - query:"erpnext.selling.doctype.sales_order.sales_order.get_supplier", - filters: {'parent': me.frm.doc.name} - } - }}, - {fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items', + { + "fieldtype": "Check", + "label": __("Against Default Supplier"), + "fieldname": "against_default_supplier", + "default": 0 + }, + { + "fieldtype": "Section Break", + "label": "", + "fieldname": "sec_break_dialog", + "hide_border": 1 + }, + { + fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items', fields: [ { fieldtype:'Data', @@ -584,8 +597,8 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( }, { fieldtype:'Float', - fieldname:'qty', - label: __('Quantity'), + fieldname:'pending_qty', + label: __('Pending Qty'), read_only: 1, in_list_view:1 }, @@ -594,60 +607,86 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( read_only:1, fieldname:'uom', label: __('UOM'), + in_list_view:1, + }, + { + fieldtype:'Data', + fieldname:'supplier', + label: __('Supplier'), + read_only:1, in_list_view:1 - } + }, ], - data: cur_frm.doc.items, - get_data: function() { - return cur_frm.doc.items - } - }, - - {"fieldtype": "Button", "label": __('Create Purchase Order'), "fieldname": "make_purchase_order", "cssClass": "btn-primary"}, - ] - }); - - dialog.fields_dict.make_purchase_order.$input.click(function() { - var args = dialog.get_values(); - let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children() - if(selected_items.length == 0) { - frappe.throw({message: 'Please select Item form Table', title: __('Message'), indicator:'blue'}) - } - let selected_items_list = [] - for(let i in selected_items){ - selected_items_list.push(selected_items[i].item_code) - } - dialog.hide(); - return frappe.call({ - type: "GET", - method: "erpnext.selling.doctype.sales_order.sales_order.make_purchase_order", - args: { - "source_name": me.frm.doc.name, - "for_supplier": args.supplier, - "selected_items": selected_items_list - }, - freeze: true, - callback: function(r) { - if(!r.exc) { - // var args = dialog.get_values(); - if (args.supplier){ - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", r.message.doctype, r.message.name); - } - else{ - frappe.route_options = { - "sales_order": me.frm.doc.name - } - frappe.set_route("List", "Purchase Order"); - } - } + data: me.frm.doc.items.map((item) =>{ + item.pending_qty = (flt(item.stock_qty) - flt(item.ordered_qty)) / flt(item.conversion_factor); + return item; + }).filter((item) => {return item.pending_qty > 0;}) } - }) + ], + primary_action_label: 'Create Purchase Order', + primary_action (args) { + if (!args) return; + let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children(); + if(selected_items.length == 0) { + frappe.throw({message: 'Please select Items from the Table', title: __('Items Required'), indicator:'blue'}) + } + + dialog.hide(); + + var method = args.against_default_supplier ? "make_purchase_order_for_default_supplier" : "make_purchase_order" + return frappe.call({ + type: "GET", + method: "erpnext.selling.doctype.sales_order.sales_order." + method, + args: { + "source_name": me.frm.doc.name, + "selected_items": selected_items + }, + freeze: true, + callback: function(r) { + if(!r.exc) { + if (!args.against_default_supplier) { + frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + else { + frappe.route_options = { + "sales_order": me.frm.doc.name + } + frappe.set_route("List", "Purchase Order"); + } + } + } + }) + } }); - dialog.get_field("items_for_po").grid.only_sortable() - dialog.get_field("items_for_po").refresh() + + dialog.fields_dict["against_default_supplier"].df.onchange = () => { + console.log("yo"); + var against_default_supplier = dialog.get_value("against_default_supplier"); + var items_for_po = dialog.get_value("items_for_po"); + + if (against_default_supplier) { + let items_with_supplier = items_for_po.filter((item) => item.supplier) + + dialog.fields_dict["items_for_po"].df.data = items_with_supplier; + dialog.get_field("items_for_po").refresh(); + } else { + let pending_items = me.frm.doc.items.map((item) =>{ + item.pending_qty = (flt(item.stock_qty) - flt(item.ordered_qty)) / flt(item.conversion_factor); + return item; + }).filter((item) => {return item.pending_qty > 0;}); + + dialog.fields_dict["items_for_po"].df.data = pending_items; + dialog.get_field("items_for_po").refresh(); + } + } + + dialog.get_field("items_for_po").grid.only_sortable(); + dialog.get_field("items_for_po").refresh(); + dialog.wrapper.find('.grid-heading-row .grid-row-check').click(); dialog.show(); }, + hold_sales_order: function(){ var me = this; var d = new frappe.ui.Dialog({ diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index fe3fa82e84..ae227e0110 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -443,25 +443,19 @@ class SalesOrder(SellingController): for item in self.items: if item.ensure_delivery_based_on_produced_serial_no: if item.item_code in normal_items: - frappe.throw(_("Cannot ensure delivery by Serial No as \ - Item {0} is added with and without Ensure Delivery by \ - Serial No.").format(item.item_code)) + frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code)) if item.item_code not in reserved_items: if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"): - frappe.throw(_("Item {0} has no Serial No. Only serilialized items \ - can have delivery based on Serial No").format(item.item_code)) + frappe.throw(_("Item {0} has no Serial No. Only serilialized items can have delivery based on Serial No").format(item.item_code)) if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}): - frappe.throw(_("No active BOM found for item {0}. Delivery by \ - Serial No cannot be ensured").format(item.item_code)) + frappe.throw(_("No active BOM found for item {0}. Delivery by Serial No cannot be ensured").format(item.item_code)) reserved_items.append(item.item_code) else: normal_items.append(item.item_code) if not item.ensure_delivery_based_on_produced_serial_no and \ item.item_code in reserved_items: - frappe.throw(_("Cannot ensure delivery by Serial No as \ - Item {0} is added with and without Ensure Delivery by \ - Serial No.").format(item.item_code)) + frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code)) def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -785,7 +779,7 @@ def get_events(start, end, filters=None): return data @frappe.whitelist() -def make_purchase_order(source_name, for_supplier=None, selected_items=[], target_doc=None): +def make_purchase_order_for_default_supplier(source_name, selected_items=[], target_doc=None): if isinstance(selected_items, string_types): selected_items = json.loads(selected_items) @@ -822,24 +816,21 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe def update_item(source, target, source_parent): target.schedule_date = source.delivery_date - target.qty = flt(source.qty) - flt(source.ordered_qty) - target.stock_qty = (flt(source.qty) - flt(source.ordered_qty)) * flt(source.conversion_factor) + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) target.project = source_parent.project - suppliers =[] - if for_supplier: - suppliers.append(for_supplier) - else: - sales_order = frappe.get_doc("Sales Order", source_name) - for item in sales_order.items: - if item.supplier and item.supplier not in suppliers: - suppliers.append(item.supplier) + suppliers = [item.get('supplier') for item in selected_items if item.get('supplier') and item.get('supplier')] + suppliers = list(set(suppliers)) + + items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')] + items_to_map = list(set(items_to_map)) if not suppliers: frappe.throw(_("Please set a Supplier against the Items to be considered in the Purchase Order.")) for supplier in suppliers: - po =frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) + po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) if len(po) == 0: doc = get_mapped_doc("Sales Order", source_name, { "Sales Order": { @@ -850,7 +841,8 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe "contact_mobile", "contact_email", "contact_person", - "taxes_and_charges" + "taxes_and_charges", + "shipping_address" ], "validation": { "docstatus": ["=", 1] @@ -872,52 +864,82 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe "item_tax_template" ], "postprocess": update_item, - "condition": lambda doc: doc.ordered_qty < doc.qty and doc.supplier == supplier and doc.item_code in selected_items + "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.supplier == supplier and doc.item_code in items_to_map } }, target_doc, set_missing_values) - if not for_supplier: - doc.insert() + + doc.insert() else: suppliers =[] if suppliers: - if not for_supplier: - frappe.db.commit() + frappe.db.commit() return doc else: - frappe.msgprint(_("PO already created for all sales order items")) - + frappe.msgprint(_("Purchase Order already created for all Sales Order items")) @frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def get_supplier(doctype, txt, searchfield, start, page_len, filters): - supp_master_name = frappe.defaults.get_user_default("supp_master_name") - if supp_master_name == "Supplier Name": - fields = ["name", "supplier_group"] - else: - fields = ["name", "supplier_name", "supplier_group"] - fields = ", ".join(fields) +def make_purchase_order(source_name, selected_items=[], target_doc=None): + if isinstance(selected_items, string_types): + selected_items = json.loads(selected_items) - return frappe.db.sql("""select {field} from `tabSupplier` - where docstatus < 2 - and ({key} like %(txt)s - or supplier_name like %(txt)s) - and name in (select supplier from `tabSales Order Item` where parent = %(parent)s) - and name not in (select supplier from `tabPurchase Order` po inner join `tabPurchase Order Item` poi - on po.name=poi.parent where po.docstatus<2 and poi.sales_order=%(parent)s) - order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999), - name, supplier_name - limit %(start)s, %(page_len)s """.format(**{ - 'field': fields, - 'key': frappe.db.escape(searchfield) - }), { - 'txt': "%%%s%%" % txt, - '_txt': txt.replace("%", ""), - 'start': start, - 'page_len': page_len, - 'parent': filters.get('parent') - }) + items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')] + items_to_map = list(set(items_to_map)) + + def set_missing_values(source, target): + target.supplier = "" + target.apply_discount_on = "" + target.additional_discount_percentage = 0.0 + target.discount_amount = 0.0 + target.inter_company_order_reference = "" + target.customer = "" + target.customer_name = "" + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + + def update_item(source, target, source_parent): + target.schedule_date = source.delivery_date + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) + target.project = source_parent.project + + # po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) + doc = get_mapped_doc("Sales Order", source_name, { + "Sales Order": { + "doctype": "Purchase Order", + "field_no_map": [ + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "taxes_and_charges", + "shipping_address" + ], + "validation": { + "docstatus": ["=", 1] + } + }, + "Sales Order Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_item"], + ["parent", "sales_order"], + ["stock_uom", "stock_uom"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["delivery_date", "schedule_date"] + ], + "field_no_map": [ + "rate", + "price_list_rate", + "item_tax_template", + "supplier" + ], + "postprocess": update_item, + "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.item_code in items_to_map + } + }, target_doc, set_missing_values) + return doc @frappe.whitelist() def make_work_orders(items, sales_order, company, project=None): diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 2f5f979bdf..9e25ed0c99 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -688,12 +688,12 @@ class TestSalesOrder(unittest.TestCase): frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 1) def test_drop_shipping(self): - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order_for_default_supplier, \ + update_status as so_update_status from erpnext.buying.doctype.purchase_order.purchase_order import update_status - make_stock_entry(target="_Test Warehouse - _TC", qty=10, rate=100) + # make items po_item = make_item("_Test Item for Drop Shipping", {"is_stock_item": 1, "delivered_by_supplier": 1}) - dn_item = make_item("_Test Regular Item", {"is_stock_item": 1}) so_items = [ @@ -715,80 +715,61 @@ class TestSalesOrder(unittest.TestCase): ] if frappe.db.get_value("Item", "_Test Regular Item", "is_stock_item")==1: - make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=10, rate=100) + make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=2, rate=100) - #setuo existing qty from bin - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, - fields=["ordered_qty", "reserved_qty"]) - - existing_ordered_qty = bin[0].ordered_qty if bin else 0.0 - existing_reserved_qty = bin[0].reserved_qty if bin else 0.0 - - bin = frappe.get_all("Bin", filters={"item_code": dn_item.item_code, - "warehouse": "_Test Warehouse - _TC"}, fields=["reserved_qty"]) - - existing_reserved_qty_for_dn_item = bin[0].reserved_qty if bin else 0.0 - - #create so, po and partial dn + #create so, po and dn so = make_sales_order(item_list=so_items, do_not_submit=True) so.submit() - po = make_purchase_order(so.name, '_Test Supplier', selected_items=[so_items[0]['item_code']]) + po = make_purchase_order_for_default_supplier(so.name, selected_items=[so_items[0]]) po.submit() - dn = create_dn_against_so(so.name, delivered_qty=1) + dn = create_dn_against_so(so.name, delivered_qty=2) self.assertEqual(so.customer, po.customer) self.assertEqual(po.items[0].sales_order, so.name) self.assertEqual(po.items[0].item_code, po_item.item_code) self.assertEqual(dn.items[0].item_code, dn_item.item_code) - - #test ordered_qty and reserved_qty - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, - fields=["ordered_qty", "reserved_qty"]) - - ordered_qty = bin[0].ordered_qty if bin else 0.0 - reserved_qty = bin[0].reserved_qty if bin else 0.0 - - self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty) - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty) - - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item + 1) - #test po_item length self.assertEqual(len(po.items), 1) - #test per_delivered status + # test ordered_qty and reserved_qty for drop ship item + bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, + fields=["ordered_qty", "reserved_qty"]) + + ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0 + reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0 + + # drop ship PO should not impact bin, test the same + self.assertEqual(abs(flt(ordered_qty)), 0) + self.assertEqual(abs(flt(reserved_qty)), 0) + + # test per_delivered status update_status("Delivered", po.name) - self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 75.00) + self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 100.00) + po.load_from_db() - #test reserved qty after complete delivery - dn = create_dn_against_so(so.name, delivered_qty=1) - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item) - - #test after closing so + # test after closing so so.db_set('status', "Closed") so.update_reserved_qty() - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, + # test ordered_qty and reserved_qty for drop ship item after closing so + bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, fields=["ordered_qty", "reserved_qty"]) - ordered_qty = bin[0].ordered_qty if bin else 0.0 - reserved_qty = bin[0].reserved_qty if bin else 0.0 + ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0 + reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0 - self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty) - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty) + self.assertEqual(abs(flt(ordered_qty)), 0) + self.assertEqual(abs(flt(reserved_qty)), 0) - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item) + # teardown + so_update_status("Draft", so.name) + dn.load_from_db() + dn.cancel() + po.cancel() + so.load_from_db() + so.cancel() def test_reserved_qty_for_closing_so(self): bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"},