diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json index cf55d554fb3..5858f10bb0b 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json @@ -30,6 +30,7 @@ "fieldtype": "Link", "label": "Reference Document Type", "options": "DocType", + "read_only_depends_on": "eval:!doc.__islocal", "reqd": 1 }, { @@ -48,7 +49,7 @@ } ], "links": [], - "modified": "2020-03-22 20:34:39.805728", + "modified": "2021-02-08 16:37:53.936656", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting Dimension", diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index ef0d3a3cb1f..dd26c4cec24 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -29,6 +29,16 @@ class AccountingDimension(Document): if exists and self.is_new(): frappe.throw("Document Type already used as a dimension") + if not self.is_new(): + self.validate_document_type_change() + + def validate_document_type_change(self): + doctype_before_save = frappe.db.get_value("Accounting Dimension", self.name, "document_type") + if doctype_before_save != self.document_type: + message = _("Cannot change Reference Document Type.") + message += _("Please create a new Accounting Dimension if required.") + frappe.throw(message) + def after_insert(self): if frappe.flags.in_test: make_dimension_in_accounting_doctypes(doc=self) diff --git a/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.json b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.json index c0327ad0ad8..0f3fbc0b8d3 100644 --- a/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.json +++ b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.json @@ -108,7 +108,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-12-16 15:27:23.659285", + "modified": "2021-02-03 12:04:58.678402", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting Dimension Filter", @@ -125,6 +125,30 @@ "role": "System 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 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 } ], "quick_entry": 1, diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 791b03a0d78..f7a15c04faa 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -88,19 +88,19 @@ class PaymentReconciliation(Document): voucher_type = ('Sales Invoice' if self.party_type == 'Customer' else "Purchase Invoice") - return frappe.db.sql(""" SELECT `tab{doc}`.name as reference_name, %(voucher_type)s as reference_type, - (sum(`tabGL Entry`.{dr_or_cr}) - sum(`tabGL Entry`.{reconciled_dr_or_cr})) as amount, + return frappe.db.sql(""" SELECT doc.name as reference_name, %(voucher_type)s as reference_type, + (sum(gl.{dr_or_cr}) - sum(gl.{reconciled_dr_or_cr})) as amount, account_currency as currency - FROM `tab{doc}`, `tabGL Entry` + FROM `tab{doc}` doc, `tabGL Entry` gl WHERE - (`tab{doc}`.name = `tabGL Entry`.against_voucher or `tab{doc}`.name = `tabGL Entry`.voucher_no) - and `tab{doc}`.{party_type_field} = %(party)s - and `tab{doc}`.is_return = 1 and `tab{doc}`.return_against IS NULL - and `tabGL Entry`.against_voucher_type = %(voucher_type)s - and `tab{doc}`.docstatus = 1 and `tabGL Entry`.party = %(party)s - and `tabGL Entry`.party_type = %(party_type)s and `tabGL Entry`.account = %(account)s - and `tabGL Entry`.is_cancelled = 0 - GROUP BY `tab{doc}`.name + (doc.name = gl.against_voucher or doc.name = gl.voucher_no) + and doc.{party_type_field} = %(party)s + and doc.is_return = 1 and ifnull(doc.return_against, "") = "" + and gl.against_voucher_type = %(voucher_type)s + and doc.docstatus = 1 and gl.party = %(party)s + and gl.party_type = %(party_type)s and gl.account = %(account)s + and gl.is_cancelled = 0 + GROUP BY doc.name Having amount > 0 """.format( @@ -113,7 +113,7 @@ class PaymentReconciliation(Document): 'party_type': self.party_type, 'voucher_type': voucher_type, 'account': self.receivable_payable_account - }, as_dict=1) + }, as_dict=1, debug=1) def add_payment_entries(self, entries): self.set('payments', []) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 1b97050eb13..53ac996290b 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -3,6 +3,7 @@ # For license information, please see license.txt from __future__ import unicode_literals +import json import frappe from frappe import _ from frappe.model.document import Document @@ -82,18 +83,37 @@ class PaymentRequest(Document): 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) + self.request_phone_payment() + + def request_phone_payment(self): + controller = get_payment_gateway_controller(self.payment_gateway) + request_amount = self.get_request_amount() + + payment_record = dict( + reference_doctype="Payment Request", + reference_docname=self.name, + payment_reference=self.reference_name, + request_amount=request_amount, + 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 get_request_amount(self): + data_of_completed_requests = frappe.get_all("Integration Request", filters={ + 'reference_doctype': self.doctype, + 'reference_docname': self.name, + 'status': 'Completed' + }, pluck="data") + + if not data_of_completed_requests: + return self.grand_total + + request_amounts = sum([json.loads(d).get('request_amount') for d in data_of_completed_requests]) + return request_amounts def on_cancel(self): self.check_if_payment_entry_exists() @@ -351,8 +371,8 @@ def make_payment_request(**args): if args.order_type == "Shopping Cart" or args.mute_email: pr.flags.mute_email = True + pr.insert(ignore_permissions=True) if args.submit_doc: - pr.insert(ignore_permissions=True) pr.submit() if args.order_type == "Shopping Cart": @@ -412,8 +432,8 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): def get_gateway_details(args): """return gateway and payment account of default payment gateway""" - if args.get("payment_gateway"): - return get_payment_gateway_account(args.get("payment_gateway")) + if args.get("payment_gateway_account"): + return get_payment_gateway_account(args.get("payment_gateway_account")) if args.order_type == "Shopping Cart": payment_gateway_account = frappe.get_doc("Shopping Cart Settings").payment_gateway_account diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 8a10e2cbd95..5eba62c0b31 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -45,7 +45,8 @@ class TestPaymentRequest(unittest.TestCase): def test_payment_request_linkings(self): so_inr = make_sales_order(currency="INR") - pr = make_payment_request(dt="Sales Order", dn=so_inr.name, recipient_id="saurabh@erpnext.com") + pr = make_payment_request(dt="Sales Order", dn=so_inr.name, recipient_id="saurabh@erpnext.com", + payment_gateway_account="_Test Gateway - INR") self.assertEqual(pr.reference_doctype, "Sales Order") self.assertEqual(pr.reference_name, so_inr.name) @@ -54,7 +55,8 @@ class TestPaymentRequest(unittest.TestCase): conversion_rate = get_exchange_rate("USD", "INR") si_usd = create_sales_invoice(currency="USD", conversion_rate=conversion_rate) - pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com") + pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com", + payment_gateway_account="_Test Gateway - USD") self.assertEqual(pr.reference_doctype, "Sales Invoice") self.assertEqual(pr.reference_name, si_usd.name) @@ -68,7 +70,7 @@ class TestPaymentRequest(unittest.TestCase): so_inr = make_sales_order(currency="INR") pr = make_payment_request(dt="Sales Order", dn=so_inr.name, recipient_id="saurabh@erpnext.com", - mute_email=1, submit_doc=1, return_doc=1) + mute_email=1, payment_gateway_account="_Test Gateway - INR", submit_doc=1, return_doc=1) pe = pr.set_as_paid() so_inr = frappe.get_doc("Sales Order", so_inr.name) @@ -79,7 +81,7 @@ class TestPaymentRequest(unittest.TestCase): currency="USD", conversion_rate=50) pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com", - mute_email=1, payment_gateway="_Test Gateway - USD", submit_doc=1, return_doc=1) + mute_email=1, payment_gateway_account="_Test Gateway - USD", submit_doc=1, return_doc=1) pe = pr.set_as_paid() @@ -106,7 +108,7 @@ class TestPaymentRequest(unittest.TestCase): currency="USD", conversion_rate=50) pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com", - mute_email=1, payment_gateway="_Test Gateway - USD", submit_doc=1, return_doc=1) + mute_email=1, payment_gateway_account="_Test Gateway - USD", submit_doc=1, return_doc=1) pe = pr.create_payment_entry() pr.load_from_db() diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js index 73367fd736d..9ea616f8e77 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js @@ -21,7 +21,7 @@ frappe.ui.form.on('POS Closing Entry', { return { filters: { 'status': 'Open', 'docstatus': 1 } }; }); - if (frm.doc.docstatus === 0) frm.set_value("period_end_date", frappe.datetime.now_datetime()); + if (frm.doc.docstatus === 0 && !frm.doc.amended_from) frm.set_value("period_end_date", frappe.datetime.now_datetime()); if (frm.doc.docstatus === 1) set_html_data(frm); }, diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py index edf3d5a5748..f5224a269e1 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py @@ -20,11 +20,16 @@ class POSClosingEntry(StatusUpdater): self.validate_pos_invoices() def validate_pos_closing(self): - user = frappe.get_all("POS Closing Entry", - filters = { "user": self.user, "docstatus": 1, "pos_profile": self.pos_profile }, - or_filters = { - "period_start_date": ("between", [self.period_start_date, self.period_end_date]), - "period_end_date": ("between", [self.period_start_date, self.period_end_date]) + user = frappe.db.sql(""" + SELECT name FROM `tabPOS Closing Entry` + WHERE + user = %(user)s AND docstatus = 1 AND pos_profile = %(profile)s AND + (period_start_date between %(start)s and %(end)s OR period_end_date between %(start)s and %(end)s) + """, { + 'user': self.user, + 'profile': self.pos_profile, + 'start': self.period_start_date, + 'end': self.period_end_date }) if user: diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 07c8e44e77b..493bd448024 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -195,18 +195,43 @@ frappe.ui.form.on('POS Invoice', { }, request_for_payment: function (frm) { + if (!frm.doc.contact_mobile) { + frappe.throw(__('Please enter mobile number first.')); + } + frm.dirty(); frm.save().then(() => { - frappe.dom.freeze(); - frappe.call({ - method: 'create_payment_request', - doc: frm.doc, - }) + frappe.dom.freeze(__('Waiting for payment...')); + frappe + .call({ + method: 'create_payment_request', + doc: frm.doc + }) .fail(() => { frappe.dom.unfreeze(); - frappe.msgprint('Payment request failed'); + frappe.msgprint(__('Payment request failed')); }) - .then(() => { - frappe.msgprint('Payment request sent successfully'); + .then(({ message }) => { + const payment_request_name = message.name; + setTimeout(() => { + frappe.db.get_value('Payment Request', payment_request_name, ['status', 'grand_total']).then(({ message }) => { + if (message.status != 'Paid') { + frappe.dom.unfreeze(); + frappe.msgprint({ + message: __('Payment Request took too long to respond. Please try requesting for payment again.'), + title: __('Request Timeout') + }); + } else if (frappe.dom.freeze_count != 0) { + frappe.dom.unfreeze(); + cur_frm.reload_doc(); + cur_pos.payment.events.submit_invoice(); + + frappe.show_alert({ + message: __("Payment of {0} received successfully.", [format_currency(message.grand_total, frm.doc.currency, 0)]), + indicator: 'green' + }); + } + }); + }, 60000); }); }); } diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 8d8babbe75c..94573f9df3c 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -317,13 +317,14 @@ class POSInvoice(SalesInvoice): ) customer_group_price_list = frappe.db.get_value("Customer Group", customer_group, 'default_price_list') selling_price_list = customer_price_list or customer_group_price_list or profile.get('selling_price_list') + if customer_currency != profile.get('currency'): + self.set('currency', customer_currency) + else: selling_price_list = profile.get('selling_price_list') if selling_price_list: self.set('selling_price_list', selling_price_list) - if customer_currency != profile.get('currency'): - self.set('currency', customer_currency) # set pos values in items for item in self.get("items"): @@ -383,22 +384,48 @@ class POSInvoice(SalesInvoice): 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 - } + pay_req = self.get_existing_payment_request(pay) + if not pay_req: + pay_req = self.get_new_payment_request(pay) + pay_req.submit() + else: + pay_req.request_phone_payment() - return make_payment_request(**record) + return pay_req + + def get_new_payment_request(self, mop): + payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { + "payment_account": mop.account, + }, ["name"]) + + args = { + "dt": "POS Invoice", + "dn": self.name, + "recipient_id": self.contact_mobile, + "mode_of_payment": mop.mode_of_payment, + "payment_gateway_account": payment_gateway_account, + "payment_request_type": "Inward", + "party_type": "Customer", + "party": self.customer, + "return_doc": True + } + return make_payment_request(**args) + + def get_existing_payment_request(self, pay): + payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { + "payment_account": pay.account, + }, ["name"]) + + args = { + 'doctype': 'Payment Request', + 'reference_doctype': 'POS Invoice', + 'reference_name': self.name, + 'payment_gateway_account': payment_gateway_account, + 'email_to': self.contact_mobile + } + pr = frappe.db.exists(args) + if pr: + return frappe.get_doc('Payment Request', pr[0][0]) @frappe.whitelist() def get_stock_availability(item_code, warehouse): diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 58409cd3c6d..c88d67989be 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -212,8 +212,8 @@ def consolidate_pos_invoices(pos_invoices=[], closing_entry={}): invoice_by_customer = get_invoice_customer_map(invoices) if len(invoices) >= 5 and closing_entry: - enqueue_job(create_merge_logs, invoice_by_customer, closing_entry) closing_entry.set_status(update=True, status='Queued') + enqueue_job(create_merge_logs, invoice_by_customer, closing_entry) else: create_merge_logs(invoice_by_customer, closing_entry) @@ -225,8 +225,8 @@ def unconsolidate_pos_invoices(closing_entry): ) if len(merge_logs) >= 5: - enqueue_job(cancel_merge_logs, merge_logs, closing_entry) closing_entry.set_status(update=True, status='Queued') + enqueue_job(cancel_merge_logs, merge_logs, closing_entry) else: cancel_merge_logs(merge_logs, closing_entry) diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index 998003ac698..928b373effe 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -52,8 +52,8 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum row = { 'item_code': d.item_code, - 'item_name': item_record.item_name, - 'item_group': item_record.item_group, + 'item_name': item_record.item_name if item_record else d.item_name, + 'item_group': item_record.item_group if item_record else d.item_group, 'description': d.description, 'invoice': d.parent, 'posting_date': d.posting_date, @@ -383,6 +383,7 @@ def get_items(filters, additional_query_columns): `tabSales Invoice`.project, `tabSales Invoice`.customer, `tabSales Invoice`.remarks, `tabSales Invoice`.territory, `tabSales Invoice`.company, `tabSales Invoice`.base_net_total, `tabSales Invoice Item`.item_code, `tabSales Invoice Item`.description, + `tabSales Invoice Item`.`item_name`, `tabSales Invoice Item`.`item_group`, `tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.income_account, `tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.stock_qty, `tabSales Invoice Item`.stock_uom, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index db6484a6062..19c97b68d35 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1504,6 +1504,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.flags.ignore_validate_update_after_submit = True parent.set_qty_as_per_stock_uom() parent.calculate_taxes_and_totals() + parent.set_total_in_words() if parent_doctype == "Sales Order": make_packing_list(parent) parent.set_gross_profit() diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 6abfe04db9f..c61b67b0a48 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -446,9 +446,13 @@ class SellingController(StockController): check_list, chk_dupl_itm = [], [] if cint(frappe.db.get_single_value("Selling Settings", "allow_multiple_items")): return + if self.doctype == "Sales Invoice" and self.is_consolidated: + return + if self.doctype == "POS Invoice": + return for d in self.get('items'): - if self.doctype in ["POS Invoice","Sales Invoice"]: + if self.doctype == "Sales Invoice": stock_items = [d.item_code, d.description, d.warehouse, d.sales_order or d.delivery_note, d.batch_no or ''] non_stock_items = [d.item_code, d.description, d.sales_order or d.delivery_note] elif self.doctype == "Delivery Note": diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 4b5e3479706..2ae9dc71022 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -313,7 +313,7 @@ class StockController(AccountsController): return serialized_items def validate_warehouse(self): - from erpnext.stock.utils import validate_warehouse_company + from erpnext.stock.utils import validate_warehouse_company, validate_disabled_warehouse warehouses = list(set([d.warehouse for d in self.get("items") if getattr(d, "warehouse", None)])) @@ -329,6 +329,7 @@ class StockController(AccountsController): warehouses.extend(from_warehouse) for w in warehouses: + validate_disabled_warehouse(w) validate_warehouse_company(w, self.company) def update_billing_percentage(self, update_modified=True): diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 938cbfdc852..d1d096843bf 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -352,7 +352,7 @@ def get_lead_with_phone_number(number): leads = frappe.get_all('Lead', or_filters={ 'phone': ['like', '%{}'.format(number)], 'mobile_no': ['like', '%{}'.format(number)] - }, limit=1) + }, limit=1, order_by="creation DESC") lead = leads[0].name if leads else None @@ -361,4 +361,4 @@ def get_lead_with_phone_number(number): def daily_open_lead(): leads = frappe.get_all("Lead", filters = [["contact_date", "Between", [nowdate(), nowdate()]]]) for lead in leads: - frappe.db.set_value("Lead", lead.name, "status", "Open") \ No newline at end of file + frappe.db.set_value("Lead", lead.name, "status", "Open") diff --git a/erpnext/crm/doctype/utils.py b/erpnext/crm/doctype/utils.py index 4ccd9bd73b6..f244daffea3 100644 --- a/erpnext/crm/doctype/utils.py +++ b/erpnext/crm/doctype/utils.py @@ -78,7 +78,9 @@ def get_scheduled_employees_for_popup(communication_medium): def strip_number(number): if not number: return - # strip 0 from the start of the number for proper number comparisions + # strip + and 0 from the start of the number for proper number comparisions + # eg. +7888383332 should match with 7888383332 # eg. 07888383332 should match with 7888383332 + number = number.lstrip('+') number = number.lstrip('0') return number diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py index d33b0a70894..554c6b0eb0f 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -5,7 +5,7 @@ import datetime class MpesaConnector(): def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke", - live_url="https://safaricom.co.ke"): + live_url="https://api.safaricom.co.ke"): """Setup configuration for Mpesa connector and generate new access token.""" self.env = env self.app_key = app_key @@ -102,14 +102,14 @@ class MpesaConnector(): "BusinessShortCode": business_shortcode, "Password": encoded.decode("utf-8"), "Timestamp": time, - "TransactionType": "CustomerPayBillOnline", "Amount": amount, "PartyA": int(phone_number), - "PartyB": business_shortcode, + "PartyB": reference_code, "PhoneNumber": int(phone_number), "CallBackURL": callback_url, "AccountReference": reference_code, - "TransactionDesc": description + "TransactionDesc": description, + "TransactionType": "CustomerPayBillOnline" if self.env == "sandbox" else "CustomerBuyGoodsOnline" } headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"} diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json index fc7b310c087..407f82616ff 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json @@ -11,8 +11,10 @@ "consumer_secret", "initiator_name", "till_number", + "transaction_limit", "sandbox", "column_break_4", + "business_shortcode", "online_passkey", "security_credential", "get_account_balance", @@ -84,10 +86,24 @@ "fieldname": "get_account_balance", "fieldtype": "Button", "label": "Get Account Balance" + }, + { + "depends_on": "eval:(doc.sandbox==0)", + "fieldname": "business_shortcode", + "fieldtype": "Data", + "label": "Business Shortcode", + "mandatory_depends_on": "eval:(doc.sandbox==0)" + }, + { + "default": "150000", + "fieldname": "transaction_limit", + "fieldtype": "Float", + "label": "Transaction Limit", + "non_negative": 1 } ], "links": [], - "modified": "2020-09-25 20:21:38.215494", + "modified": "2021-01-29 12:02:16.106942", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "Mpesa Settings", diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 1cad84dcde3..b5718026c12 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -33,13 +33,34 @@ class MpesaSettings(Document): 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)) + args = frappe._dict(kwargs) + request_amounts = self.split_request_amount_according_to_transaction_limit(args) - self.handle_api_response("CheckoutRequestID", kwargs, response) + for i, amount in enumerate(request_amounts): + args.request_amount = amount + 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(amount)) + else: + response = frappe._dict(generate_stk_push(**args)) + + self.handle_api_response("CheckoutRequestID", args, response) + + def split_request_amount_according_to_transaction_limit(self, args): + request_amount = args.request_amount + if request_amount > self.transaction_limit: + # make multiple requests + request_amounts = [] + requests_to_be_made = frappe.utils.ceil(request_amount / self.transaction_limit) # 480/150 = ceil(3.2) = 4 + for i in range(requests_to_be_made): + amount = self.transaction_limit + if i == requests_to_be_made - 1: + amount = request_amount - (self.transaction_limit * i) # for 4th request, 480 - (150 * 3) = 30 + request_amounts.append(amount) + else: + request_amounts = [request_amount] + + return request_amounts def get_account_balance_info(self): payload = dict( @@ -67,7 +88,8 @@ class MpesaSettings(Document): req_name = getattr(response, global_id) error = None - create_request_log(request_dict, "Host", "Mpesa", req_name, error) + if not frappe.db.exists('Integration Request', req_name): + create_request_log(request_dict, "Host", "Mpesa", req_name, error) if error: frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) @@ -80,6 +102,8 @@ def generate_stk_push(**kwargs): mpesa_settings = frappe.get_doc("Mpesa Settings", args.payment_gateway[6:]) env = "production" if not mpesa_settings.sandbox else "sandbox" + # for sandbox, business shortcode is same as till number + business_shortcode = mpesa_settings.business_shortcode if env == "production" else mpesa_settings.till_number connector = MpesaConnector(env=env, app_key=mpesa_settings.consumer_key, @@ -87,10 +111,12 @@ def generate_stk_push(**kwargs): 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, + response = connector.stk_push( + business_shortcode=business_shortcode, amount=args.request_amount, + passcode=mpesa_settings.get_password("online_passkey"), callback_url=callback_url, reference_code=mpesa_settings.till_number, - phone_number=mobile_number, description="POS Payment") + phone_number=mobile_number, description="POS Payment" + ) return response @@ -108,29 +134,72 @@ def verify_transaction(**kwargs): 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)) + integration_request = frappe.get_doc("Integration Request", checkout_id) + transaction_data = frappe._dict(loads(integration_request.data)) + total_paid = 0 # for multiple integration request made against a pos invoice + success = False # for reporting successfull callback to point of sale ui if transaction_response['ResultCode'] == 0: - if request.reference_doctype and request.reference_docname: + if integration_request.reference_doctype and integration_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"] + amount = fetch_param_value(item_response, "Amount", "Name") 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) + pr = frappe.get_doc(integration_request.reference_doctype, integration_request.reference_docname) + + mpesa_receipts, completed_payments = get_completed_integration_requests_info( + integration_request.reference_doctype, + integration_request.reference_docname, + checkout_id + ) + + total_paid = amount + sum(completed_payments) + mpesa_receipts = ', '.join(mpesa_receipts + [mpesa_receipt]) + + if total_paid >= pr.grand_total: + pr.run_method("on_payment_authorized", 'Completed') + success = True + + frappe.db.set_value("POS Invoice", pr.reference_name, "mpesa_receipt_number", mpesa_receipts) + integration_request.handle_success(transaction_response) except Exception: - request.handle_failure(transaction_response) + integration_request.handle_failure(transaction_response) frappe.log_error(frappe.get_traceback()) else: - request.handle_failure(transaction_response) + integration_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) + frappe.publish_realtime( + event='process_phone_payment', + doctype="POS Invoice", + docname=transaction_data.payment_reference, + user=integration_request.owner, + message={ + 'amount': total_paid, + 'success': success, + 'failure_message': transaction_response["ResultDesc"] if transaction_response['ResultCode'] != 0 else '' + }, + ) + +def get_completed_integration_requests_info(reference_doctype, reference_docname, checkout_id): + output_of_other_completed_requests = frappe.get_all("Integration Request", filters={ + 'name': ['!=', checkout_id], + 'reference_doctype': reference_doctype, + 'reference_docname': reference_docname, + 'status': 'Completed' + }, pluck="output") + + mpesa_receipts, completed_payments = [], [] + + for out in output_of_other_completed_requests: + out = frappe._dict(loads(out)) + item_response = out["CallbackMetadata"]["Item"] + completed_amount = fetch_param_value(item_response, "Amount", "Name") + completed_mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + completed_payments.append(completed_amount) + mpesa_receipts.append(completed_mpesa_receipt) + + return mpesa_receipts, completed_payments def get_account_balance(request_payload): """Call account balance API to send the request to the Mpesa Servers.""" diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 49f6d95a6e5..29487962f69 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -9,6 +9,10 @@ from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import p from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice class TestMpesaSettings(unittest.TestCase): + def tearDown(self): + frappe.db.sql('delete from `tabMpesa Settings`') + frappe.db.sql('delete from `tabIntegration Request` where integration_request_service = "Mpesa"') + def test_creation_of_payment_gateway(self): create_mpesa_settings(payment_gateway_name="_Test") @@ -40,6 +44,8 @@ class TestMpesaSettings(unittest.TestCase): } })) + integration_request.delete() + 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") @@ -56,10 +62,16 @@ class TestMpesaSettings(unittest.TestCase): # test payment request creation self.assertEquals(pr.payment_gateway, "Mpesa-Payment") - callback_response = get_payment_callback_payload() + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + }, pluck="name") + + callback_response = get_payment_callback_payload(Amount=500, CheckoutRequestID=integration_req_ids[0]) verify_transaction(**callback_response) # test creation of integration request - integration_request = frappe.get_doc("Integration Request", "ws_CO_061020201133231972") + integration_request = frappe.get_doc("Integration Request", integration_req_ids[0]) # test integration request creation and successful update of the status on receiving callback response self.assertTrue(integration_request) @@ -69,8 +81,120 @@ class TestMpesaSettings(unittest.TestCase): integration_request.reload() self.assertEquals(pos_invoice.mpesa_receipt_number, "LGR7OWQX0R") self.assertEquals(integration_request.status, "Completed") + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + integration_request.delete() + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + + def test_processing_of_multiple_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") + frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + pos_invoice = create_pos_invoice(do_not_submit=1) + pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 1000}) + 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") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + }, pluck="name") + + # create random receipt nos and send it as response to callback handler + mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids] + + integration_requests = [] + for i in range(len(integration_req_ids)): + callback_response = get_payment_callback_payload( + Amount=500, + CheckoutRequestID=integration_req_ids[i], + MpesaReceiptNumber=mpesa_receipt_numbers[i] + ) + # handle response manually + verify_transaction(**callback_response) + # test completion of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[i]) + self.assertEquals(integration_request.status, "Completed") + integration_requests.append(integration_request) + + # check receipt number once all the integration requests are completed + pos_invoice.reload() + self.assertEquals(pos_invoice.mpesa_receipt_number, ', '.join(mpesa_receipt_numbers)) frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + [d.delete() for d in integration_requests] + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + + def test_processing_of_only_one_succes_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") + frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + pos_invoice = create_pos_invoice(do_not_submit=1) + pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 1000}) + 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") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + }, pluck="name") + + # create random receipt nos and send it as response to callback handler + mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids] + + callback_response = get_payment_callback_payload( + Amount=500, + CheckoutRequestID=integration_req_ids[0], + MpesaReceiptNumber=mpesa_receipt_numbers[0] + ) + # handle response manually + verify_transaction(**callback_response) + # test completion of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[0]) + self.assertEquals(integration_request.status, "Completed") + + # now one request is completed + # second integration request fails + # now retrying payment request should make only one integration request again + pr = pos_invoice.create_payment_request() + new_integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + 'name': ['not in', integration_req_ids] + }, pluck="name") + + self.assertEquals(len(new_integration_req_ids), 1) + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + frappe.db.sql("delete from `tabIntegration Request` where integration_request_service = 'Mpesa'") + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() def create_mpesa_settings(payment_gateway_name="Express"): if frappe.db.exists("Mpesa Settings", payment_gateway_name): @@ -160,16 +284,19 @@ def get_test_account_balance_response(): } } -def get_payment_request_response_payload(): +def get_payment_request_response_payload(Amount=500): """Response received after successfully calling the stk push process request API.""" + + CheckoutRequestID = frappe.utils.random_string(10) + return { "MerchantRequestID": "8071-27184008-1", - "CheckoutRequestID": "ws_CO_061020201133231972", + "CheckoutRequestID": CheckoutRequestID, "ResultCode": 0, "ResultDesc": "The service request is processed successfully.", "CallbackMetadata": { "Item": [ - { "Name": "Amount", "Value": 500.0 }, + { "Name": "Amount", "Value": Amount }, { "Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R" }, { "Name": "TransactionDate", "Value": 20201006113336 }, { "Name": "PhoneNumber", "Value": 254723575670 } @@ -177,41 +304,26 @@ def get_payment_request_response_payload(): } } - -def get_payment_callback_payload(): +def get_payment_callback_payload(Amount=500, CheckoutRequestID="ws_CO_061020201133231972", MpesaReceiptNumber="LGR7OWQX0R"): """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 + "stkCallback":{ + "MerchantRequestID":"19465-780693-1", + "CheckoutRequestID":CheckoutRequestID, + "ResultCode":0, + "ResultDesc":"The service request is processed successfully.", + "CallbackMetadata":{ + "Item":[ + { "Name":"Amount", "Value":Amount }, + { "Name":"MpesaReceiptNumber", "Value":MpesaReceiptNumber }, + { "Name":"Balance" }, + { "Name":"TransactionDate", "Value":20170727154800 }, + { "Name":"PhoneNumber", "Value":254721566839 } + ] } - ] } } - } } def get_account_balance_callback_payload(): diff --git a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py index 8a918b02751..a8c7720a0a4 100644 --- a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py +++ b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py @@ -139,12 +139,13 @@ def create_inpatient(patient): inpatient_record.phone = patient_obj.phone inpatient_record.inpatient = "Scheduled" inpatient_record.scheduled_date = today() + inpatient_record.company = "_Test Company" return inpatient_record def get_healthcare_service_unit(unit_name=None): if not unit_name: - service_unit = get_random("Healthcare Service Unit", filters={"inpatient_occupancy": 1}) + service_unit = get_random("Healthcare Service Unit", filters={"inpatient_occupancy": 1, "company": "_Test Company"}) else: service_unit = frappe.db.exists("Healthcare Service Unit", {"healthcare_service_unit_name": unit_name}) diff --git a/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py b/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py index 0d3f45f5000..4b461f1a97d 100644 --- a/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py +++ b/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py @@ -119,7 +119,7 @@ def create_records(patient): ip_record.expected_length_of_stay = 0 ip_record.save() ip_record.reload() - service_unit = get_healthcare_service_unit() + service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy') admit_patient(ip_record, service_unit, now_datetime()) ipmo = create_ipmo(patient) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index bc1d7628ffb..109d9216e7d 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -78,7 +78,7 @@ website_generators = ["Item Group", "Item", "BOM", "Sales Partner", "Job Opening", "Student Admission"] website_context = { - "favicon": "/assets/erpnext/images/favicon.png", + "favicon": "/assets/erpnext/images/erpnext-favicon.svg", "splash_image": "/assets/erpnext/images/erpnext-logo.svg" } diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py index f4b214adc3c..c0e614ac088 100644 --- a/erpnext/hr/doctype/employee/test_employee.py +++ b/erpnext/hr/doctype/employee/test_employee.py @@ -16,11 +16,13 @@ class TestEmployee(unittest.TestCase): employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) employee.date_of_birth = "1992" + frappe.utils.nowdate()[4:] employee.company_email = "test@example.com" + employee.company = "_Test Company" employee.save() from erpnext.hr.doctype.employee.employee import get_employees_who_are_born_today, send_birthday_reminders - self.assertTrue(employee.name in [e.name for e in get_employees_who_are_born_today()]) + employees_born_today = get_employees_who_are_born_today() + self.assertTrue(employees_born_today.get("_Test Company")) frappe.db.sql("delete from `tabEmail Queue`") diff --git a/erpnext/hr/doctype/job_offer/job_offer.py b/erpnext/hr/doctype/job_offer/job_offer.py index c397a3f5cad..7e650f76917 100644 --- a/erpnext/hr/doctype/job_offer/job_offer.py +++ b/erpnext/hr/doctype/job_offer/job_offer.py @@ -16,7 +16,7 @@ class JobOffer(Document): def validate(self): self.validate_vacancies() - job_offer = frappe.db.exists("Job Offer",{"job_applicant": self.job_applicant}) + job_offer = frappe.db.exists("Job Offer",{"job_applicant": self.job_applicant, "docstatus": ["!=", 2]}) if job_offer and job_offer != self.name: frappe.throw(_("Job Offer: {0} is already for Job Applicant: {1}").format(frappe.bold(job_offer), frappe.bold(self.job_applicant))) diff --git a/erpnext/hr/doctype/leave_application/leave_application_list.js b/erpnext/hr/doctype/leave_application/leave_application_list.js index cbb4b73227e..a3c03b1bec7 100644 --- a/erpnext/hr/doctype/leave_application/leave_application_list.js +++ b/erpnext/hr/doctype/leave_application/leave_application_list.js @@ -1,5 +1,6 @@ frappe.listview_settings['Leave Application'] = { add_fields: ["leave_type", "employee", "employee_name", "total_leave_days", "from_date", "to_date"], + has_indicator_for_draft: 1, get_indicator: function (doc) { if (doc.status === "Approved") { return [__("Approved"), "green", "status,=,Approved"]; diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index 46082129e24..c5929c6bf99 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -36,6 +36,8 @@ def execute(filters=None): conditions, filters = get_conditions(filters) columns, days = get_columns(filters) att_map = get_attendance_list(conditions, filters) + if not att_map: + return columns, [], None, None if filters.group_by: emp_map, group_by_parameters = get_employee_details(filters.group_by, filters.company) @@ -65,10 +67,14 @@ def execute(filters=None): if filters.group_by: emp_att_map = {} for parameter in group_by_parameters: - data.append([ ""+ parameter + ""]) - record, aaa = add_data(emp_map[parameter], att_map, filters, holiday_map, conditions, default_holiday_list, leave_list=leave_list) - emp_att_map.update(aaa) - data += record + emp_map_set = set([key for key in emp_map[parameter].keys()]) + att_map_set = set([key for key in att_map.keys()]) + if (att_map_set & emp_map_set): + parameter_row = [""+ parameter + ""] + ['' for day in range(filters["total_days_in_month"] + 2)] + data.append(parameter_row) + record, emp_att_data = add_data(emp_map[parameter], att_map, filters, holiday_map, conditions, default_holiday_list, leave_list=leave_list) + emp_att_map.update(emp_att_data) + data += record else: record, emp_att_map = add_data(emp_map, att_map, filters, holiday_map, conditions, default_holiday_list, leave_list=leave_list) data += record @@ -237,6 +243,9 @@ def get_attendance_list(conditions, filters): status from tabAttendance where docstatus = 1 %s order by employee, attendance_date""" % conditions, filters, as_dict=1) + if not attendance_list: + msgprint(_("No attendance record found"), alert=True, indicator="orange") + att_map = {} for d in attendance_list: att_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, "") diff --git a/erpnext/portal/product_configurator/utils.py b/erpnext/portal/product_configurator/utils.py index 4693d44509a..21fd7c28789 100644 --- a/erpnext/portal/product_configurator/utils.py +++ b/erpnext/portal/product_configurator/utils.py @@ -386,7 +386,8 @@ def get_items(filters=None, search=None): r.description = r.web_long_description or r.description r.image = r.website_image or r.image product_info = get_product_info_for_website(r.item_code, skip_quotation_creation=True).get('product_info') - r.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None + if product_info: + r.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None return results diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 9e807f728ec..ea81b3eb644 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -288,7 +288,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None): def make_salary_slip(source_name, target_doc=None): target = frappe.new_doc("Salary Slip") set_missing_values(source_name, target) - target.run_method("get_emp_and_leave_details") + target.run_method("get_emp_and_working_day_details") return target diff --git a/erpnext/public/build.json b/erpnext/public/build.json index 4a40e8e8f94..73262382739 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -2,7 +2,7 @@ "css/erpnext.css": [ "public/less/erpnext.less", "public/less/hub.less", - "public/less/call_popup.less", + "public/scss/call_popup.scss", "public/scss/point-of-sale.scss" ], "css/marketplace.css": [ diff --git a/erpnext/public/images/erp-icon.svg b/erpnext/public/images/erp-icon.svg deleted file mode 100644 index 6bec40cc62e..00000000000 --- a/erpnext/public/images/erp-icon.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - erpnext-logo - Created with Sketch. - - - - - \ No newline at end of file diff --git a/erpnext/public/images/erpnext-12.svg b/erpnext/public/images/erpnext-12.svg deleted file mode 100644 index fcc8e46fdd5..00000000000 --- a/erpnext/public/images/erpnext-12.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - version-12 - Created with Sketch. - - - - - - - - - - - - - - - - - 12 - - - - \ No newline at end of file diff --git a/erpnext/public/images/erpnext-favicon.svg b/erpnext/public/images/erpnext-favicon.svg new file mode 100644 index 00000000000..a3ac3bb2ce2 --- /dev/null +++ b/erpnext/public/images/erpnext-favicon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/erpnext/public/images/erpnext-footer.png b/erpnext/public/images/erpnext-footer.png deleted file mode 100644 index ffff7756b77..00000000000 Binary files a/erpnext/public/images/erpnext-footer.png and /dev/null differ diff --git a/erpnext/public/images/erpnext-logo.png b/erpnext/public/images/erpnext-logo.png deleted file mode 100644 index 115faaa6a8f..00000000000 Binary files a/erpnext/public/images/erpnext-logo.png and /dev/null differ diff --git a/erpnext/public/images/erpnext_logo.svg b/erpnext/public/images/erpnext_logo.svg deleted file mode 100644 index af3a84953b0..00000000000 --- a/erpnext/public/images/erpnext_logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/erpnext/public/images/favicon.png b/erpnext/public/images/favicon.png deleted file mode 100644 index b6948856f88..00000000000 Binary files a/erpnext/public/images/favicon.png and /dev/null differ diff --git a/erpnext/public/images/splash.png b/erpnext/public/images/splash.png deleted file mode 100644 index 8e5d055c660..00000000000 Binary files a/erpnext/public/images/splash.png and /dev/null differ diff --git a/erpnext/public/js/call_popup/call_popup.js b/erpnext/public/js/call_popup/call_popup.js index be1745e54f1..c954f12ac63 100644 --- a/erpnext/public/js/call_popup/call_popup.js +++ b/erpnext/public/js/call_popup/call_popup.js @@ -7,10 +7,103 @@ class CallPopup { } make() { + frappe.utils.play_sound('incoming-call'); this.dialog = new frappe.ui.Dialog({ 'static': true, - 'minimizable': true, - 'fields': [{ + 'minimizable': true + }); + this.dialog.get_close_btn().show(); + this.setup_dialog(); + this.set_call_status(); + frappe.utils.bind_actions_with_object(this.dialog.$body, this); + this.dialog.$wrapper.addClass('call-popup'); + this.dialog.get_close_btn().unbind('click').click(this.close_modal.bind(this)); + this.dialog.show(); + } + + setup_dialog() { + this.setup_call_details(); + this.dialog.$body.empty().append(this.caller_info); + } + + set_indicator(color, blink=false) { + let classes = `indicator ${color} ${blink ? 'blink': ''}`; + this.dialog.header.find('.indicator').attr('class', classes); + } + + set_call_status(call_status) { + let title = ''; + call_status = call_status || this.call_log.status; + if (['Ringing'].includes(call_status) || !call_status) { + title = __('Incoming call from {0}', [this.get_caller_name() || this.caller_number]); + this.set_indicator('blue', true); + } else if (call_status === 'In Progress') { + title = __('Call Connected'); + this.set_indicator('green'); + } else if (['No Answer', 'Missed'].includes(call_status)) { + this.set_indicator('yellow'); + title = __('Call Missed'); + } else if (['Completed', 'Busy', 'Failed'].includes(call_status)) { + this.set_indicator('red'); + title = __('Call Ended'); + } else { + this.set_indicator('blue'); + title = call_status; + } + this.dialog.set_title(title); + } + + update_call_log(call_log, missed) { + this.call_log = call_log; + this.set_call_status(missed ? 'Missed': null); + } + + close_modal() { + this.dialog.hide(); + delete erpnext.call_popup; + } + + call_ended(call_log, missed) { + frappe.utils.play_sound('call-disconnect'); + this.update_call_log(call_log, missed); + setTimeout(() => { + if (!this.dialog.get_value('call_summary')) { + this.close_modal(); + } + }, 60000); + this.clear_listeners(); + } + + get_caller_name() { + const contact_link = this.get_contact_link(); + return contact_link.link_title || contact_link.link_name; + } + + get_contact_link() { + let log = this.call_log; + let contact_link = log.links.find(d => d.link_doctype === 'Contact'); + return contact_link || {}; + } + + setup_listener() { + frappe.realtime.on(`call_${this.call_log.id}_ended`, call_log => { + this.call_ended(call_log); + }); + + frappe.realtime.on(`call_${this.call_log.id}_missed`, call_log => { + this.call_ended(call_log, true); + }); + } + + clear_listeners() { + frappe.realtime.off(`call_${this.call_log.id}_ended`); + frappe.realtime.off(`call_${this.call_log.id}_missed`); + } + + setup_call_details() { + this.caller_info = $(`
`); + this.call_details = new frappe.ui.FieldGroup({ + fields: [{ 'fieldname': 'name', 'label': 'Name', 'default': this.get_caller_name() || __('Unknown Caller'), @@ -19,17 +112,17 @@ class CallPopup { }, { 'fieldtype': 'Button', 'label': __('Open Contact'), - 'click': () => frappe.set_route('Form', 'Contact', this.call_log.contact), - 'depends_on': () => this.call_log.contact - }, { - 'fieldtype': 'Button', - 'label': __('Open Lead'), - 'click': () => frappe.set_route('Form', 'Lead', this.call_log.lead), - 'depends_on': () => this.call_log.lead + 'click': () => frappe.set_route('Form', 'Contact', this.get_contact_link().link_name), + 'depends_on': () => this.get_caller_name() }, { 'fieldtype': 'Button', 'label': __('Create New Contact'), - 'click': () => frappe.new_doc('Contact', { 'mobile_no': this.caller_number }), + 'click': this.create_new_contact.bind(this), + 'depends_on': () => !this.get_caller_name() + }, { + 'fieldtype': 'Button', + 'label': __('Create New Customer'), + 'click': this.create_new_customer.bind(this), 'depends_on': () => !this.get_caller_name() }, { 'fieldtype': 'Button', @@ -44,26 +137,9 @@ class CallPopup { 'fieldtype': 'Data', 'default': this.caller_number, 'read_only': 1 - }, { - 'fielname': 'last_interaction', - 'fieldtype': 'Section Break', - 'label': __('Activity'), - 'depends_on': () => this.get_caller_name() - }, { - 'fieldtype': 'Small Text', - 'label': __('Last Issue'), - 'fieldname': 'last_issue', - 'read_only': true, - 'depends_on': () => this.call_log.contact, - 'default': `${__('No issue has been raised by the caller.')}` - }, { - 'fieldtype': 'Small Text', - 'label': __('Last Communication'), - 'fieldname': 'last_communication', - 'read_only': true, - 'default': `${__('No communication found.')}` }, { 'fieldtype': 'Section Break', + 'hide_border': 1, }, { 'fieldtype': 'Small Text', 'label': __('Call Summary'), @@ -72,7 +148,7 @@ class CallPopup { 'fieldtype': 'Button', 'label': __('Save'), 'click': () => { - const call_summary = this.dialog.get_value('call_summary'); + const call_summary = this.call_details.get_value('call_summary'); if (!call_summary) return; frappe.xcall('erpnext.telephony.doctype.call_log.call_log.add_call_summary', { 'call_log': this.call_log.name, @@ -94,108 +170,42 @@ class CallPopup { }); } }], + body: this.caller_info }); - this.set_call_status(); - this.dialog.get_close_btn().show(); - this.make_last_interaction_section(); - this.dialog.$body.addClass('call-popup'); - this.dialog.set_secondary_action(this.close_modal.bind(this)); - frappe.utils.play_sound('incoming-call'); - this.dialog.show(); + this.call_details.make(); } - set_indicator(color, blink=false) { - let classes = `indicator ${color} ${blink ? 'blink': ''}`; - this.dialog.header.find('.indicator').attr('class', classes); + is_known_caller() { + return Boolean(this.get_caller_name()); } - set_call_status(call_status) { - let title = ''; - call_status = call_status || this.call_log.status; - if (['Ringing'].includes(call_status) || !call_status) { - title = __('Incoming call from {0}', [this.get_caller_name() || this.caller_number]); - this.set_indicator('blue', true); - } else if (call_status === 'In Progress') { - title = __('Call Connected'); - this.set_indicator('yellow'); - } else if (call_status === 'Missed') { - this.set_indicator('red'); - title = __('Call Missed'); - } else if (['Completed', 'Disconnected'].includes(call_status)) { - this.set_indicator('red'); - title = __('Call Disconnected'); - } else { - this.set_indicator('blue'); - title = call_status; - } - this.dialog.set_title(title); + create_new_customer() { + // to avoid quick entry form + const new_customer = frappe.model.get_new_doc('Customer'); + new_customer.mobile_no = this.caller_number; + frappe.set_route('Form', new_customer.doctype, new_customer.name); } - update_call_log(call_log) { - this.call_log = call_log; - this.set_call_status(); - } - - close_modal() { - this.dialog.hide(); - delete erpnext.call_popup; - } - - call_disconnected(call_log) { - frappe.utils.play_sound('call-disconnect'); - this.update_call_log(call_log); - setTimeout(() => { - if (!this.dialog.get_value('call_summary')) { - this.close_modal(); - } - }, 30000); - } - - make_last_interaction_section() { - frappe.xcall('erpnext.crm.doctype.utils.get_last_interaction', { - 'contact': this.call_log.contact, - 'lead': this.call_log.lead - }).then(data => { - const comm_field = this.dialog.get_field('last_communication'); - if (data.last_communication) { - const comm = data.last_communication; - comm_field.set_value(comm.content); - } - - if (data.last_issue) { - const issue = data.last_issue; - const issue_field = this.dialog.get_field("last_issue"); - issue_field.set_value(issue.subject); - issue_field.$wrapper.append(` - - ${__('View all issues from {0}', [issue.customer])} - - `); - } - }); - } - - get_caller_name() { - let log = this.call_log; - return log.contact_name || log.lead_name; - } - - setup_listener() { - frappe.realtime.on(`call_${this.call_log.id}_disconnected`, call_log => { - this.call_disconnected(call_log); - // Remove call disconnect listener after the call is disconnected - frappe.realtime.off(`call_${this.call_log.id}_disconnected`); - }); + create_new_contact() { + // TODO: fix new_doc, it should accept child table values + const new_contact = frappe.model.get_new_doc('Contact'); + const phone_no = frappe.model.add_child(new_contact, 'Contact Phone', 'phone_nos'); + phone_no.phone = this.caller_number; + phone_no.is_primary_mobile_no = 1; + frappe.set_route('Form', new_contact.doctype, new_contact.name); } } $(document).on('app_ready', function () { frappe.realtime.on('show_call_popup', call_log => { - if (!erpnext.call_popup) { - erpnext.call_popup = new CallPopup(call_log); + let call_popup = erpnext.call_popup; + if (call_popup && call_log.name === call_popup.call_log.name) { + call_popup.update_call_log(call_log); + call_popup.dialog.show(); } else { - erpnext.call_popup.update_call_log(call_log); - erpnext.call_popup.dialog.show(); + erpnext.call_popup = new CallPopup(call_log); } }); }); + +window.CallPopup = CallPopup; diff --git a/erpnext/public/js/templates/call_link.html b/erpnext/public/js/templates/call_link.html index 08bdf142a70..071078c776e 100644 --- a/erpnext/public/js/templates/call_link.html +++ b/erpnext/public/js/templates/call_link.html @@ -1,32 +1,31 @@
-
-
+
+
+ {{ type }} Call + {% if (duration) %} + • {{ frappe.format(duration, { fieldtype: "Duration" }) }} + {% endif %} + • {{ comment_when(creation) }} +
- - - {{ type }} Call - - - {{ frappe.format(duration, { fieldtype: "Duration" }) }} - - - {{ comment_when(creation) }} - - - - Details - {% if (show_call_button) { %} - Callback - {% } %} + + + + + +
-
+ + +
{% if (type === "Incoming") { %} Incoming call from {{ from }}, received by {{ to }} {% } else { %} Outgoing Call made by {{ from }} to {{ to }} {% } %} -
-
+
{% if (summary) { %} - {{ summary }} + {{ summary }} {% } else { %} {{ __("No Summary") }} {% } %} diff --git a/erpnext/public/less/call_popup.less b/erpnext/public/less/call_popup.less deleted file mode 100644 index 32e85ce16d9..00000000000 --- a/erpnext/public/less/call_popup.less +++ /dev/null @@ -1,9 +0,0 @@ -.call-popup { - a:hover { - text-decoration: underline; - } - .for-description { - max-height: 250px; - overflow: scroll; - } -} \ No newline at end of file diff --git a/erpnext/public/scss/call_popup.scss b/erpnext/public/scss/call_popup.scss new file mode 100644 index 00000000000..95e31828c18 --- /dev/null +++ b/erpnext/public/scss/call_popup.scss @@ -0,0 +1,21 @@ +.call-popup { + a:hover { + text-decoration: underline; + } + .for-description { + max-height: 250px; + overflow: scroll; + } +} + +audio { + height: 40px; + width: 100%; + max-width: 500px; + background-color: var(--control-bg); + border-radius: var(--border-radius-sm); + &-webkit-media-controls-panel { + background: var(--control-bg); + } + outline: none; +} diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index c8e7e149c43..5f701f2aa69 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -20,11 +20,13 @@ from frappe.utils.data import cstr, cint, format_date, flt, time_diff_in_seconds def validate_einvoice_fields(doc): einvoicing_enabled = cint(frappe.db.get_value('E Invoice Settings', 'E Invoice Settings', 'enable')) - invalid_doctype = doc.doctype not in ['Sales Invoice'] + invalid_doctype = doc.doctype != 'Sales Invoice' invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'] company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin') + no_taxes_applied = len(doc.get('taxes', [])) == 0 - if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction: return + if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction or no_taxes_applied: + return if doc.docstatus == 0 and doc._action == 'save': if doc.irn: @@ -303,7 +305,7 @@ def validate_mandatory_fields(invoice): _('GSTIN is mandatory to fetch company GSTIN details. Please enter GSTIN in selected company address.'), title=_('Missing Fields') ) - if not frappe.db.get_value('Address', invoice.customer_address, 'gstin'): + if invoice.gst_category != 'Overseas' and not frappe.db.get_value('Address', invoice.customer_address, 'gstin'): frappe.throw( _('GSTIN is mandatory to fetch customer GSTIN details. Please enter GSTIN in selected customer address.'), title=_('Missing Fields') @@ -444,6 +446,8 @@ class GSPConnector(): def get_credentials(self): if self.invoice: gstin = self.get_seller_gstin() + if not self.e_invoice_settings.enable: + frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings"))) credentials = next(d for d in self.e_invoice_settings.credentials if d.gstin == gstin) else: credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None @@ -817,4 +821,4 @@ def generate_eway_bill(doctype, docname, **kwargs): @frappe.whitelist() def cancel_eway_bill(doctype, docname, eway_bill, reason, remark): gsp_connector = GSPConnector(doctype, docname) - gsp_connector.cancel_eway_bill(eway_bill, reason, remark) \ No newline at end of file + gsp_connector.cancel_eway_bill(eway_bill, reason, remark) diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index c1c8aedc9f3..4e57ff7ea37 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -32,6 +32,10 @@ def execute(filters=None): data = [] columns = get_columns() + conditions = "" + if filters.supplier_group: + conditions += "AND s.supplier_group = %s" %frappe.db.escape(filters.get("supplier_group")) + data = frappe.db.sql(""" SELECT s.supplier_group as "supplier_group", @@ -46,15 +50,17 @@ def execute(filters=None): AND s.irs_1099 = 1 AND gl.fiscal_year = %(fiscal_year)s AND gl.party_type = "Supplier" + AND gl.company = %(company)s + {conditions} + GROUP BY gl.party + ORDER BY - gl.party DESC - """, { - "fiscal_year": filters.fiscal_year, - "supplier_group": filters.supplier_group, - "company": filters.company - }, as_dict=True) + gl.party DESC""".format(conditions=conditions), { + "fiscal_year": filters.fiscal_year, + "company": filters.company + }, as_dict=True) return columns, data @@ -79,13 +85,13 @@ def get_columns(): "fieldname": "tax_id", "label": _("Tax ID"), "fieldtype": "Data", - "width": 120 + "width": 200 }, { "fieldname": "payments", "label": _("Total Payments"), "fieldtype": "Currency", - "width": 120 + "width": 200 } ] diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index bf8b7fc128a..c4525946088 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -126,7 +126,9 @@ class Customer(TransactionBase): '''If Customer created from Lead, update lead status to "Converted" update Customer link in Quotation, Opportunity''' if self.lead_name: - frappe.db.set_value('Lead', self.lead_name, 'status', 'Converted', update_modified=False) + lead = frappe.get_doc('Lead', self.lead_name) + lead.status = 'Converted' + lead.save() def create_lead_address_contact(self): if self.lead_name: diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index e259367e58b..cbfab8204f0 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -325,6 +325,9 @@ class TestSalesOrder(unittest.TestCase): create_dn_against_so(so.name, 4) make_sales_invoice(so.name) + prev_total = so.get("base_total") + prev_total_in_words = so.get("base_in_words") + first_item_of_so = so.get("items")[0] trans_item = json.dumps([ {'item_code' : first_item_of_so.item_code, 'rate' : first_item_of_so.rate, \ @@ -340,6 +343,12 @@ class TestSalesOrder(unittest.TestCase): self.assertEqual(so.get("items")[-1].amount, 1400) self.assertEqual(so.status, 'To Deliver and Bill') + updated_total = so.get("base_total") + updated_total_in_words = so.get("base_in_words") + + self.assertEqual(updated_total, prev_total+1400) + self.assertNotEqual(updated_total_in_words, prev_total_in_words) + def test_update_child_removing_item(self): so = make_sales_order(**{ "item_list": [{ diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index f753b6d5c5e..044e80357dc 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -477,7 +477,7 @@ erpnext.PointOfSale.ItemCart = class { const taxes = frm.doc.taxes.map(t => { return { description: t.description, rate: t.rate - } + }; }); this.render_taxes(frm.doc.total_taxes_and_charges, taxes); } diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index c9b8ad9afe1..bcbac3b4be8 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -153,32 +153,23 @@ erpnext.PointOfSale.Payment = class { me.$payment_modes.find(`.${mode}-amount`).css('display', 'none'); me.$payment_modes.find(`.${mode}-name`).css('display', 'inline'); - const doc = me.events.get_frm().doc; me.selected_mode = me[`${mode}_control`]; me.selected_mode && me.selected_mode.$input.get(0).focus(); - const current_value = me.selected_mode ? me.selected_mode.get_value() : undefined; - !current_value && doc.grand_total > doc.paid_amount && me.selected_mode ? - me.selected_mode.set_value(doc.grand_total - doc.paid_amount) : ''; + me.auto_set_remaining_amount(); } }); - frappe.realtime.on("process_phone_payment", function(data) { - frappe.dom.unfreeze(); - cur_frm.reload_doc(); - let message = data["ResultDesc"]; - let title = __("Payment Failed"); + frappe.ui.form.on('POS Invoice', 'contact_mobile', (frm) => { + const contact = frm.doc.contact_mobile; + const request_button = $(this.request_for_payment_field.$input[0]); + if (contact) { + request_button.removeClass('btn-default').addClass('btn-primary'); + } else { + request_button.removeClass('btn-primary').addClass('btn-default'); + } + }); - if (data["ResultCode"] == 0) { - title = __("Payment Received"); - $('.btn.btn-xs.btn-default[data-fieldname=request_for_payment]').html(`Payment Received`); - me.events.submit_invoice(); - } - - frappe.msgprint({ - "message": message, - "title": title - }); - }); + this.setup_listener_for_payments(); this.$payment_modes.on('click', '.shortcut', () => { const value = $(this).attr('data-value'); @@ -224,6 +215,41 @@ erpnext.PointOfSale.Payment = class { }); } + setup_listener_for_payments() { + frappe.realtime.on("process_phone_payment", (data) => { + const doc = this.events.get_frm().doc; + const { response, amount, success, failure_message } = data; + let message, title; + + if (success) { + title = __("Payment Received"); + if (amount >= doc.grand_total) { + frappe.dom.unfreeze(); + message = __("Payment of {0} received successfully.", [format_currency(amount, doc.currency, 0)]); + this.events.submit_invoice(); + cur_frm.reload_doc(); + + } else { + message = __("Payment of {0} received successfully. Waiting for other requests to complete...", [format_currency(amount, doc.currency, 0)]); + } + } else if (failure_message) { + message = failure_message; + title = __("Payment Failed"); + } + + frappe.msgprint({ "message": message, "title": title }); + }); + } + + auto_set_remaining_amount() { + const doc = this.events.get_frm().doc; + const remaining_amount = doc.grand_total - doc.paid_amount; + const current_value = this.selected_mode ? this.selected_mode.get_value() : undefined; + if (!current_value && remaining_amount > 0 && this.selected_mode) { + this.selected_mode.set_value(remaining_amount); + } + } + attach_shortcuts() { const ctrl_label = frappe.utils.is_mac() ? '⌘' : 'Ctrl'; this.$component.find('.submit-order-btn').attr("title", `${ctrl_label}+Enter`); @@ -333,9 +359,11 @@ erpnext.PointOfSale.Payment = class { fieldtype: 'Currency', placeholder: __('Enter {0} amount.', [p.mode_of_payment]), onchange: function() { - if (this.value || this.value == 0) { - frappe.model.set_value(p.doctype, p.name, 'amount', flt(this.value)) - .then(() => me.update_totals_section()); + const current_value = frappe.model.get_value(p.doctype, p.name, 'amount'); + if (current_value != this.value) { + frappe.model + .set_value(p.doctype, p.name, 'amount', flt(this.value)) + .then(() => me.update_totals_section()) const formatted_currency = format_currency(this.value, currency); me.$payment_modes.find(`.${mode}-amount`).html(formatted_currency); diff --git a/erpnext/setup/doctype/customer_group/customer_group.json b/erpnext/setup/doctype/customer_group/customer_group.json index 10f9bd00300..0e2ed9efcf8 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.json +++ b/erpnext/setup/doctype/customer_group/customer_group.json @@ -139,7 +139,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-03-18 18:10:13.048492", + "modified": "2021-02-08 17:01:52.162202", "modified_by": "Administrator", "module": "Setup", "name": "Customer Group", @@ -189,6 +189,15 @@ "permlevel": 1, "read": 1, "role": "Sales Manager" + }, + { + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "Customer", + "select": 1, + "share": 1 } ], "search_fields": "parent_customer_group", diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json index 31624edb497..e8352144872 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -214,7 +214,7 @@ "is_tree": 1, "links": [], "max_attachments": 3, - "modified": "2020-12-30 12:57:38.876956", + "modified": "2021-02-08 17:02:44.951572", "modified_by": "Administrator", "module": "Setup", "name": "Item Group", @@ -271,6 +271,15 @@ "read": 1, "report": 1, "role": "Accounts User" + }, + { + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "Customer", + "select": 1, + "share": 1 } ], "search_fields": "parent_item_group", diff --git a/erpnext/setup/doctype/territory/territory.json b/erpnext/setup/doctype/territory/territory.json index aa8e0486f59..a25bda054b9 100644 --- a/erpnext/setup/doctype/territory/territory.json +++ b/erpnext/setup/doctype/territory/territory.json @@ -123,7 +123,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-03-18 18:11:36.623555", + "modified": "2021-02-08 17:10:03.767426", "modified_by": "Administrator", "module": "Setup", "name": "Territory", @@ -166,6 +166,15 @@ { "read": 1, "role": "Maintenance User" + }, + { + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "Customer", + "select": 1, + "share": 1 } ], "search_fields": "parent_territory,territory_manager", diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py index da9e798327d..36d446ed0fd 100644 --- a/erpnext/shopping_cart/product_query.py +++ b/erpnext/shopping_cart/product_query.py @@ -23,8 +23,10 @@ class ProductQuery: self.cart_settings = frappe.get_doc("Shopping Cart Settings") self.page_length = self.settings.products_per_page or 20 self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', 'item_group', 'image', 'web_long_description', 'description', 'route'] - self.filters = [['show_in_website', '=', 1]] - self.or_filters = [] + self.filters = [] + self.or_filters = [['show_in_website', '=', 1]] + if not self.settings.get('hide_variants'): + self.or_filters.append(['show_variant_in_website', '=', 1]) def query(self, attributes=None, fields=None, search_term=None, start=0): """Summary @@ -73,7 +75,8 @@ class ProductQuery: for item in result: product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') - item.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None + if product_info: + item.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None return result diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 9dbb64c343e..95cb92b1b36 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -132,7 +132,7 @@ erpnext.stock.ItemDashboard = Class.extend({ var message = __("No Stock Available Currently"); this.content.find('.result').css('text-align', 'center'); - $(`
+ $(`
${message}
`).appendTo(this.result); } }, diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index a5c303ccb4d..36d09efd1ad 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -27,10 +27,11 @@ class StockLedgerEntry(Document): def validate(self): self.flags.ignore_submit_comment = True - from erpnext.stock.utils import validate_warehouse_company + from erpnext.stock.utils import validate_warehouse_company, validate_disabled_warehouse self.validate_mandatory() self.validate_item() self.validate_batch() + validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) self.scrub_posting_time() self.validate_and_set_fiscal_year() diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 46919c8c8c3..95f8c438b34 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -202,8 +202,7 @@ class update_entries_after(object): where item_code = %(item_code)s and warehouse = %(warehouse)s - and voucher_type = %(voucher_type)s - and voucher_no = %(voucher_no)s + and timestamp(posting_date, time_format(posting_time, '%H:%i:%s')) = timestamp(%(posting_date)s, time_format(%(posting_time)s, '%H:%i:%s')) order by creation ASC for update @@ -794,4 +793,4 @@ def get_future_sle_with_negative_qty(args): and qty_after_transaction + {0} < 0 order by timestamp(posting_date, posting_time) asc limit 1 - """.format(args.actual_qty), args, as_dict=1) \ No newline at end of file + """.format(args.actual_qty), args, as_dict=1) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 4ea7e4fcd6e..0af3d908229 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe, erpnext from frappe import _ import json -from frappe.utils import flt, cstr, nowdate, nowtime +from frappe.utils import flt, cstr, nowdate, nowtime, get_link_to_form from six import string_types @@ -284,6 +284,10 @@ def is_group_warehouse(warehouse): if frappe.db.get_value("Warehouse", warehouse, "is_group"): frappe.throw(_("Group node warehouse is not allowed to select for transactions")) +def validate_disabled_warehouse(warehouse): + if frappe.db.get_value("Warehouse", warehouse, "disabled"): + frappe.throw(_("Disabled Warehouse {0} cannot be used for this transaction.").format(get_link_to_form('Warehouse', warehouse))) + def update_included_uom_in_report(columns, result, include_uom, conversion_factors): if not include_uom or not conversion_factors: return diff --git a/erpnext/telephony/doctype/call_log/call_log.js b/erpnext/telephony/doctype/call_log/call_log.js index 977f86da0dd..e7afa0b7d09 100644 --- a/erpnext/telephony/doctype/call_log/call_log.js +++ b/erpnext/telephony/doctype/call_log/call_log.js @@ -2,7 +2,26 @@ // For license information, please see license.txt frappe.ui.form.on('Call Log', { - // refresh: function(frm) { - - // } + refresh: function(frm) { + frm.events.setup_recording_audio_control(frm); + const incoming_call = frm.doc.type == 'Incoming'; + frm.add_custom_button(incoming_call ? __('Callback'): __('Call Again'), () => { + const number = incoming_call ? frm.doc.from : frm.doc.to; + frappe.phone_call.handler(number, frm); + }); + }, + setup_recording_audio_control(frm) { + const recording_wrapper = frm.get_field('recording_html').$wrapper; + if (!frm.doc.recording_url || frm.doc.recording_url == 'null') { + recording_wrapper.empty(); + } else { + recording_wrapper.addClass('input-max-width'); + recording_wrapper.html(` + + `); + } + } }); diff --git a/erpnext/telephony/doctype/call_log/call_log.json b/erpnext/telephony/doctype/call_log/call_log.json index 1ecd884bbd4..1d6c39edf6e 100644 --- a/erpnext/telephony/doctype/call_log/call_log.json +++ b/erpnext/telephony/doctype/call_log/call_log.json @@ -5,6 +5,7 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "call_details_section", "id", "from", "to", @@ -21,20 +22,9 @@ "section_break_11", "summary", "section_break_19", - "links", - "column_break_3", - "section_break_5" + "links" ], "fields": [ - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, - { - "fieldname": "section_break_5", - "fieldtype": "Section Break", - "label": "Call Details" - }, { "fieldname": "id", "fieldtype": "Data", @@ -75,6 +65,7 @@ { "fieldname": "recording_url", "fieldtype": "Data", + "hidden": 1, "label": "Recording URL" }, { @@ -112,13 +103,13 @@ }, { "fieldname": "summary", - "fieldtype": "Small Text", - "label": "Call Summary" + "fieldtype": "Small Text" }, { "fieldname": "section_break_11", "fieldtype": "Section Break", - "hide_border": 1 + "hide_border": 1, + "label": "Call Summary" }, { "fieldname": "start_time", @@ -138,12 +129,17 @@ "label": "Customer", "options": "Customer", "read_only": 1 + }, + { + "fieldname": "call_details_section", + "fieldtype": "Section Break", + "label": "Call Details" } ], "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-01-13 12:28:20.288985", + "modified": "2021-02-08 14:23:28.744844", "modified_by": "Administrator", "module": "Telephony", "name": "Call Log", diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py index a277a5f9568..4d553df08b8 100644 --- a/erpnext/telephony/doctype/call_log/call_log.py +++ b/erpnext/telephony/doctype/call_log/call_log.py @@ -165,6 +165,8 @@ def get_linked_call_logs(doctype, docname): for log in logs: log.show_call_button = 0 timeline_contents.append({ + 'icon': 'call', + 'is_card': True, 'creation': log.creation, 'template': 'call_link', 'template_data': log diff --git a/erpnext/templates/pages/cart.html b/erpnext/templates/pages/cart.html index 2cabf5a9e0e..ea343713a13 100644 --- a/erpnext/templates/pages/cart.html +++ b/erpnext/templates/pages/cart.html @@ -62,6 +62,9 @@
{% if doc.items %}
+ + {{ _("Continue Shopping") }} + {% if cart_settings.enable_checkout %}