diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 4d3cbea6bc1..41f9ce030a1 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/accounts/doctype/cashier_closing/cashier_closing.py b/erpnext/accounts/doctype/cashier_closing/cashier_closing.py index 6de62ee5777..7ad1d3ab831 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: 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 50fc3bbab75..51fc3f72cd1 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,7 +46,9 @@ ], "icon": "fa fa-credit-card", "idx": 1, - "modified": "2020-09-18 17:26:09.703215", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-18 17:57:23.835236", "modified_by": "Administrator", "module": "Accounts", "name": "Mode of Payment", 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 699eb08e178..3ce5701823e 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 a53417eedf9..d51856a8a4b 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 3bfc10dda55..54229f52470 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" 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 8dc26288206..12e6f5ef22d 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": "
\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 diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index e1e43140c01..901ef1987b4 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.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 8eadfd0b24a..2ee356aaf40 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 e93ec951fb0..1b97050eb13 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,25 @@ 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) + 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, + 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() self.set_as_cancelled() @@ -105,13 +119,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): @@ -140,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""" @@ -151,7 +170,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 @@ -166,8 +185,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) payment_entry.update({ "reference_no": self.name, @@ -255,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 frappe.local.session.user != "Guest") and self.payment_channel != "Phone": success_url = shopping_cart_settings.payment_success_url if success_url: @@ -280,7 +299,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 +309,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,9 +333,11 @@ 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, + "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), @@ -344,7 +365,7 @@ 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 if dt in ["Sales Order", "Purchase Order"]: @@ -356,6 +377,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 @@ -366,6 +393,10 @@ def get_amount(ref_doc): 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` @@ -373,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/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 3be43044aad..c43cb794aa5 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -201,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 4780688471c..1cff3c661d5 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/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 7229aff43a1..5b0822e3234 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 @@ -57,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): @@ -69,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') @@ -312,6 +326,32 @@ 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": + 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, + }) + record = { + "payment_gateway": payment_gateway, + "dt": "POS Invoice", + "dn": self.name, + "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 + } + + 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/accounts/page/bank_reconciliation/bank_reconciliation.py b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py index a168cd1a7d5..8abe20c00a4 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: diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 008f6e82369..53677cde8aa 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -796,7 +796,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") @@ -831,7 +831,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/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index a0ab2a00f99..618212da804 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/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 0ab39b6cee3..f56c9b4f0c3 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 5f01f6e24c3..4e09a7ef055 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 7784a7b5249..a51498e9354 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 ea38129a70a..36f87b0b841 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 1a9cd351dc7..2e1652de733 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 408f49f5233..e07f4626b8f 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 ce9316f9877..96d7e2dbcf1 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 baf245735a4..ae5611f3c41 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): diff --git a/erpnext/domains/healthcare.py b/erpnext/domains/healthcare.py index 8bd4c762907..bbeb2c66bcf 100644 --- a/erpnext/domains/healthcare.py +++ b/erpnext/domains/healthcare.py @@ -49,6 +49,22 @@ data = { 'fieldname': 'reference_dn', 'label': 'Reference Name', 'fieldtype': 'Dynamic Link', 'options': 'reference_dt', 'insert_after': 'reference_dt' } + ], + 'Stock Entry': [ + { + 'fieldname': 'inpatient_medication_entry', 'label': 'Inpatient Medication Entry', 'fieldtype': 'Link', 'options': 'Inpatient Medication Entry', + 'insert_after': 'credit_note', 'read_only': True + } + ], + 'Stock Entry Detail': [ + { + 'fieldname': 'patient', 'label': 'Patient', 'fieldtype': 'Link', 'options': 'Patient', + 'insert_after': 'po_detail', 'read_only': True + }, + { + 'fieldname': 'inpatient_medication_entry_child', 'label': 'Inpatient Medication Entry Child', 'fieldtype': 'Data', + 'insert_after': 'patient', 'read_only': True + } ] }, 'on_setup': 'erpnext.healthcare.setup.setup_healthcare' 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 00000000000..e69de29bb2d 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 00000000000..2c4d4bbdecf --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html @@ -0,0 +1,28 @@ + +{% if not jQuery.isEmptyObject(data) %} +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
| {{ __("Account Type") }} | +{{ __("Current Balance") }} | +{{ __("Available Balance") }} | +{{ __("Reserved Balance") }} | +{{ __("Uncleared Balance") }} | +
|---|---|---|---|---|
| {%= key %} | +{%= value["current_balance"] %} | +{%= value["available_balance"] %} | +{%= value["reserved_balance"] %} | +{%= value["uncleared_balance"] %} | +
Account Balance Information Not Available.
+{% endif %} \ No newline at end of file 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 00000000000..d33b0a70894 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -0,0 +1,118 @@ +import base64 +import requests +from requests.auth import HTTPBasicAuth +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 + if env == "sandbox": + self.base_url = sandbox_url + else: + self.base_url = live_url + self.authenticate() + + def authenticate(self): + """ + 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. + """ + 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_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py new file mode 100644 index 00000000000..0499e88b5e7 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -0,0 +1,53 @@ +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" + }, + { + "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"): + create_custom_fields(pos_field) + + record_dict = [{ + "doctype": "POS Field", + "fieldname": "contact_mobile", + "label": "Mobile No", + "fieldtype": "Data", + "options": "Phone", + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" + }, + { + "doctype": "POS Field", + "fieldname": "request_for_payment", + "label": "Request for Payment", + "fieldtype": "Button", + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" + } + ] + 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.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js new file mode 100644 index 00000000000..636aa99de44 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -0,0 +1,36 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Mpesa Settings', { + onload_post_render: function(frm) { + 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) { + frappe.throw(__("Please set the initiator name and the security credential")); + } + frappe.call({ + method: "get_account_balance_info", + doc: frm.doc + }); + }, + + 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', { + 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 new file mode 100644 index 00000000000..fc7b310c087 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json @@ -0,0 +1,135 @@ +{ + "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", + "initiator_name", + "till_number", + "sandbox", + "column_break_4", + "online_passkey", + "security_credential", + "get_account_balance", + "account_balance" + ], + "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 + }, + { + "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" + } + ], + "links": [], + "modified": "2020-09-25 20:21:38.215494", + "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 00000000000..dea4d817701 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + + +from __future__ import unicode_literals +from json import loads, dumps + +import frappe +from frappe.model.document import Document +from frappe import _ +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 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 = ["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() + 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, 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): + 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): + payload = dict( + reference_doctype="Mpesa Settings", + reference_docname=self.name, + doc_details=vars(self) + ) + + 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): + """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") + error = response + else: + # global checkout id used as request name + req_name = getattr(response, global_id) + error = None + + create_request_log(request_dict, "Host", "Mpesa", req_name, error) + + if error: + 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" + + 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")) + + 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=mpesa_settings.till_number, + phone_number=mobile_number, 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")) + +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 from stk.""" + 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(loads(request.data)) + + if transaction_response['ResultCode'] == 0: + if request.reference_doctype and request.reference_docname: + try: + 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_name, "mpesa_receipt_number", mpesa_receipt) + request.handle_success(transaction_response) + except Exception: + request.handle_failure(transaction_response) + frappe.log_error(frappe.get_traceback()) + + else: + 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) + +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" + + 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): + """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", "") + request = frappe.get_doc("Integration Request", conversation_id) + + if request.status == "Completed": + return + + transaction_data = frappe._dict(loads(request.data)) + + 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 = 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) + + request.handle_success(account_balance_response) + frappe.publish_realtime("refresh_mpesa_dashboard") + except Exception: + request.handle_failure(account_balance_response) + frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) + else: + request.handle_failure(account_balance_response) + +def format_string_to_json(balance_info): + """ + 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'}} + """ + balance_dict = frappe._dict() + for account_info in balance_info.split("&"): + account_info = account_info.split('|') + balance_dict[account_info[0]] = dict( + 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) + +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 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 00000000000..4e86d365e36 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals +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): + def test_creation_of_payment_gateway(self): + 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): + 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( #nosec + 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 diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 84f7f5a5d41..e278fd78071 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_type="General"): + 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": 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/healthcare/doctype/drug_prescription/drug_prescription.json b/erpnext/healthcare/doctype/drug_prescription/drug_prescription.json index 5e4d59cacf7..d91e6bf9dc6 100644 --- a/erpnext/healthcare/doctype/drug_prescription/drug_prescription.json +++ b/erpnext/healthcare/doctype/drug_prescription/drug_prescription.json @@ -43,7 +43,8 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Dosage", - "options": "Prescription Dosage" + "options": "Prescription Dosage", + "reqd": 1 }, { "fieldname": "period", @@ -51,14 +52,16 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Period", - "options": "Prescription Duration" + "options": "Prescription Duration", + "reqd": 1 }, { "fieldname": "dosage_form", "fieldtype": "Link", "ignore_user_permissions": 1, "label": "Dosage Form", - "options": "Dosage Form" + "options": "Dosage Form", + "reqd": 1 }, { "fieldname": "column_break_7", @@ -72,7 +75,7 @@ "label": "Comment" }, { - "depends_on": "use_interval", + "depends_on": "usage_interval", "fieldname": "interval", "fieldtype": "Int", "in_list_view": 1, @@ -80,6 +83,7 @@ }, { "default": "1", + "depends_on": "usage_interval", "fieldname": "update_schedule", "fieldtype": "Check", "hidden": 1, @@ -99,12 +103,13 @@ "default": "0", "fieldname": "usage_interval", "fieldtype": "Check", + "hidden": 1, "label": "Dosage by Time Interval" } ], "istable": 1, "links": [], - "modified": "2020-02-26 17:02:42.741338", + "modified": "2020-09-30 23:32:09.495288", "modified_by": "Administrator", "module": "Healthcare", "name": "Drug Prescription", diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/__init__.py b/erpnext/healthcare/doctype/inpatient_medication_entry/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.js b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.js new file mode 100644 index 00000000000..b953b8adff5 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.js @@ -0,0 +1,37 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Inpatient Medication Entry', { + refresh: function(frm) { + // Ignore cancellation of doctype on cancel all + frm.ignore_doctypes_on_cancel_all = ['Stock Entry']; + + frm.set_query('item_code', () => { + return { + filters: { + is_stock_item: 1 + } + }; + }); + + frm.set_query('drug_code', 'medication_orders', () => { + return { + filters: { + is_stock_item: 1 + } + }; + }); + }, + + get_medication_orders: function(frm) { + frappe.call({ + method: 'get_medication_orders', + doc: frm.doc, + freeze: true, + freeze_message: __('Fetching Pending Medication Orders'), + callback: function() { + refresh_field('medication_orders'); + } + }); + } +}); diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.json b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.json new file mode 100644 index 00000000000..5d80251b713 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.json @@ -0,0 +1,203 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2020-09-25 14:13:20.111906", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "company", + "column_break_3", + "posting_date", + "status", + "filters_section", + "item_code", + "assigned_to_practitioner", + "patient", + "practitioner", + "service_unit", + "column_break_11", + "from_date", + "to_date", + "from_time", + "to_time", + "select_medication_orders_section", + "get_medication_orders", + "medication_orders", + "section_break_18", + "update_stock", + "warehouse", + "amended_from" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "HLC-IME-.YYYY.-" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Posting Date", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "\nDraft\nSubmitted\nPending\nIn Process\nCompleted\nCancelled", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "filters_section", + "fieldtype": "Section Break", + "label": "Filters" + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code (Drug)", + "options": "Item" + }, + { + "depends_on": "update_stock", + "description": "Warehouse from where medication stock should be consumed", + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Medication Warehouse", + "mandatory_depends_on": "update_stock", + "options": "Warehouse" + }, + { + "fieldname": "patient", + "fieldtype": "Link", + "label": "Patient", + "options": "Patient" + }, + { + "fieldname": "service_unit", + "fieldtype": "Link", + "label": "Healthcare Service Unit", + "options": "Healthcare Service Unit" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Inpatient Medication Entry", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "practitioner", + "fieldtype": "Link", + "label": "Healthcare Practitioner", + "options": "Healthcare Practitioner" + }, + { + "fieldname": "select_medication_orders_section", + "fieldtype": "Section Break", + "label": "Medication Orders" + }, + { + "fieldname": "medication_orders", + "fieldtype": "Table", + "label": "Inpatient Medication Orders", + "options": "Inpatient Medication Entry Detail", + "read_only": 1, + "reqd": 1 + }, + { + "depends_on": "eval:doc.docstatus!==1", + "fieldname": "get_medication_orders", + "fieldtype": "Button", + "label": "Get Pending Medication Orders", + "print_hide": 1 + }, + { + "fieldname": "assigned_to_practitioner", + "fieldtype": "Link", + "label": "Assigned To", + "options": "User" + }, + { + "fieldname": "section_break_18", + "fieldtype": "Section Break", + "label": "Stock Details" + }, + { + "default": "1", + "fieldname": "update_stock", + "fieldtype": "Check", + "label": "Update Stock" + }, + { + "fieldname": "from_time", + "fieldtype": "Time", + "label": "From Time" + }, + { + "fieldname": "to_time", + "fieldtype": "Time", + "label": "To Time" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2020-09-30 23:40:45.528715", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Inpatient Medication Entry", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py new file mode 100644 index 00000000000..23858931098 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt, get_link_to_form, getdate, nowtime +from erpnext.stock.utils import get_latest_stock_qty +from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_account + +class InpatientMedicationEntry(Document): + def validate(self): + self.validate_medication_orders() + + def get_medication_orders(self): + self.validate_datetime_filters() + + # pull inpatient medication orders based on selected filters + orders = get_pending_medication_orders(self) + + if orders: + self.add_mo_to_table(orders) + return self + else: + self.set('medication_orders', []) + frappe.msgprint(_('No pending medication orders found for selected criteria')) + + def validate_datetime_filters(self): + if self.from_date and self.to_date: + self.validate_from_to_dates('from_date', 'to_date') + + if self.from_date and getdate(self.from_date) > getdate(): + frappe.throw(_('From Date cannot be after the current date.')) + + if self.to_date and getdate(self.to_date) > getdate(): + frappe.throw(_('To Date cannot be after the current date.')) + + if self.from_time and self.from_time > nowtime(): + frappe.throw(_('From Time cannot be after the current time.')) + + if self.to_time and self.to_time > nowtime(): + frappe.throw(_('To Time cannot be after the current time.')) + + def add_mo_to_table(self, orders): + # Add medication orders in the child table + self.set('medication_orders', []) + + for data in orders: + self.append('medication_orders', { + 'patient': data.patient, + 'patient_name': data.patient_name, + 'inpatient_record': data.inpatient_record, + 'service_unit': data.service_unit, + 'datetime': "%s %s" % (data.date, data.time or "00:00:00"), + 'drug_code': data.drug, + 'drug_name': data.drug_name, + 'dosage': data.dosage, + 'dosage_form': data.dosage_form, + 'against_imo': data.parent, + 'against_imoe': data.name + }) + + def on_submit(self): + self.validate_medication_orders() + success_msg = "" + if self.update_stock: + stock_entry = self.process_stock() + success_msg += _('Stock Entry {0} created and ').format( + frappe.bold(get_link_to_form('Stock Entry', stock_entry))) + + self.update_medication_orders() + success_msg += _('Inpatient Medication Orders updated successfully') + frappe.msgprint(success_msg, title=_('Success'), indicator='green') + + def validate_medication_orders(self): + for entry in self.medication_orders: + docstatus, is_completed = frappe.db.get_value('Inpatient Medication Order Entry', entry.against_imoe, + ['docstatus', 'is_completed']) + + if docstatus == 2: + frappe.throw(_('Row {0}: Cannot create Inpatient Medication Entry against cancelled Inpatient Medication Order {1}').format( + entry.idx, get_link_to_form(entry.against_imo))) + + if is_completed: + frappe.throw(_('Row {0}: This Medication Order is already marked as completed').format( + entry.idx)) + + def on_cancel(self): + self.cancel_stock_entries() + self.update_medication_orders(on_cancel=True) + + def process_stock(self): + allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') + if not allow_negative_stock: + self.check_stock_qty() + + return self.make_stock_entry() + + def update_medication_orders(self, on_cancel=False): + orders, order_entry_map = self.get_order_entry_map() + # mark completion status + is_completed = 1 + if on_cancel: + is_completed = 0 + + frappe.db.sql(""" + UPDATE `tabInpatient Medication Order Entry` + SET is_completed = %(is_completed)s + WHERE name IN %(orders)s + """, {'orders': orders, 'is_completed': is_completed}) + + # update status and completed orders count + for order, count in order_entry_map.items(): + medication_order = frappe.get_doc('Inpatient Medication Order', order) + completed_orders = flt(count) + current_value = frappe.db.get_value('Inpatient Medication Order', order, 'completed_orders') + + if on_cancel: + completed_orders = flt(current_value) - flt(count) + else: + completed_orders = flt(current_value) + flt(count) + + medication_order.db_set('completed_orders', completed_orders) + medication_order.set_status() + + def get_order_entry_map(self): + # for marking order completion status + orders = [] + # orders mapped + order_entry_map = dict() + + for entry in self.medication_orders: + orders.append(entry.against_imoe) + parent = entry.against_imo + if not order_entry_map.get(parent): + order_entry_map[parent] = 0 + + order_entry_map[parent] += 1 + + return orders, order_entry_map + + def check_stock_qty(self): + from erpnext.stock.stock_ledger import NegativeStockError + + drug_availability = dict() + for d in self.medication_orders: + if not drug_availability.get(d.drug_code): + drug_availability[d.drug_code] = 0 + drug_availability[d.drug_code] += flt(d.dosage) + + for drug, dosage in drug_availability.items(): + available_qty = get_latest_stock_qty(drug, self.warehouse) + + # validate qty + if flt(available_qty) < flt(dosage): + frappe.throw(_('Quantity not available for {0} in warehouse {1}').format( + frappe.bold(drug), frappe.bold(self.warehouse)) + + '