diff --git a/erpnext/__init__.py b/erpnext/__init__.py index a754e1323bd..2a474bfe7d7 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -5,7 +5,7 @@ import frappe from erpnext.hooks import regional_overrides from frappe.utils import getdate -__version__ = '13.0.0-beta.11' +__version__ = '13.0.0-beta.12' def get_default_company(user=None): '''Get default company for user''' diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 52e9ff8b764..239588f8979 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -33,11 +33,11 @@ class AccountingDimension(Document): if frappe.flags.in_test: make_dimension_in_accounting_doctypes(doc=self) else: - frappe.enqueue(make_dimension_in_accounting_doctypes, doc=self) + frappe.enqueue(make_dimension_in_accounting_doctypes, doc=self, queue='long') def on_trash(self): if frappe.flags.in_test: - delete_accounting_dimension(doc=self) + delete_accounting_dimension(doc=self, queue='long') else: frappe.enqueue(delete_accounting_dimension, doc=self) @@ -48,6 +48,9 @@ class AccountingDimension(Document): if not self.fieldname: self.fieldname = scrub(self.label) + def on_update(self): + frappe.flags.accounting_dimensions = None + def make_dimension_in_accounting_doctypes(doc): doclist = get_doctypes_with_dimensions() doc_count = len(get_accounting_dimensions()) @@ -165,9 +168,9 @@ def toggle_disabling(doc): frappe.clear_cache(doctype=doctype) def get_doctypes_with_dimensions(): - doclist = ["GL Entry", "Sales Invoice", "Purchase Invoice", "Payment Entry", "Asset", + doclist = ["GL Entry", "Sales Invoice", "POS Invoice", "Purchase Invoice", "Payment Entry", "Asset", "Expense Claim", "Expense Claim Detail", "Expense Taxes and Charges", "Stock Entry", "Budget", "Payroll Entry", "Delivery Note", - "Sales Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Journal Entry Account", "Material Request Item", "Delivery Note Item", + "Sales Invoice Item", "POS Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Journal Entry Account", "Material Request Item", "Delivery Note Item", "Purchase Receipt Item", "Stock Entry Detail", "Payment Entry Deduction", "Sales Taxes and Charges", "Purchase Taxes and Charges", "Shipping Rule", "Landed Cost Item", "Asset Value Adjustment", "Loyalty Program", "Fee Schedule", "Fee Structure", "Stock Reconciliation", "Travel Request", "Fees", "POS Profile", "Opening Invoice Creation Tool", "Opening Invoice Creation Tool Item", "Subscription", @@ -176,12 +179,14 @@ def get_doctypes_with_dimensions(): return doclist def get_accounting_dimensions(as_list=True): - accounting_dimensions = frappe.get_all("Accounting Dimension", fields=["label", "fieldname", "disabled", "document_type"]) + if frappe.flags.accounting_dimensions is None: + frappe.flags.accounting_dimensions = frappe.get_all("Accounting Dimension", + fields=["label", "fieldname", "disabled", "document_type"]) if as_list: - return [d.fieldname for d in accounting_dimensions] + return [d.fieldname for d in frappe.flags.accounting_dimensions] else: - return accounting_dimensions + return frappe.flags.accounting_dimensions def get_checks_for_pl_and_bs_accounts(): dimensions = frappe.db.sql("""SELECT p.label, p.disabled, p.fieldname, c.default_dimension, c.company, c.mandatory_for_pl, c.mandatory_for_bs 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/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index b0a864f76cd..ce76d0a39cc 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -27,30 +27,30 @@ class GLEntry(Document): def validate(self): self.flags.ignore_submit_comment = True - self.check_mandatory() self.validate_and_set_fiscal_year() self.pl_must_have_cost_center() - self.validate_cost_center() if not self.flags.from_repost: + self.check_mandatory() + self.validate_cost_center() self.check_pl_account() self.validate_party() self.validate_currency() - def on_update_with_args(self, adv_adj, update_outstanding = 'Yes', from_repost=False): - if not from_repost: + def on_update(self): + adv_adj = self.flags.adv_adj + if not self.flags.from_repost: self.validate_account_details(adv_adj) self.validate_dimensions_for_pl_and_bs() self.validate_allowed_dimensions() + validate_balance_type(self.account, adv_adj) + validate_frozen_account(self.account, adv_adj) - validate_frozen_account(self.account, adv_adj) - validate_balance_type(self.account, adv_adj) - - # Update outstanding amt on against voucher - if self.against_voucher_type in ['Journal Entry', 'Sales Invoice', 'Purchase Invoice', 'Fees'] \ - and self.against_voucher and update_outstanding == 'Yes' and not from_repost: - update_outstanding_amt(self.account, self.party_type, self.party, self.against_voucher_type, - self.against_voucher) + # Update outstanding amt on against voucher + if (self.against_voucher_type in ['Journal Entry', 'Sales Invoice', 'Purchase Invoice', 'Fees'] + and self.against_voucher and self.flags.update_outstanding == 'Yes'): + update_outstanding_amt(self.account, self.party_type, self.party, self.against_voucher_type, + self.against_voucher) def check_mandatory(self): mandatory = ['account','voucher_type','voucher_no','company'] @@ -58,7 +58,7 @@ class GLEntry(Document): if not self.get(k): frappe.throw(_("{0} is required").format(_(self.meta.get_label(k)))) - account_type = frappe.db.get_value("Account", self.account, "account_type") + account_type = frappe.get_cached_value("Account", self.account, "account_type") if not (self.party_type and self.party): if account_type == "Receivable": frappe.throw(_("{0} {1}: Customer is required against Receivable account {2}") @@ -73,7 +73,7 @@ class GLEntry(Document): .format(self.voucher_type, self.voucher_no, self.account)) def pl_must_have_cost_center(self): - if frappe.db.get_value("Account", self.account, "report_type") == "Profit and Loss": + if frappe.get_cached_value("Account", self.account, "report_type") == "Profit and Loss": if not self.cost_center and self.voucher_type != 'Period Closing Voucher': frappe.throw(_("{0} {1}: Cost Center is required for 'Profit and Loss' account {2}. Please set up a default Cost Center for the Company.") .format(self.voucher_type, self.voucher_no, self.account)) @@ -140,25 +140,16 @@ class GLEntry(Document): .format(self.voucher_type, self.voucher_no, self.account, self.company)) def validate_cost_center(self): - if not hasattr(self, "cost_center_company"): - self.cost_center_company = {} + if not self.cost_center: return - def _get_cost_center_company(): - if not self.cost_center_company.get(self.cost_center): - self.cost_center_company[self.cost_center] = frappe.db.get_value( - "Cost Center", self.cost_center, "company") + is_group, company = frappe.get_cached_value('Cost Center', + self.cost_center, ['is_group', 'company']) - return self.cost_center_company[self.cost_center] - - def _check_is_group(): - return cint(frappe.get_cached_value('Cost Center', self.cost_center, 'is_group')) - - if self.cost_center and _get_cost_center_company() != self.company: + if company != self.company: frappe.throw(_("{0} {1}: Cost Center {2} does not belong to Company {3}") .format(self.voucher_type, self.voucher_no, self.cost_center, self.company)) - if not self.flags.from_repost and not self.voucher_type == 'Period Closing Voucher' \ - and self.cost_center and _check_is_group(): + if (self.voucher_type != 'Period Closing Voucher' and is_group): frappe.throw(_("""{0} {1}: Cost Center {2} is a group cost center and group cost centers cannot be used in transactions""").format( self.voucher_type, self.voucher_no, frappe.bold(self.cost_center))) @@ -184,7 +175,6 @@ class GLEntry(Document): if not self.fiscal_year: self.fiscal_year = get_fiscal_year(self.posting_date, company=self.company)[0] - def validate_balance_type(account, adv_adj=False): if not adv_adj and account: balance_must_be = frappe.db.get_value("Account", account, "balance_must_be") @@ -250,7 +240,7 @@ def update_outstanding_amt(account, party_type, party, against_voucher_type, aga def validate_frozen_account(account, adv_adj=None): - frozen_account = frappe.db.get_value("Account", account, "freeze_account") + frozen_account = frappe.get_cached_value("Account", account, "freeze_account") if frozen_account == 'Yes' and not adv_adj: frozen_accounts_modifier = frappe.db.get_value( 'Accounts Settings', None, 'frozen_accounts_modifier') 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 57baac76819..9ea616f8e77 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js @@ -3,6 +3,7 @@ frappe.ui.form.on('POS Closing Entry', { onload: function(frm) { + frm.ignore_doctypes_on_cancel_all = ['POS Invoice Merge Log']; frm.set_query("pos_profile", function(doc) { return { filters: { 'user': doc.user } @@ -20,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.json b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json index 32bca3b8407..18d430f59f8 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json @@ -11,6 +11,7 @@ "column_break_3", "posting_date", "pos_opening_entry", + "status", "section_break_5", "company", "column_break_7", @@ -184,11 +185,27 @@ "label": "POS Opening Entry", "options": "POS Opening Entry", "reqd": 1 + }, + { + "allow_on_submit": 1, + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "Draft\nSubmitted\nQueued\nCancelled", + "print_hide": 1, + "read_only": 1 } ], "is_submittable": 1, - "links": [], - "modified": "2020-05-29 15:03:22.226113", + "links": [ + { + "link_doctype": "POS Invoice Merge Log", + "link_fieldname": "pos_closing_entry" + } + ], + "modified": "2021-01-12 12:21:05.388650", "modified_by": "Administrator", "module": "Accounts", "name": "POS Closing Entry", 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 c26e14ff6f8..edf3d5a5748 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py @@ -6,13 +6,12 @@ from __future__ import unicode_literals import frappe import json from frappe import _ -from frappe.model.document import Document -from frappe.utils import getdate, get_datetime, flt -from collections import defaultdict +from frappe.utils import get_datetime, flt +from erpnext.controllers.status_updater import StatusUpdater from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data -from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import merge_pos_invoices +from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices, unconsolidate_pos_invoices -class POSClosingEntry(Document): +class POSClosingEntry(StatusUpdater): def validate(self): if frappe.db.get_value("POS Opening Entry", self.pos_opening_entry, "status") != "Open": frappe.throw(_("Selected POS Opening Entry should be open."), title=_("Invalid Opening Entry")) @@ -64,17 +63,22 @@ class POSClosingEntry(Document): frappe.throw(error_list, title=_("Invalid POS Invoices"), as_list=True) - def on_submit(self): - merge_pos_invoices(self.pos_transactions) - opening_entry = frappe.get_doc("POS Opening Entry", self.pos_opening_entry) - opening_entry.pos_closing_entry = self.name - opening_entry.set_status() - opening_entry.save() - def get_payment_reconciliation_details(self): currency = frappe.get_cached_value('Company', self.company, "default_currency") return frappe.render_template("erpnext/accounts/doctype/pos_closing_entry/closing_voucher_details.html", {"data": self, "currency": currency}) + + def on_submit(self): + consolidate_pos_invoices(closing_entry=self) + + def on_cancel(self): + unconsolidate_pos_invoices(closing_entry=self) + + def update_opening_entry(self, for_cancel=False): + opening_entry = frappe.get_doc("POS Opening Entry", self.pos_opening_entry) + opening_entry.pos_closing_entry = self.name if not for_cancel else None + opening_entry.set_status() + opening_entry.save() @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry_list.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry_list.js new file mode 100644 index 00000000000..20fd610899e --- /dev/null +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry_list.js @@ -0,0 +1,16 @@ +// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +// render +frappe.listview_settings['POS Closing Entry'] = { + get_indicator: function(doc) { + var status_color = { + "Draft": "red", + "Submitted": "blue", + "Queued": "orange", + "Cancelled": "red" + + }; + return [__(doc.status), status_color[doc.status], "status,=,"+doc.status]; + } +}; diff --git a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py index 8de54d5bdef..40db09ec3be 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py @@ -13,7 +13,6 @@ from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profi class TestPOSClosingEntry(unittest.TestCase): def test_pos_closing_entry(self): test_user, pos_profile = init_user_and_profile() - opening_entry = create_opening_entry(pos_profile, test_user.name) pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1) @@ -45,6 +44,49 @@ class TestPOSClosingEntry(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") + def test_cancelling_of_pos_closing_entry(self): + test_user, pos_profile = init_user_and_profile() + opening_entry = create_opening_entry(pos_profile, test_user.name) + + pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1) + pos_inv1.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3500 + }) + pos_inv1.submit() + + pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200 + }) + pos_inv2.submit() + + pcv_doc = make_closing_entry_from_opening(opening_entry) + payment = pcv_doc.payment_reconciliation[0] + + self.assertEqual(payment.mode_of_payment, 'Cash') + + for d in pcv_doc.payment_reconciliation: + if d.mode_of_payment == 'Cash': + d.closing_amount = 6700 + + pcv_doc.submit() + + pos_inv1.load_from_db() + self.assertRaises(frappe.ValidationError, pos_inv1.cancel) + + si_doc = frappe.get_doc("Sales Invoice", pos_inv1.consolidated_invoice) + self.assertRaises(frappe.ValidationError, si_doc.cancel) + + pcv_doc.load_from_db() + pcv_doc.cancel() + si_doc.load_from_db() + pos_inv1.load_from_db() + self.assertEqual(si_doc.docstatus, 2) + self.assertEqual(pos_inv1.status, 'Paid') + + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + def init_user_and_profile(**args): user = 'test@example.com' test_user = frappe.get_doc('User', user) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 86062d1e7cc..493bd448024 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -2,6 +2,7 @@ // For license information, please see license.txt {% include 'erpnext/selling/sales_common.js' %}; +frappe.provide("erpnext.accounts"); erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend({ setup(doc) { @@ -9,12 +10,19 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend( this._super(doc); }, + company: function() { + erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + }, + onload(doc) { this._super(); + this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice Merge Log']; if(doc.__islocal && doc.is_pos && frappe.get_route_str() !== 'point-of-sale') { this.frm.script_manager.trigger("is_pos"); this.frm.refresh_fields(); } + + erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype); }, refresh(doc) { @@ -187,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 bd664c59f2a..0c1406c1ce8 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -6,10 +6,9 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.model.document import Document -from erpnext.controllers.selling_controller import SellingController -from frappe.utils import cint, flt, add_months, today, date_diff, getdate, add_days, cstr, nowdate from erpnext.accounts.utils import get_account_currency from erpnext.accounts.party import get_party_account, get_due_date +from frappe.utils import cint, flt, getdate, nowdate, get_link_to_form from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos, get_serial_nos @@ -58,6 +57,22 @@ class POSInvoice(SalesInvoice): self.apply_loyalty_points() self.check_phone_payments() self.set_status(update=True) + + def before_cancel(self): + if self.consolidated_invoice and frappe.db.get_value('Sales Invoice', self.consolidated_invoice, 'docstatus') == 1: + pos_closing_entry = frappe.get_all( + "POS Invoice Reference", + ignore_permissions=True, + filters={ 'pos_invoice': self.name }, + pluck="parent", + limit=1 + ) + frappe.throw( + _('You need to cancel POS Closing Entry {} to be able to cancel this document.').format( + get_link_to_form("POS Closing Entry", pos_closing_entry[0]) + ), + title=_('Not Allowed') + ) def on_cancel(self): # run on cancel method of selling controller @@ -78,7 +93,7 @@ class POSInvoice(SalesInvoice): mode_of_payment=pay.mode_of_payment, status="Paid"), fieldname="grand_total") - if pay.amount != paid_amt: + if paid_amt and pay.amount != paid_amt: return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment)) def validate_stock_availablility(self): @@ -296,14 +311,21 @@ class POSInvoice(SalesInvoice): self.set(fieldname, profile.get(fieldname)) if self.customer: - customer_price_list, customer_group = frappe.db.get_value("Customer", self.customer, ['default_price_list', 'customer_group']) + customer_price_list, customer_group, customer_currency = frappe.db.get_value( + "Customer", self.customer, ['default_price_list', 'customer_group', 'default_currency'] + ) 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"): @@ -363,22 +385,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]) def add_return_modes(doc, pos_profile): def append_payment(payment_mode): diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index c179360b017..57a23af8af0 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -290,7 +290,7 @@ class TestPOSInvoice(unittest.TestCase): def test_merging_into_sales_invoice_with_discount(self): from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile - from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import merge_pos_invoices + from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices frappe.db.sql("delete from `tabPOS Invoice`") test_user, pos_profile = init_user_and_profile() @@ -306,7 +306,7 @@ class TestPOSInvoice(unittest.TestCase): }) pos_inv2.submit() - merge_pos_invoices() + consolidate_pos_invoices() pos_inv.load_from_db() rounded_total = frappe.db.get_value("Sales Invoice", pos_inv.consolidated_invoice, "rounded_total") @@ -315,7 +315,7 @@ class TestPOSInvoice(unittest.TestCase): def test_merging_into_sales_invoice_with_discount_and_inclusive_tax(self): from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile - from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import merge_pos_invoices + from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices frappe.db.sql("delete from `tabPOS Invoice`") test_user, pos_profile = init_user_and_profile() @@ -348,7 +348,7 @@ class TestPOSInvoice(unittest.TestCase): }) pos_inv2.submit() - merge_pos_invoices() + consolidate_pos_invoices() pos_inv.load_from_db() rounded_total = frappe.db.get_value("Sales Invoice", pos_inv.consolidated_invoice, "rounded_total") @@ -357,7 +357,7 @@ class TestPOSInvoice(unittest.TestCase): def test_merging_with_validate_selling_price(self): from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile - from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import merge_pos_invoices + from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices if not frappe.db.get_single_value("Selling Settings", "validate_selling_price"): frappe.db.set_value("Selling Settings", "Selling Settings", "validate_selling_price", 1) @@ -393,7 +393,7 @@ class TestPOSInvoice(unittest.TestCase): }) pos_inv2.submit() - merge_pos_invoices() + consolidate_pos_invoices() pos_inv2.load_from_db() rounded_total = frappe.db.get_value("Sales Invoice", pos_inv2.consolidated_invoice, "rounded_total") diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json index 8f97639bbc9..da2984f05af 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json @@ -7,6 +7,8 @@ "field_order": [ "posting_date", "customer", + "column_break_3", + "pos_closing_entry", "section_break_3", "pos_invoices", "references_section", @@ -76,11 +78,22 @@ "label": "Consolidated Credit Note", "options": "Sales Invoice", "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "pos_closing_entry", + "fieldtype": "Link", + "label": "POS Closing Entry", + "options": "POS Closing Entry" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-05-29 15:08:41.317100", + "modified": "2020-12-01 11:53:57.267579", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Merge Log", 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 5e43cfe0302..58409cd3c6d 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 @@ -7,8 +7,11 @@ import frappe from frappe import _ from frappe.model import default_fields from frappe.model.document import Document +from frappe.utils import flt, getdate, nowdate +from frappe.utils.background_jobs import enqueue from frappe.model.mapper import map_doc, map_child_doc -from frappe.utils import cint, flt, add_months, today, date_diff, getdate, add_days, cstr, nowdate +from frappe.utils.scheduler import is_scheduler_inactive +from frappe.core.page.background_jobs.background_jobs import get_info from six import iteritems @@ -61,7 +64,13 @@ class POSInvoiceMergeLog(Document): self.save() # save consolidated_sales_invoice & consolidated_credit_note ref in merge log - self.update_pos_invoices(sales_invoice, credit_note) + self.update_pos_invoices(pos_invoice_docs, sales_invoice, credit_note) + + def on_cancel(self): + pos_invoice_docs = [frappe.get_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices] + + self.update_pos_invoices(pos_invoice_docs) + self.cancel_linked_invoices() def process_merging_into_sales_invoice(self, data): sales_invoice = self.get_new_sales_invoice() @@ -111,6 +120,7 @@ class POSInvoiceMergeLog(Document): i.qty = i.qty + item.qty if not found: item.rate = item.net_rate + item.price_list_rate = 0 si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) items.append(si_item) @@ -148,6 +158,8 @@ class POSInvoiceMergeLog(Document): invoice.set('taxes', taxes) invoice.additional_discount_percentage = 0 invoice.discount_amount = 0.0 + invoice.taxes_and_charges = None + invoice.ignore_pricing_rule = 1 return invoice @@ -160,17 +172,21 @@ class POSInvoiceMergeLog(Document): return sales_invoice - def update_pos_invoices(self, sales_invoice, credit_note): - for d in self.pos_invoices: - doc = frappe.get_doc('POS Invoice', d.pos_invoice) - if not doc.is_return: - doc.update({'consolidated_invoice': sales_invoice}) - else: - doc.update({'consolidated_invoice': credit_note}) + def update_pos_invoices(self, invoice_docs, sales_invoice='', credit_note=''): + for doc in invoice_docs: + doc.load_from_db() + doc.update({ 'consolidated_invoice': None if self.docstatus==2 else (credit_note if doc.is_return else sales_invoice) }) doc.set_status(update=True) doc.save() -def get_all_invoices(): + def cancel_linked_invoices(self): + for si_name in [self.consolidated_invoice, self.consolidated_credit_note]: + if not si_name: continue + si = frappe.get_doc('Sales Invoice', si_name) + si.flags.ignore_validate = True + si.cancel() + +def get_all_unconsolidated_invoices(): filters = { 'consolidated_invoice': [ 'in', [ '', None ]], 'status': ['not in', ['Consolidated']], @@ -181,7 +197,7 @@ def get_all_invoices(): return pos_invoices -def get_invoices_customer_map(pos_invoices): +def get_invoice_customer_map(pos_invoices): # pos_invoice_customer_map = { 'Customer 1': [{}, {}, {}], 'Custoemr 2' : [{}] } pos_invoice_customer_map = {} for invoice in pos_invoices: @@ -191,20 +207,82 @@ def get_invoices_customer_map(pos_invoices): return pos_invoice_customer_map -def merge_pos_invoices(pos_invoices=[]): - if not pos_invoices: - pos_invoices = get_all_invoices() - - pos_invoice_map = get_invoices_customer_map(pos_invoices) - create_merge_logs(pos_invoice_map) +def consolidate_pos_invoices(pos_invoices=[], closing_entry={}): + invoices = pos_invoices or closing_entry.get('pos_transactions') or get_all_unconsolidated_invoices() + invoice_by_customer = get_invoice_customer_map(invoices) -def create_merge_logs(pos_invoice_customer_map): - for customer, invoices in iteritems(pos_invoice_customer_map): + 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') + else: + create_merge_logs(invoice_by_customer, closing_entry) + +def unconsolidate_pos_invoices(closing_entry): + merge_logs = frappe.get_all( + 'POS Invoice Merge Log', + filters={ 'pos_closing_entry': closing_entry.name }, + pluck='name' + ) + + if len(merge_logs) >= 5: + enqueue_job(cancel_merge_logs, merge_logs, closing_entry) + closing_entry.set_status(update=True, status='Queued') + else: + cancel_merge_logs(merge_logs, closing_entry) + +def create_merge_logs(invoice_by_customer, closing_entry={}): + for customer, invoices in iteritems(invoice_by_customer): merge_log = frappe.new_doc('POS Invoice Merge Log') merge_log.posting_date = getdate(nowdate()) merge_log.customer = customer + merge_log.pos_closing_entry = closing_entry.get('name', None) merge_log.set('pos_invoices', invoices) merge_log.save(ignore_permissions=True) merge_log.submit() + + if closing_entry: + closing_entry.set_status(update=True, status='Submitted') + closing_entry.update_opening_entry() +def cancel_merge_logs(merge_logs, closing_entry={}): + for log in merge_logs: + merge_log = frappe.get_doc('POS Invoice Merge Log', log) + merge_log.flags.ignore_permissions = True + merge_log.cancel() + + if closing_entry: + closing_entry.set_status(update=True, status='Cancelled') + closing_entry.update_opening_entry(for_cancel=True) + +def enqueue_job(job, invoice_by_customer, closing_entry): + check_scheduler_status() + + job_name = closing_entry.get("name") + if not job_already_enqueued(job_name): + enqueue( + job, + queue="long", + timeout=10000, + event="processing_merge_logs", + job_name=job_name, + closing_entry=closing_entry, + invoice_by_customer=invoice_by_customer, + now=frappe.conf.developer_mode or frappe.flags.in_test + ) + + if job == create_merge_logs: + msg = _('POS Invoices will be consolidated in a background process') + else: + msg = _('POS Invoices will be unconsolidated in a background process') + + frappe.msgprint(msg, alert=1) + +def check_scheduler_status(): + if is_scheduler_inactive() and not frappe.flags.in_test: + frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive")) + +def job_already_enqueued(job_name): + enqueued_jobs = [d.get("job_name") for d in get_info()] + if job_name in enqueued_jobs: + return True \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index 0f34272eb49..db046c98008 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -7,7 +7,7 @@ import frappe import unittest from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return -from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import merge_pos_invoices +from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile class TestPOSInvoiceMergeLog(unittest.TestCase): @@ -34,7 +34,7 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): }) pos_inv3.submit() - merge_pos_invoices() + consolidate_pos_invoices() pos_inv.load_from_db() self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice)) @@ -79,7 +79,7 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): pos_inv_cn.paid_amount = -300 pos_inv_cn.submit() - merge_pos_invoices() + consolidate_pos_invoices() pos_inv.load_from_db() self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice)) diff --git a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py index acac1c4072e..cb5b3a58fe9 100644 --- a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py +++ b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py @@ -6,7 +6,6 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.utils import cint, get_link_to_form -from frappe.model.document import Document from erpnext.controllers.status_updater import StatusUpdater class POSOpeningEntry(StatusUpdater): diff --git a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js index 6c26dedc54b..1ad3c919b71 100644 --- a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js +++ b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js @@ -5,7 +5,7 @@ frappe.listview_settings['POS Opening Entry'] = { get_indicator: function(doc) { var status_color = { - "Draft": "grey", + "Draft": "red", "Open": "orange", "Closed": "green", "Cancelled": "red" diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 750ed82d154..4b69f6e2efe 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -12,8 +12,6 @@ "company", "country", "column_break_9", - "update_stock", - "ignore_pricing_rule", "warehouse", "campaign", "company_address", @@ -25,8 +23,14 @@ "hide_images", "hide_unavailable_items", "auto_add_item_to_cart", - "item_groups", "column_break_16", + "update_stock", + "ignore_pricing_rule", + "allow_rate_change", + "allow_discount_change", + "section_break_23", + "item_groups", + "column_break_25", "customer_groups", "section_break_16", "print_format", @@ -309,6 +313,7 @@ "default": "1", "fieldname": "update_stock", "fieldtype": "Check", + "hidden": 1, "label": "Update Stock", "read_only": 1 }, @@ -329,13 +334,34 @@ "fieldname": "auto_add_item_to_cart", "fieldtype": "Check", "label": "Automatically Add Filtered Item To Cart" + }, + { + "default": "0", + "fieldname": "allow_rate_change", + "fieldtype": "Check", + "label": "Allow User to Edit Rate" + }, + { + "default": "0", + "fieldname": "allow_discount_change", + "fieldtype": "Check", + "label": "Allow User to Edit Discount" + }, + { + "collapsible": 1, + "fieldname": "section_break_23", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_25", + "fieldtype": "Column Break" } ], "icon": "icon-cog", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-12-20 13:59:28.877572", + "modified": "2021-01-06 14:42:41.713864", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 1f7853dbf71..07e75acb418 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -40,6 +40,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_22", "net_rate", @@ -783,6 +784,14 @@ "print_hide": 1, "read_only": 1 }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "options": "currency", + "read_only": 1 + }, { "fieldname": "sales_invoice_item", "fieldtype": "Data", @@ -795,7 +804,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2020-12-26 17:20:36.415791", + "modified": "2021-01-30 21:43:21.488258", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index f2a62cdacd1..5bef9e242d7 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -20,6 +20,7 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte var me = this; this._super(); + this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice']; if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { // show debit_to in print format this.frm.set_df_property("debit_to", "print_hide", 0); diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 447cee42a75..018bc7e6419 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -1987,8 +1987,15 @@ "icon": "fa fa-file-text", "idx": 181, "is_submittable": 1, - "links": [], - "modified": "2020-12-25 22:57:32.555067", + "links": [ + { + "custom": 1, + "group": "Reference", + "link_doctype": "POS Invoice", + "link_fieldname": "consolidated_invoice" + } + ], + "modified": "2021-01-12 12:16:15.192520", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index f45e076ce13..903a2ef5f70 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -236,7 +236,25 @@ class SalesInvoice(SellingController): if len(self.payments) == 0 and self.is_pos: frappe.throw(_("At least one mode of payment is required for POS invoice.")) + def check_if_consolidated_invoice(self): + # since POS Invoice extends Sales Invoice, we explicitly check if doctype is Sales Invoice + if self.doctype == "Sales Invoice" and self.is_consolidated: + invoice_or_credit_note = "consolidated_credit_note" if self.is_return else "consolidated_invoice" + pos_closing_entry = frappe.get_all( + "POS Invoice Merge Log", + filters={ invoice_or_credit_note: self.name }, + pluck="pos_closing_entry" + ) + if pos_closing_entry: + msg = _("To cancel a {} you need to cancel the POS Closing Entry {}. ").format( + frappe.bold("Consolidated Sales Invoice"), + get_link_to_form("POS Closing Entry", pos_closing_entry[0]) + ) + frappe.throw(msg, title=_("Not Allowed")) + def before_cancel(self): + self.check_if_consolidated_invoice() + super(SalesInvoice, self).before_cancel() self.update_time_sheet(None) @@ -438,7 +456,9 @@ class SalesInvoice(SellingController): if not for_validate and not self.customer: self.customer = pos.customer - self.ignore_pricing_rule = pos.ignore_pricing_rule + if not for_validate: + self.ignore_pricing_rule = pos.ignore_pricing_rule + if pos.get('account_for_change_amount'): self.account_for_change_amount = pos.get('account_for_change_amount') diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index 7a98afff364..b403c7b2375 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -45,6 +45,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_21", "net_rate", @@ -811,12 +812,20 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "options": "currency", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-12-26 17:25:04.090630", + "modified": "2021-01-30 21:42:37.796771", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 287c79f13fe..b42c0c61d9e 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -44,9 +44,9 @@ def validate_accounting_period(gl_map): frappe.throw(_("You cannot create or cancel any accounting entries with in the closed Accounting Period {0}") .format(frappe.bold(accounting_periods[0].name)), ClosedAccountingPeriod) -def process_gl_map(gl_map, merge_entries=True): +def process_gl_map(gl_map, merge_entries=True, precision=None): if merge_entries: - gl_map = merge_similar_entries(gl_map) + gl_map = merge_similar_entries(gl_map, precision) for entry in gl_map: # toggle debit, credit if negative entry if flt(entry.debit) < 0: @@ -69,7 +69,7 @@ def process_gl_map(gl_map, merge_entries=True): return gl_map -def merge_similar_entries(gl_map): +def merge_similar_entries(gl_map, precision=None): merged_gl_map = [] accounting_dimensions = get_accounting_dimensions() for entry in gl_map: @@ -88,7 +88,9 @@ def merge_similar_entries(gl_map): company = gl_map[0].company if gl_map else erpnext.get_default_company() company_currency = erpnext.get_company_currency(company) - precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), company_currency) + + if not precision: + precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), company_currency) # filter zero debit and credit entries merged_gl_map = filter(lambda x: flt(x.debit, precision)!=0 or flt(x.credit, precision)!=0, merged_gl_map) @@ -132,8 +134,8 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False): gle.update(args) gle.flags.ignore_permissions = 1 gle.flags.from_repost = from_repost - gle.insert() - gle.run_method("on_update_with_args", adv_adj, update_outstanding, from_repost) + gle.flags.adv_adj = adv_adj + gle.flags.update_outstanding = update_outstanding or 'Yes' gle.submit() if not from_repost: diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index d0116890b65..76f3c50578e 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -222,7 +222,7 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i set_gl_entries_by_account(start_date, end_date, root.lft, root.rgt, filters, - gl_entries_by_account, accounts_by_name, ignore_closing_entries=False) + gl_entries_by_account, accounts_by_name, accounts, ignore_closing_entries=False) calculate_values(accounts_by_name, gl_entries_by_account, companies, start_date, filters) accumulate_values_into_parents(accounts, accounts_by_name, companies) @@ -339,7 +339,7 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com return data def set_gl_entries_by_account(from_date, to_date, root_lft, root_rgt, filters, gl_entries_by_account, - accounts_by_name, ignore_closing_entries=False): + accounts_by_name, accounts, ignore_closing_entries=False): """Returns a dict like { "account": [gl entries], ... }""" company_lft, company_rgt = frappe.get_cached_value('Company', @@ -382,15 +382,31 @@ def set_gl_entries_by_account(from_date, to_date, root_lft, root_rgt, filters, g for entry in gl_entries: key = entry.account_number or entry.account_name - validate_entries(key, entry, accounts_by_name) + validate_entries(key, entry, accounts_by_name, accounts) gl_entries_by_account.setdefault(key, []).append(entry) return gl_entries_by_account -def validate_entries(key, entry, accounts_by_name): +def get_account_details(account): + return frappe.get_cached_value('Account', account, ['name', 'report_type', 'root_type', 'company', + 'is_group', 'account_name', 'account_number', 'parent_account', 'lft', 'rgt'], as_dict=1) + +def validate_entries(key, entry, accounts_by_name, accounts): if key not in accounts_by_name: - field = "Account number" if entry.account_number else "Account name" - frappe.throw(_("{0} {1} is not present in the parent company").format(field, key)) + args = get_account_details(entry.account) + + if args.parent_account: + parent_args = get_account_details(args.parent_account) + + args.update({ + 'lft': parent_args.lft + 1, + 'rgt': parent_args.rgt - 1, + 'root_type': parent_args.root_type, + 'report_type': parent_args.report_type + }) + + accounts_by_name.setdefault(key, args) + accounts.append(args) def get_additional_conditions(from_date, ignore_closing_entries, filters): additional_conditions = [] diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 67c7fd2d22a..89a05b187d1 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -82,7 +82,7 @@ def get_fiscal_years(transaction_date=None, fiscal_year=None, label="Date", verb error_msg = _("""{0} {1} is not in any active Fiscal Year""").format(label, formatdate(transaction_date)) if company: error_msg = _("""{0} for {1}""").format(error_msg, frappe.bold(company)) - + if verbose==1: frappe.msgprint(error_msg) raise FiscalYearError(error_msg) @@ -888,6 +888,11 @@ def get_coa(doctype, parent, is_root, chart=None): def update_gl_entries_after(posting_date, posting_time, for_warehouses=None, for_items=None, warehouse_account=None, company=None): + stock_vouchers = get_future_stock_vouchers(posting_date, posting_time, for_warehouses, for_items, company) + repost_gle_for_stock_vouchers(stock_vouchers, posting_date, company, warehouse_account) + + +def repost_gle_for_stock_vouchers(stock_vouchers, posting_date, company=None, warehouse_account=None): def _delete_gl_entries(voucher_type, voucher_no): frappe.db.sql("""delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s""", (voucher_type, voucher_no)) @@ -895,21 +900,21 @@ def update_gl_entries_after(posting_date, posting_time, for_warehouses=None, for if not warehouse_account: warehouse_account = get_warehouse_account_map(company) - future_stock_vouchers = get_future_stock_vouchers(posting_date, posting_time, for_warehouses, for_items) - gle = get_voucherwise_gl_entries(future_stock_vouchers, posting_date) + precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2 - for voucher_type, voucher_no in future_stock_vouchers: + gle = get_voucherwise_gl_entries(stock_vouchers, posting_date) + for voucher_type, voucher_no in stock_vouchers: existing_gle = gle.get((voucher_type, voucher_no), []) - voucher_obj = frappe.get_doc(voucher_type, voucher_no) + voucher_obj = frappe.get_cached_doc(voucher_type, voucher_no) expected_gle = voucher_obj.get_gl_entries(warehouse_account) if expected_gle: - if not existing_gle or not compare_existing_and_expected_gle(existing_gle, expected_gle): + if not existing_gle or not compare_existing_and_expected_gle(existing_gle, expected_gle, precision): _delete_gl_entries(voucher_type, voucher_no) voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True) else: _delete_gl_entries(voucher_type, voucher_no) -def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None): +def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None, company=None): future_stock_vouchers = [] values = [] @@ -922,6 +927,10 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f condition += " and warehouse in ({})".format(", ".join(["%s"] * len(for_warehouses))) values += for_warehouses + if company: + condition += " and company = %s" + values.append(company) + for d in frappe.db.sql("""select distinct sle.voucher_type, sle.voucher_no from `tabStock Ledger Entry` sle where @@ -945,16 +954,17 @@ def get_voucherwise_gl_entries(future_stock_vouchers, posting_date): return gl_entries -def compare_existing_and_expected_gle(existing_gle, expected_gle): +def compare_existing_and_expected_gle(existing_gle, expected_gle, precision): matched = True for entry in expected_gle: account_existed = False for e in existing_gle: if entry.account == e.account: account_existed = True - if entry.account == e.account and entry.against_account == e.against_account \ - and (not entry.cost_center or not e.cost_center or entry.cost_center == e.cost_center) \ - and (entry.debit != e.debit or entry.credit != e.credit): + if (entry.account == e.account and entry.against_account == e.against_account + and (not entry.cost_center or not e.cost_center or entry.cost_center == e.cost_center) + and ( flt(entry.debit, precision) != flt(e.debit, precision) or + flt(entry.credit, precision) != flt(e.credit, precision))): matched = False break if not account_existed: @@ -982,7 +992,7 @@ def check_if_stock_and_account_balance_synced(posting_date, company, voucher_typ error_reason = _("Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and it's linked warehouses as on {3}.").format( stock_bal, account_bal, frappe.bold(account), posting_date) error_resolution = _("Please create an adjustment Journal Entry for amount {0} on {1}")\ - .format(frappe.bold(diff), frappe.bold(posting_date)) + .format(frappe.bold(diff), frappe.bold(posting_date)) frappe.msgprint( msg="""{0}

{1}

""".format(error_reason, error_resolution), diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index c691e9f9f85..75b2954dddc 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -40,6 +40,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_29", "net_rate", @@ -726,13 +727,21 @@ "fieldname": "more_info_section_break", "fieldtype": "Section Break", "label": "More Information" + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "options": "currency", + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-12-07 11:59:47.670951", + "modified": "2021-01-30 21:44:41.816974", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/change_log/v13/v13_0_0-beta_12.md b/erpnext/change_log/v13/v13_0_0-beta_12.md new file mode 100644 index 00000000000..cb981f1488c --- /dev/null +++ b/erpnext/change_log/v13/v13_0_0-beta_12.md @@ -0,0 +1,14 @@ +### Version 13.0.0 Beta 12 Release Notes +#### Features +- Department wise Appointment Type charges ([#24572](https://github.com/frappe/erpnext/pull/24572)) +- Capture Rate of stock UOM in purchase ([#24315](https://github.com/frappe/erpnext/pull/24315)) + +#### Fixes + +- Fixed stock and account balance syncing ([#24644](https://github.com/frappe/erpnext/pull/24644)) +- Fixed incorrect stock ledger qty in the stock ledger report and bin ([#24649](https://github.com/frappe/erpnext/pull/24649)) +- Added patch to fix incorrect stock ledger and stock account value ([#24702](https://github.com/frappe/erpnext/pull/24702)) +- Skip e-invoice generation for non-taxable invoices ([#24568](https://github.com/frappe/erpnext/pull/24568)) +- Cannot cancel old invoices if eligible for e-invoicing ([#24608](https://github.com/frappe/erpnext/pull/24608)) +- Mpesa fixes and enhancement ([#24306](https://github.com/frappe/erpnext/pull/24306)) +- Fixed Consolidated Financial Statement report ([#24580](https://github.com/frappe/erpnext/pull/24580)) \ No newline at end of file diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 35c6cd33f9f..e6b14c2e408 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -302,6 +302,7 @@ class AccountsController(TransactionBase): args["doctype"] = self.doctype args["name"] = self.name args["child_docname"] = item.name + args["ignore_pricing_rule"] = self.ignore_pricing_rule if hasattr(self, 'ignore_pricing_rule') else 0 if not args.get("transaction_date"): args["transaction_date"] = args.get("posting_date") @@ -1503,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 e085048f99b..3e82d46c2de 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -456,9 +456,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": @@ -469,13 +473,19 @@ class SellingController(StockController): non_stock_items = [d.item_code, d.description] if frappe.db.get_value("Item", d.item_code, "is_stock_item") == 1: + duplicate_items_msg = _("Item {0} entered multiple times.").format(frappe.bold(d.item_code)) + duplicate_items_msg += "

" + duplicate_items_msg += _("Please enable {} in {} to allow same item in multiple rows").format( + frappe.bold("Allow Item to Be Added Multiple Times in a Transaction"), + get_link_to_form("Selling Settings", "Selling Settings") + ) if stock_items in check_list: - frappe.throw(_("Note: Item {0} entered multiple times").format(d.item_code)) + frappe.throw(duplicate_items_msg) else: check_list.append(stock_items) else: if non_stock_items in chk_dupl_itm: - frappe.throw(_("Note: Item {0} entered multiple times").format(d.item_code)) + frappe.throw(duplicate_items_msg) else: chk_dupl_itm.append(non_stock_items) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 8c05134ae41..0987d0985ea 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -93,6 +93,12 @@ status_map = { ["Open", "eval:self.docstatus == 1 and not self.pos_closing_entry"], ["Closed", "eval:self.docstatus == 1 and self.pos_closing_entry"], ["Cancelled", "eval:self.docstatus == 2"], + ], + "POS Closing Entry": [ + ["Draft", None], + ["Submitted", "eval:self.docstatus == 1"], + ["Queued", "eval:self.status == 'Queued'"], + ["Cancelled", "eval:self.docstatus == 2"], ] } diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 4b5e3479706..cb44b73caf1 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -24,6 +24,7 @@ class StockController(AccountsController): self.validate_inspection() self.validate_serialized_batch() self.validate_customer_provided_item() + self.set_rate_of_stock_uom() self.validate_internal_transfer() self.validate_putaway_capacity() @@ -73,7 +74,7 @@ class StockController(AccountsController): gl_list = [] warehouse_with_no_account = [] - precision = frappe.get_precision("GL Entry", "debit_in_account_currency") + precision = self.get_debit_field_precision() for item_row in voucher_details: sle_list = sle_map.get(item_row.name) @@ -130,7 +131,13 @@ class StockController(AccountsController): if frappe.db.get_value("Warehouse", wh, "company"): frappe.throw(_("Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}.").format(wh, self.company)) - return process_gl_map(gl_list) + return process_gl_map(gl_list, precision=precision) + + def get_debit_field_precision(self): + if not frappe.flags.debit_field_precision: + frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency") + + return frappe.flags.debit_field_precision def update_stock_ledger_entries(self, sle): sle.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, @@ -243,7 +250,7 @@ class StockController(AccountsController): .format(item.idx, frappe.bold(item.item_code), msg), title=_("Expense Account Missing")) else: - is_expense_account = frappe.db.get_value("Account", + is_expense_account = frappe.get_cached_value("Account", item.get("expense_account"), "report_type")=="Profit and Loss" if self.doctype not in ("Purchase Receipt", "Purchase Invoice", "Stock Reconciliation", "Stock Entry") and not is_expense_account: frappe.throw(_("Expense / Difference account ({0}) must be a 'Profit or Loss' account") @@ -395,6 +402,11 @@ class StockController(AccountsController): if frappe.db.get_value('Item', d.item_code, 'is_customer_provided_item'): d.allow_zero_valuation_rate = 1 + def set_rate_of_stock_uom(self): + if self.doctype in ["Purchase Receipt", "Purchase Invoice", "Purchase Order", "Sales Invoice", "Sales Order", "Delivery Note", "Quotation"]: + for d in self.get("items"): + d.stock_uom_rate = d.rate / d.conversion_factor + def validate_internal_transfer(self): if self.doctype in ('Sales Invoice', 'Delivery Note', 'Purchase Invoice', 'Purchase Receipt') \ and self.is_internal_transfer(): @@ -481,7 +493,6 @@ class StockController(AccountsController): "voucher_no": self.name, "company": self.company }) - if check_if_future_sle_exists(args): create_repost_item_valuation_entry(args) elif not is_reposting_pending(): diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1f50e9c14df..6c7eb922210 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -107,7 +107,7 @@ class calculate_taxes_and_totals(object): elif item.discount_amount and item.pricing_rules: item.rate = item.price_list_rate - item.discount_amount - if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item']: + if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item', 'POS Invoice Item']: item.rate_with_margin, item.base_rate_with_margin = self.calculate_margin(item) if flt(item.rate_with_margin) > 0: item.rate = flt(item.rate_with_margin * (1.0 - (item.discount_percentage / 100.0)), item.precision("rate")) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 1439adb015b..938cbfdc852 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -176,7 +176,7 @@ class Lead(SellingController): "phone": self.mobile_no }) - contact.insert() + contact.insert(ignore_permissions=True) return contact 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 4e86d365e36..08b2bc2fa83 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,10 +44,13 @@ 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") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") + 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': 500}) @@ -55,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) @@ -68,6 +81,122 @@ 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() + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") def create_mpesa_settings(payment_gateway_name="Express"): if frappe.db.exists("Mpesa Settings", payment_gateway_name): @@ -157,16 +286,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 } @@ -174,41 +306,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/appointment_type/appointment_type.js b/erpnext/healthcare/doctype/appointment_type/appointment_type.js index 15916a5134a..861675acea3 100644 --- a/erpnext/healthcare/doctype/appointment_type/appointment_type.js +++ b/erpnext/healthcare/doctype/appointment_type/appointment_type.js @@ -2,4 +2,82 @@ // For license information, please see license.txt frappe.ui.form.on('Appointment Type', { + refresh: function(frm) { + frm.set_query('price_list', function() { + return { + filters: {'selling': 1} + }; + }); + + frm.set_query('medical_department', 'items', function(doc) { + let item_list = doc.items.map(({medical_department}) => medical_department); + return { + filters: [ + ['Medical Department', 'name', 'not in', item_list] + ] + }; + }); + + frm.set_query('op_consulting_charge_item', 'items', function() { + return { + filters: { + is_stock_item: 0 + } + }; + }); + + frm.set_query('inpatient_visit_charge_item', 'items', function() { + return { + filters: { + is_stock_item: 0 + } + }; + }); + } }); + +frappe.ui.form.on('Appointment Type Service Item', { + op_consulting_charge_item: function(frm, cdt, cdn) { + let d = locals[cdt][cdn]; + if (frm.doc.price_list && d.op_consulting_charge_item) { + frappe.call({ + 'method': 'frappe.client.get_value', + args: { + 'doctype': 'Item Price', + 'filters': { + 'item_code': d.op_consulting_charge_item, + 'price_list': frm.doc.price_list + }, + 'fieldname': ['price_list_rate'] + }, + callback: function(data) { + if (data.message.price_list_rate) { + frappe.model.set_value(cdt, cdn, 'op_consulting_charge', data.message.price_list_rate); + } + } + }); + } + }, + + inpatient_visit_charge_item: function(frm, cdt, cdn) { + let d = locals[cdt][cdn]; + if (frm.doc.price_list && d.inpatient_visit_charge_item) { + frappe.call({ + 'method': 'frappe.client.get_value', + args: { + 'doctype': 'Item Price', + 'filters': { + 'item_code': d.inpatient_visit_charge_item, + 'price_list': frm.doc.price_list + }, + 'fieldname': ['price_list_rate'] + }, + callback: function (data) { + if (data.message.price_list_rate) { + frappe.model.set_value(cdt, cdn, 'inpatient_visit_charge', data.message.price_list_rate); + } + } + }); + } + } +}); \ No newline at end of file diff --git a/erpnext/healthcare/doctype/appointment_type/appointment_type.json b/erpnext/healthcare/doctype/appointment_type/appointment_type.json index 58753bb4f05..38723182878 100644 --- a/erpnext/healthcare/doctype/appointment_type/appointment_type.json +++ b/erpnext/healthcare/doctype/appointment_type/appointment_type.json @@ -12,7 +12,10 @@ "appointment_type", "ip", "default_duration", - "color" + "color", + "billing_section", + "price_list", + "items" ], "fields": [ { @@ -52,10 +55,27 @@ "label": "Color", "no_copy": 1, "report_hide": 1 + }, + { + "fieldname": "billing_section", + "fieldtype": "Section Break", + "label": "Billing" + }, + { + "fieldname": "price_list", + "fieldtype": "Link", + "label": "Price List", + "options": "Price List" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Appointment Type Service Items", + "options": "Appointment Type Service Item" } ], "links": [], - "modified": "2020-02-03 21:06:05.833050", + "modified": "2021-01-22 09:41:05.010524", "modified_by": "Administrator", "module": "Healthcare", "name": "Appointment Type", diff --git a/erpnext/healthcare/doctype/appointment_type/appointment_type.py b/erpnext/healthcare/doctype/appointment_type/appointment_type.py index 1dacffab357..67a24f31e03 100644 --- a/erpnext/healthcare/doctype/appointment_type/appointment_type.py +++ b/erpnext/healthcare/doctype/appointment_type/appointment_type.py @@ -4,6 +4,53 @@ from __future__ import unicode_literals from frappe.model.document import Document +import frappe class AppointmentType(Document): - pass + def validate(self): + if self.items and self.price_list: + for item in self.items: + existing_op_item_price = frappe.db.exists('Item Price', { + 'item_code': item.op_consulting_charge_item, + 'price_list': self.price_list + }) + + if not existing_op_item_price and item.op_consulting_charge_item and item.op_consulting_charge: + make_item_price(self.price_list, item.op_consulting_charge_item, item.op_consulting_charge) + + existing_ip_item_price = frappe.db.exists('Item Price', { + 'item_code': item.inpatient_visit_charge_item, + 'price_list': self.price_list + }) + + if not existing_ip_item_price and item.inpatient_visit_charge_item and item.inpatient_visit_charge: + make_item_price(self.price_list, item.inpatient_visit_charge_item, item.inpatient_visit_charge) + +@frappe.whitelist() +def get_service_item_based_on_department(appointment_type, department): + item_list = frappe.db.get_value('Appointment Type Service Item', + filters = {'medical_department': department, 'parent': appointment_type}, + fieldname = ['op_consulting_charge_item', + 'inpatient_visit_charge_item', 'op_consulting_charge', 'inpatient_visit_charge'], + as_dict = 1 + ) + + # if department wise items are not set up + # use the generic items + if not item_list: + item_list = frappe.db.get_value('Appointment Type Service Item', + filters = {'parent': appointment_type}, + fieldname = ['op_consulting_charge_item', + 'inpatient_visit_charge_item', 'op_consulting_charge', 'inpatient_visit_charge'], + as_dict = 1 + ) + + return item_list + +def make_item_price(price_list, item, item_price): + frappe.get_doc({ + 'doctype': 'Item Price', + 'price_list': price_list, + 'item_code': item, + 'price_list_rate': item_price + }).insert(ignore_permissions=True, ignore_mandatory=True) diff --git a/erpnext/healthcare/doctype/appointment_type_service_item/__init__.py b/erpnext/healthcare/doctype/appointment_type_service_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.json b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.json new file mode 100644 index 00000000000..5ff68cd682c --- /dev/null +++ b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.json @@ -0,0 +1,67 @@ +{ + "actions": [], + "creation": "2021-01-22 09:34:53.373105", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "medical_department", + "op_consulting_charge_item", + "op_consulting_charge", + "column_break_4", + "inpatient_visit_charge_item", + "inpatient_visit_charge" + ], + "fields": [ + { + "fieldname": "medical_department", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Medical Department", + "options": "Medical Department" + }, + { + "fieldname": "op_consulting_charge_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Out Patient Consulting Charge Item", + "options": "Item" + }, + { + "fieldname": "op_consulting_charge", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Out Patient Consulting Charge" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "inpatient_visit_charge_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Inpatient Visit Charge Item", + "options": "Item" + }, + { + "fieldname": "inpatient_visit_charge", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Inpatient Visit Charge Item" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-01-22 09:35:26.503443", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Appointment Type Service Item", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.py b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.py new file mode 100644 index 00000000000..b2e0e82bad0 --- /dev/null +++ b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class AppointmentTypeServiceItem(Document): + pass diff --git a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py index c3242284674..325c2094fbf 100644 --- a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py +++ b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py @@ -121,6 +121,7 @@ class ClinicalProcedure(Document): stock_entry.stock_entry_type = 'Material Receipt' stock_entry.to_warehouse = self.warehouse + stock_entry.company = self.company expense_account = get_account(None, 'expense_account', 'Healthcare Settings', self.company) for item in self.items: if item.qty > item.actual_qty: diff --git a/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py b/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py index 4ee5f6bad39..fb72073a07f 100644 --- a/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py +++ b/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- + # -*- coding: utf-8 -*- # Copyright (c) 2017, ESS LLP and Contributors # See license.txt from __future__ import unicode_literals @@ -60,6 +60,7 @@ def create_procedure(procedure_template, patient, practitioner): procedure.practitioner = practitioner procedure.consume_stock = procedure_template.allow_stock_consumption procedure.items = procedure_template.items - procedure.warehouse = frappe.db.get_single_value('Stock Settings', 'default_warehouse') + procedure.company = "_Test Company" + procedure.warehouse = "_Test Warehouse - _TC" procedure.submit() return procedure \ No newline at end of file diff --git a/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json b/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json index cb747f95ef8..8162f03f6dc 100644 --- a/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json +++ b/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json @@ -159,6 +159,7 @@ "fieldname": "op_consulting_charge", "fieldtype": "Currency", "label": "Out Patient Consulting Charge", + "mandatory_depends_on": "op_consulting_charge_item", "options": "Currency" }, { @@ -174,7 +175,8 @@ { "fieldname": "inpatient_visit_charge", "fieldtype": "Currency", - "label": "Inpatient Visit Charge" + "label": "Inpatient Visit Charge", + "mandatory_depends_on": "inpatient_visit_charge_item" }, { "depends_on": "eval: !doc.__islocal", @@ -280,7 +282,7 @@ ], "image_field": "image", "links": [], - "modified": "2020-04-06 13:44:24.759623", + "modified": "2021-01-22 10:14:43.187675", "modified_by": "Administrator", "module": "Healthcare", "name": "Healthcare Practitioner", diff --git a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py index 8a918b02751..ea0d1e982d9 100644 --- a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py +++ b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py @@ -139,6 +139,7 @@ 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 diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js index 3d5073b13e7..0354733dfb7 100644 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js @@ -24,11 +24,13 @@ frappe.ui.form.on('Patient Appointment', { }); frm.set_query('practitioner', function() { - return { - filters: { - 'department': frm.doc.department - } - }; + if (frm.doc.department) { + return { + filters: { + 'department': frm.doc.department + } + }; + } }); frm.set_query('service_unit', function() { @@ -140,6 +142,20 @@ frappe.ui.form.on('Patient Appointment', { patient: function(frm) { if (frm.doc.patient) { frm.trigger('toggle_payment_fields'); + frappe.call({ + method: 'frappe.client.get', + args: { + doctype: 'Patient', + name: frm.doc.patient + }, + callback: function (data) { + let age = null; + if (data.message.dob) { + age = calculate_age(data.message.dob); + } + frappe.model.set_value(frm.doctype, frm.docname, 'patient_age', age); + } + }); } else { frm.set_value('patient_name', ''); frm.set_value('patient_sex', ''); @@ -148,6 +164,37 @@ frappe.ui.form.on('Patient Appointment', { } }, + practitioner: function(frm) { + if (frm.doc.practitioner ) { + frm.events.set_payment_details(frm); + } + }, + + appointment_type: function(frm) { + if (frm.doc.appointment_type) { + frm.events.set_payment_details(frm); + } + }, + + set_payment_details: function(frm) { + frappe.db.get_single_value('Healthcare Settings', 'automate_appointment_invoicing').then(val => { + if (val) { + frappe.call({ + method: 'erpnext.healthcare.utils.get_service_item_and_practitioner_charge', + args: { + doc: frm.doc + }, + callback: function(data) { + if (data.message) { + frappe.model.set_value(frm.doctype, frm.docname, 'paid_amount', data.message.practitioner_charge); + frappe.model.set_value(frm.doctype, frm.docname, 'billing_item', data.message.service_item); + } + } + }); + } + }); + }, + therapy_plan: function(frm) { frm.trigger('set_therapy_type_filter'); }, @@ -190,14 +237,18 @@ frappe.ui.form.on('Patient Appointment', { // show payment fields as non-mandatory frm.toggle_display('mode_of_payment', 0); frm.toggle_display('paid_amount', 0); + frm.toggle_display('billing_item', 0); frm.toggle_reqd('mode_of_payment', 0); frm.toggle_reqd('paid_amount', 0); + frm.toggle_reqd('billing_item', 0); } else { // if automated appointment invoicing is disabled, hide fields frm.toggle_display('mode_of_payment', data.message ? 1 : 0); frm.toggle_display('paid_amount', data.message ? 1 : 0); + frm.toggle_display('billing_item', data.message ? 1 : 0); frm.toggle_reqd('mode_of_payment', data.message ? 1 : 0); frm.toggle_reqd('paid_amount', data.message ? 1 :0); + frm.toggle_reqd('billing_item', data.message ? 1 : 0); } } }); @@ -540,57 +591,6 @@ let update_status = function(frm, status){ ); }; -frappe.ui.form.on('Patient Appointment', 'practitioner', function(frm) { - if (frm.doc.practitioner) { - frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'Healthcare Practitioner', - name: frm.doc.practitioner - }, - callback: function (data) { - frappe.model.set_value(frm.doctype, frm.docname, 'department', data.message.department); - frappe.model.set_value(frm.doctype, frm.docname, 'paid_amount', data.message.op_consulting_charge); - frappe.model.set_value(frm.doctype, frm.docname, 'billing_item', data.message.op_consulting_charge_item); - } - }); - } -}); - -frappe.ui.form.on('Patient Appointment', 'patient', function(frm) { - if (frm.doc.patient) { - frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'Patient', - name: frm.doc.patient - }, - callback: function (data) { - let age = null; - if (data.message.dob) { - age = calculate_age(data.message.dob); - } - frappe.model.set_value(frm.doctype,frm.docname, 'patient_age', age); - } - }); - } -}); - -frappe.ui.form.on('Patient Appointment', 'appointment_type', function(frm) { - if (frm.doc.appointment_type) { - frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'Appointment Type', - name: frm.doc.appointment_type - }, - callback: function(data) { - frappe.model.set_value(frm.doctype,frm.docname, 'duration',data.message.default_duration); - } - }); - } -}); - let calculate_age = function(birth) { let ageMS = Date.parse(Date()) - Date.parse(birth); let age = new Date(); diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json index 35600e48092..83c92af36ac 100644 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json @@ -19,19 +19,19 @@ "inpatient_record", "column_break_1", "company", + "practitioner", + "practitioner_name", + "department", "service_unit", + "section_break_12", + "appointment_type", + "duration", "procedure_template", "get_procedure_from_encounter", "procedure_prescription", "therapy_plan", "therapy_type", "get_prescribed_therapies", - "practitioner", - "practitioner_name", - "department", - "section_break_12", - "appointment_type", - "duration", "column_break_17", "appointment_date", "appointment_time", @@ -79,6 +79,7 @@ "set_only_once": 1 }, { + "fetch_from": "appointment_type.default_duration", "fieldname": "duration", "fieldtype": "Int", "in_filter": 1, @@ -144,7 +145,6 @@ "in_standard_filter": 1, "label": "Healthcare Practitioner", "options": "Healthcare Practitioner", - "read_only": 1, "reqd": 1, "search_index": 1, "set_only_once": 1 @@ -158,7 +158,6 @@ "in_standard_filter": 1, "label": "Department", "options": "Medical Department", - "read_only": 1, "search_index": 1, "set_only_once": 1 }, @@ -227,12 +226,14 @@ "fieldname": "mode_of_payment", "fieldtype": "Link", "label": "Mode of Payment", - "options": "Mode of Payment" + "options": "Mode of Payment", + "read_only_depends_on": "invoiced" }, { "fieldname": "paid_amount", "fieldtype": "Currency", - "label": "Paid Amount" + "label": "Paid Amount", + "read_only_depends_on": "invoiced" }, { "fieldname": "column_break_2", @@ -302,7 +303,8 @@ "fieldname": "therapy_plan", "fieldtype": "Link", "label": "Therapy Plan", - "options": "Therapy Plan" + "options": "Therapy Plan", + "set_only_once": 1 }, { "fieldname": "ref_sales_invoice", @@ -347,7 +349,7 @@ } ], "links": [], - "modified": "2020-12-16 13:16:58.578503", + "modified": "2021-02-08 13:13:15.116833", "modified_by": "Administrator", "module": "Healthcare", "name": "Patient Appointment", diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py index b05c673d84c..649f16dbf15 100755 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py @@ -26,6 +26,7 @@ class PatientAppointment(Document): def after_insert(self): self.update_prescription_details() + self.set_payment_details() invoice_appointment(self) self.update_fee_validity() send_confirmation_msg(self) @@ -85,6 +86,13 @@ class PatientAppointment(Document): def set_appointment_datetime(self): self.appointment_datetime = "%s %s" % (self.appointment_date, self.appointment_time or "00:00:00") + def set_payment_details(self): + if frappe.db.get_single_value('Healthcare Settings', 'automate_appointment_invoicing'): + details = get_service_item_and_practitioner_charge(self) + self.db_set('billing_item', details.get('service_item')) + if not self.paid_amount: + self.db_set('paid_amount', details.get('practitioner_charge')) + def validate_customer_created(self): if frappe.db.get_single_value('Healthcare Settings', 'automate_appointment_invoicing'): if not frappe.db.get_value('Patient', self.patient, 'customer'): @@ -148,31 +156,37 @@ def invoice_appointment(appointment_doc): fee_validity = None if automate_invoicing and not appointment_invoiced and not fee_validity: - sales_invoice = frappe.new_doc('Sales Invoice') - sales_invoice.patient = appointment_doc.patient - sales_invoice.customer = frappe.get_value('Patient', appointment_doc.patient, 'customer') - sales_invoice.appointment = appointment_doc.name - sales_invoice.due_date = getdate() - sales_invoice.company = appointment_doc.company - sales_invoice.debit_to = get_receivable_account(appointment_doc.company) + create_sales_invoice(appointment_doc) - item = sales_invoice.append('items', {}) - item = get_appointment_item(appointment_doc, item) - # Add payments if payment details are supplied else proceed to create invoice as Unpaid - if appointment_doc.mode_of_payment and appointment_doc.paid_amount: - sales_invoice.is_pos = 1 - payment = sales_invoice.append('payments', {}) - payment.mode_of_payment = appointment_doc.mode_of_payment - payment.amount = appointment_doc.paid_amount +def create_sales_invoice(appointment_doc): + sales_invoice = frappe.new_doc('Sales Invoice') + sales_invoice.patient = appointment_doc.patient + sales_invoice.customer = frappe.get_value('Patient', appointment_doc.patient, 'customer') + sales_invoice.appointment = appointment_doc.name + sales_invoice.due_date = getdate() + sales_invoice.company = appointment_doc.company + sales_invoice.debit_to = get_receivable_account(appointment_doc.company) - sales_invoice.set_missing_values(for_validate=True) - sales_invoice.flags.ignore_mandatory = True - sales_invoice.save(ignore_permissions=True) - sales_invoice.submit() - frappe.msgprint(_('Sales Invoice {0} created').format(sales_invoice.name), alert=True) - frappe.db.set_value('Patient Appointment', appointment_doc.name, 'invoiced', 1) - frappe.db.set_value('Patient Appointment', appointment_doc.name, 'ref_sales_invoice', sales_invoice.name) + item = sales_invoice.append('items', {}) + item = get_appointment_item(appointment_doc, item) + + # Add payments if payment details are supplied else proceed to create invoice as Unpaid + if appointment_doc.mode_of_payment and appointment_doc.paid_amount: + sales_invoice.is_pos = 1 + payment = sales_invoice.append('payments', {}) + payment.mode_of_payment = appointment_doc.mode_of_payment + payment.amount = appointment_doc.paid_amount + + sales_invoice.set_missing_values(for_validate=True) + sales_invoice.flags.ignore_mandatory = True + sales_invoice.save(ignore_permissions=True) + sales_invoice.submit() + frappe.msgprint(_('Sales Invoice {0} created').format(sales_invoice.name), alert=True) + frappe.db.set_value('Patient Appointment', appointment_doc.name, { + 'invoiced': 1, + 'ref_sales_invoice': sales_invoice.name + }) def check_is_new_patient(patient, name=None): @@ -187,13 +201,14 @@ def check_is_new_patient(patient, name=None): def get_appointment_item(appointment_doc, item): - service_item, practitioner_charge = get_service_item_and_practitioner_charge(appointment_doc) - item.item_code = service_item + details = get_service_item_and_practitioner_charge(appointment_doc) + charge = appointment_doc.paid_amount or details.get('practitioner_charge') + item.item_code = details.get('service_item') item.description = _('Consulting Charges: {0}').format(appointment_doc.practitioner) item.income_account = get_income_account(appointment_doc.practitioner, appointment_doc.company) item.cost_center = frappe.get_cached_value('Company', appointment_doc.company, 'cost_center') - item.rate = practitioner_charge - item.amount = practitioner_charge + item.rate = charge + item.amount = charge item.qty = 1 item.reference_dt = 'Patient Appointment' item.reference_dn = appointment_doc.name diff --git a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py index f7ec6f58fc5..2bb8a53c454 100644 --- a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py @@ -32,7 +32,8 @@ class TestPatientAppointment(unittest.TestCase): patient, medical_department, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) appointment = create_appointment(patient, practitioner, add_days(nowdate(), 4), invoice = 1) - self.assertEqual(frappe.db.get_value('Patient Appointment', appointment.name, 'invoiced'), 1) + appointment.reload() + self.assertEqual(appointment.invoiced, 1) encounter = make_encounter(appointment.name) self.assertTrue(encounter) self.assertEqual(encounter.company, appointment.company) @@ -41,7 +42,7 @@ class TestPatientAppointment(unittest.TestCase): # invoiced flag mapped from appointment self.assertEqual(encounter.invoiced, frappe.db.get_value('Patient Appointment', appointment.name, 'invoiced')) - def test_invoicing(self): + def test_auto_invoicing(self): patient, medical_department, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 0) @@ -57,6 +58,50 @@ class TestPatientAppointment(unittest.TestCase): self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'patient'), appointment.patient) self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'paid_amount'), appointment.paid_amount) + def test_auto_invoicing_based_on_department(self): + patient, medical_department, practitioner = create_healthcare_docs() + frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) + frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) + appointment_type = create_appointment_type() + + appointment = create_appointment(patient, practitioner, add_days(nowdate(), 2), + invoice=1, appointment_type=appointment_type.name, department='_Test Medical Department') + appointment.reload() + + self.assertEqual(appointment.invoiced, 1) + self.assertEqual(appointment.billing_item, 'HLC-SI-001') + self.assertEqual(appointment.paid_amount, 200) + + sales_invoice_name = frappe.db.get_value('Sales Invoice Item', {'reference_dn': appointment.name}, 'parent') + self.assertTrue(sales_invoice_name) + self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'paid_amount'), appointment.paid_amount) + + def test_auto_invoicing_according_to_appointment_type_charge(self): + patient, medical_department, practitioner = create_healthcare_docs() + frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) + frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) + + item = create_healthcare_service_items() + items = [{ + 'op_consulting_charge_item': item, + 'op_consulting_charge': 300 + }] + appointment_type = create_appointment_type(args={ + 'name': 'Generic Appointment Type charge', + 'items': items + }) + + appointment = create_appointment(patient, practitioner, add_days(nowdate(), 2), + invoice=1, appointment_type=appointment_type.name) + appointment.reload() + + self.assertEqual(appointment.invoiced, 1) + self.assertEqual(appointment.billing_item, item) + self.assertEqual(appointment.paid_amount, 300) + + sales_invoice_name = frappe.db.get_value('Sales Invoice Item', {'reference_dn': appointment.name}, 'parent') + self.assertTrue(sales_invoice_name) + def test_appointment_cancel(self): patient, medical_department, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 1) @@ -178,14 +223,15 @@ def create_encounter(appointment): encounter.submit() return encounter -def create_appointment(patient, practitioner, appointment_date, invoice=0, procedure_template=0, service_unit=None, save=1): +def create_appointment(patient, practitioner, appointment_date, invoice=0, procedure_template=0, + service_unit=None, appointment_type=None, save=1, department=None): item = create_healthcare_service_items() frappe.db.set_value('Healthcare Settings', None, 'inpatient_visit_charge_item', item) frappe.db.set_value('Healthcare Settings', None, 'op_consulting_charge_item', item) appointment = frappe.new_doc('Patient Appointment') appointment.patient = patient appointment.practitioner = practitioner - appointment.department = '_Test Medical Department' + appointment.department = department or '_Test Medical Department' appointment.appointment_date = appointment_date appointment.company = '_Test Company' appointment.duration = 15 @@ -193,7 +239,8 @@ def create_appointment(patient, practitioner, appointment_date, invoice=0, proce appointment.service_unit = service_unit if invoice: appointment.mode_of_payment = 'Cash' - appointment.paid_amount = 500 + if appointment_type: + appointment.appointment_type = appointment_type if procedure_template: appointment.procedure_template = create_clinical_procedure_template().get('name') if save: @@ -223,4 +270,29 @@ def create_clinical_procedure_template(): template.description = 'Knee Surgery and Rehab' template.rate = 50000 template.save() - return template \ No newline at end of file + return template + +def create_appointment_type(args=None): + if not args: + args = frappe.local.form_dict + + name = args.get('name') or 'Test Appointment Type wise Charge' + + if frappe.db.exists('Appointment Type', name): + return frappe.get_doc('Appointment Type', name) + + else: + item = create_healthcare_service_items() + items = [{ + 'medical_department': '_Test Medical Department', + 'op_consulting_charge_item': item, + 'op_consulting_charge': 200 + }] + return frappe.get_doc({ + 'doctype': 'Appointment Type', + 'appointment_type': args.get('name') or 'Test Appointment Type wise Charge', + 'default_duration': args.get('default_duration') or 20, + 'color': args.get('color') or '#7575ff', + 'price_list': args.get('price_list') or frappe.db.get_value("Price List", {"selling": 1}), + 'items': args.get('items') or items + }).insert() \ No newline at end of file diff --git a/erpnext/healthcare/utils.py b/erpnext/healthcare/utils.py index 40f7f9cabd2..510ac9e475a 100644 --- a/erpnext/healthcare/utils.py +++ b/erpnext/healthcare/utils.py @@ -5,9 +5,11 @@ from __future__ import unicode_literals import math import frappe +import json from frappe import _ from frappe.utils.formatters import format_value from frappe.utils import time_diff_in_hours, rounded +from six import string_types from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_income_account from erpnext.healthcare.doctype.fee_validity.fee_validity import create_fee_validity from erpnext.healthcare.doctype.lab_test.lab_test import create_multiple @@ -64,7 +66,9 @@ def get_appointments_to_invoice(patient, company): income_account = None service_item = None if appointment.practitioner: - service_item, practitioner_charge = get_service_item_and_practitioner_charge(appointment) + details = get_service_item_and_practitioner_charge(appointment) + service_item = details.get('service_item') + practitioner_charge = details.get('practitioner_charge') income_account = get_income_account(appointment.practitioner, appointment.company) appointments_to_invoice.append({ 'reference_type': 'Patient Appointment', @@ -97,7 +101,9 @@ def get_encounters_to_invoice(patient, company): frappe.db.get_single_value('Healthcare Settings', 'do_not_bill_inpatient_encounters'): continue - service_item, practitioner_charge = get_service_item_and_practitioner_charge(encounter) + details = get_service_item_and_practitioner_charge(encounter) + service_item = details.get('service_item') + practitioner_charge = details.get('practitioner_charge') income_account = get_income_account(encounter.practitioner, encounter.company) encounters_to_invoice.append({ @@ -173,7 +179,7 @@ def get_clinical_procedures_to_invoice(patient, company): if procedure.invoice_separately_as_consumables and procedure.consume_stock \ and procedure.status == 'Completed' and not procedure.consumption_invoiced: - service_item = get_healthcare_service_item('clinical_procedure_consumable_item') + service_item = frappe.db.get_single_value('Healthcare Settings', 'clinical_procedure_consumable_item') if not service_item: msg = _('Please Configure Clinical Procedure Consumable Item in ') msg += '''Healthcare Settings''' @@ -304,24 +310,50 @@ def get_therapy_sessions_to_invoice(patient, company): return therapy_sessions_to_invoice - +@frappe.whitelist() def get_service_item_and_practitioner_charge(doc): + if isinstance(doc, string_types): + doc = json.loads(doc) + doc = frappe.get_doc(doc) + + service_item = None + practitioner_charge = None + department = doc.medical_department if doc.doctype == 'Patient Encounter' else doc.department + is_inpatient = doc.inpatient_record - if is_inpatient: - service_item = get_practitioner_service_item(doc.practitioner, 'inpatient_visit_charge_item') + + if doc.get('appointment_type'): + service_item, practitioner_charge = get_appointment_type_service_item(doc.appointment_type, department, is_inpatient) + + if not service_item and not practitioner_charge: + service_item, practitioner_charge = get_practitioner_service_item(doc.practitioner, is_inpatient) if not service_item: - service_item = get_healthcare_service_item('inpatient_visit_charge_item') - else: - service_item = get_practitioner_service_item(doc.practitioner, 'op_consulting_charge_item') - if not service_item: - service_item = get_healthcare_service_item('op_consulting_charge_item') + service_item = get_healthcare_service_item(is_inpatient) + if not service_item: throw_config_service_item(is_inpatient) - practitioner_charge = get_practitioner_charge(doc.practitioner, is_inpatient) if not practitioner_charge: throw_config_practitioner_charge(is_inpatient, doc.practitioner) + return {'service_item': service_item, 'practitioner_charge': practitioner_charge} + + +def get_appointment_type_service_item(appointment_type, department, is_inpatient): + from erpnext.healthcare.doctype.appointment_type.appointment_type import get_service_item_based_on_department + + item_list = get_service_item_based_on_department(appointment_type, department) + service_item = None + practitioner_charge = None + + if item_list: + if is_inpatient: + service_item = item_list.get('inpatient_visit_charge_item') + practitioner_charge = item_list.get('inpatient_visit_charge') + else: + service_item = item_list.get('op_consulting_charge_item') + practitioner_charge = item_list.get('op_consulting_charge') + return service_item, practitioner_charge @@ -345,12 +377,27 @@ def throw_config_practitioner_charge(is_inpatient, practitioner): frappe.throw(msg, title=_('Missing Configuration')) -def get_practitioner_service_item(practitioner, service_item_field): - return frappe.db.get_value('Healthcare Practitioner', practitioner, service_item_field) +def get_practitioner_service_item(practitioner, is_inpatient): + service_item = None + practitioner_charge = None + + if is_inpatient: + service_item, practitioner_charge = frappe.db.get_value('Healthcare Practitioner', practitioner, ['inpatient_visit_charge_item', 'inpatient_visit_charge']) + else: + service_item, practitioner_charge = frappe.db.get_value('Healthcare Practitioner', practitioner, ['op_consulting_charge_item', 'op_consulting_charge']) + + return service_item, practitioner_charge -def get_healthcare_service_item(service_item_field): - return frappe.db.get_single_value('Healthcare Settings', service_item_field) +def get_healthcare_service_item(is_inpatient): + service_item = None + + if is_inpatient: + service_item = frappe.db.get_single_value('Healthcare Settings', 'inpatient_visit_charge_item') + else: + service_item = frappe.db.get_single_value('Healthcare Settings', 'op_consulting_charge_item') + + return service_item def get_practitioner_charge(practitioner, is_inpatient): @@ -381,7 +428,8 @@ def set_invoiced(item, method, ref_invoice=None): invoiced = True if item.reference_dt == 'Clinical Procedure': - if get_healthcare_service_item('clinical_procedure_consumable_item') == item.item_code: + service_item = frappe.db.get_single_value('Healthcare Settings', 'clinical_procedure_consumable_item') + if service_item == item.item_code: frappe.db.set_value(item.reference_dt, item.reference_dn, 'consumption_invoiced', invoiced) else: frappe.db.set_value(item.reference_dt, item.reference_dn, 'invoiced', invoiced) @@ -403,7 +451,8 @@ def set_invoiced(item, method, ref_invoice=None): def validate_invoiced_on_submit(item): - if item.reference_dt == 'Clinical Procedure' and get_healthcare_service_item('clinical_procedure_consumable_item') == item.item_code: + if item.reference_dt == 'Clinical Procedure' and \ + frappe.db.get_single_value('Healthcare Settings', 'clinical_procedure_consumable_item') == item.item_code: is_invoiced = frappe.db.get_value(item.reference_dt, item.reference_dn, 'consumption_invoiced') else: is_invoiced = frappe.db.get_value(item.reference_dt, item.reference_dn, 'invoiced') diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py index f4b214adc3c..b88daaa34f9 100644 --- a/erpnext/hr/doctype/employee/test_employee.py +++ b/erpnext/hr/doctype/employee/test_employee.py @@ -16,6 +16,7 @@ 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 diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 06a8e1987d3..00e8c5418a0 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -94,11 +94,11 @@ class TestWorkOrder(unittest.TestCase): wo_order = make_wo_order_test_record(item="_Test FG Item", qty=2, source_warehouse=warehouse, skip_transfer=1) - bin1_on_submit = get_bin(item, warehouse) + reserved_qty_on_submission = cint(get_bin(item, warehouse).reserved_qty_for_production) # reserved qty for production is updated - self.assertEqual(cint(bin1_at_start.reserved_qty_for_production) + 2, - cint(bin1_on_submit.reserved_qty_for_production)) + self.assertEqual(cint(bin1_at_start.reserved_qty_for_production) + 2, reserved_qty_on_submission) + test_stock_entry.make_stock_entry(item_code="_Test Item", target=warehouse, qty=100, basic_rate=100) @@ -109,9 +109,9 @@ class TestWorkOrder(unittest.TestCase): s.submit() bin1_at_completion = get_bin(item, warehouse) - + self.assertEqual(cint(bin1_at_completion.reserved_qty_for_production), - cint(bin1_on_submit.reserved_qty_for_production) - 1) + reserved_qty_on_submission - 1) def test_production_item(self): wo_order = make_wo_order_test_record(item="_Test FG Item", qty=1, do_not_save=True) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6e28865a549..80e2f1c01af 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -677,7 +677,7 @@ erpnext.patches.v13_0.move_tax_slabs_from_payroll_period_to_income_tax_slab #123 erpnext.patches.v12_0.fix_quotation_expired_status erpnext.patches.v12_0.update_appointment_reminder_scheduler_entry erpnext.patches.v12_0.rename_pos_closing_doctype -erpnext.patches.v13_0.replace_pos_payment_mode_table +erpnext.patches.v13_0.replace_pos_payment_mode_table #2020-12-29 erpnext.patches.v12_0.remove_duplicate_leave_ledger_entries #2020-05-22 erpnext.patches.v13_0.patch_to_fix_reverse_linking_in_additional_salary_encashment_and_incentive execute:frappe.reload_doc("HR", "doctype", "Employee Advance") @@ -741,6 +741,7 @@ erpnext.patches.v13_0.update_member_email_address erpnext.patches.v13_0.update_custom_fields_for_shopify erpnext.patches.v13_0.updates_for_multi_currency_payroll erpnext.patches.v13_0.create_leave_policy_assignment_based_on_employee_current_leave_policy +erpnext.patches.v13_0.update_pos_closing_entry_in_merge_log erpnext.patches.v13_0.add_po_to_global_search erpnext.patches.v13_0.update_returned_qty_in_pr_dn erpnext.patches.v13_0.create_uae_pos_invoice_fields @@ -749,3 +750,6 @@ erpnext.patches.v13_0.set_company_in_leave_ledger_entry erpnext.patches.v13_0.convert_qi_parameter_to_link_field erpnext.patches.v13_0.setup_patient_history_settings_for_standard_doctypes erpnext.patches.v13_0.add_naming_series_to_old_projects # 1-02-2021 +erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl +erpnext.patches.v12_0.add_state_code_for_ladakh +erpnext.patches.v13_0.update_vehicle_no_reqd_condition diff --git a/erpnext/patches/v12_0/add_state_code_for_ladakh.py b/erpnext/patches/v12_0/add_state_code_for_ladakh.py new file mode 100644 index 00000000000..d41101cc46a --- /dev/null +++ b/erpnext/patches/v12_0/add_state_code_for_ladakh.py @@ -0,0 +1,16 @@ +import frappe +from erpnext.regional.india import states + +def execute(): + + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = ['Address-gst_state', 'Tax Category-gst_state'] + + # Update options in gst_state custom fields + for field in custom_fields: + gst_state_field = frappe.get_doc('Custom Field', field) + gst_state_field.options = '\n'.join(states) + gst_state_field.save() diff --git a/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py new file mode 100644 index 00000000000..ca04e8acc22 --- /dev/null +++ b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py @@ -0,0 +1,50 @@ +import frappe +from frappe import _ +from frappe.utils import getdate, get_time +from erpnext.stock.stock_ledger import update_entries_after +from erpnext.accounts.utils import update_gl_entries_after + +def execute(): + frappe.reload_doc('stock', 'doctype', 'repost_item_valuation') + + reposting_project_deployed_on = frappe.db.get_value("DocType", "Repost Item Valuation", "creation") + + data = frappe.db.sql(''' + SELECT + name, item_code, warehouse, voucher_type, voucher_no, posting_date, posting_time + FROM + `tabStock Ledger Entry` + WHERE + creation > %s + and is_cancelled = 0 + ORDER BY timestamp(posting_date, posting_time) asc, creation asc + ''', reposting_project_deployed_on, as_dict=1) + + frappe.db.auto_commit_on_many_writes = 1 + print("Reposting Stock Ledger Entries...") + total_sle = len(data) + i = 0 + for d in data: + update_entries_after({ + "item_code": d.item_code, + "warehouse": d.warehouse, + "posting_date": d.posting_date, + "posting_time": d.posting_time, + "voucher_type": d.voucher_type, + "voucher_no": d.voucher_no, + "sle_id": d.name + }, allow_negative_stock=True) + + i += 1 + if i%100 == 0: + print(i, "/", total_sle) + + + print("Reposting General Ledger Entries...") + posting_date = getdate(reposting_project_deployed_on) + posting_time = get_time(reposting_project_deployed_on) + + for row in frappe.get_all('Company', filters= {'enable_perpetual_inventory': 1}): + update_gl_entries_after(posting_date, posting_time, company=row.name) + + frappe.db.auto_commit_on_many_writes = 0 diff --git a/erpnext/patches/v13_0/replace_pos_payment_mode_table.py b/erpnext/patches/v13_0/replace_pos_payment_mode_table.py index 1ca211bf1be..7cb264830ab 100644 --- a/erpnext/patches/v13_0/replace_pos_payment_mode_table.py +++ b/erpnext/patches/v13_0/replace_pos_payment_mode_table.py @@ -6,12 +6,10 @@ from __future__ import unicode_literals import frappe def execute(): - frappe.reload_doc("accounts", "doctype", "POS Payment Method") + frappe.reload_doc("accounts", "doctype", "pos_payment_method") pos_profiles = frappe.get_all("POS Profile") for pos_profile in pos_profiles: - if not pos_profile.get("payments"): return - payments = frappe.db.sql(""" select idx, parentfield, parenttype, parent, mode_of_payment, `default` from `tabSales Invoice Payment` where parent=%s """, pos_profile.name, as_dict=1) diff --git a/erpnext/patches/v13_0/update_pos_closing_entry_in_merge_log.py b/erpnext/patches/v13_0/update_pos_closing_entry_in_merge_log.py new file mode 100644 index 00000000000..42bca7c53a6 --- /dev/null +++ b/erpnext/patches/v13_0/update_pos_closing_entry_in_merge_log.py @@ -0,0 +1,25 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc("accounts", "doctype", "POS Invoice Merge Log") + frappe.reload_doc("accounts", "doctype", "POS Closing Entry") + if frappe.db.count('POS Invoice Merge Log'): + frappe.db.sql(''' + UPDATE + `tabPOS Invoice Merge Log` log, `tabPOS Invoice Reference` log_ref + SET + log.pos_closing_entry = ( + SELECT clo_ref.parent FROM `tabPOS Invoice Reference` clo_ref + WHERE clo_ref.pos_invoice = log_ref.pos_invoice + AND clo_ref.parenttype = 'POS Closing Entry' + ) + WHERE + log_ref.parent = log.name + ''') + + frappe.db.sql('''UPDATE `tabPOS Closing Entry` SET status = 'Submitted' where docstatus = 1''') + frappe.db.sql('''UPDATE `tabPOS Closing Entry` SET status = 'Cancelled' where docstatus = 2''') diff --git a/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py b/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py new file mode 100644 index 00000000000..c26cddbe4e5 --- /dev/null +++ b/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py @@ -0,0 +1,9 @@ +import frappe + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + if frappe.db.exists('Custom Field', { 'fieldname': 'vehicle_no' }): + frappe.db.set_value('Custom Field', { 'fieldname': 'vehicle_no' }, 'mandatory_depends_on', '') diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js index 0dcea88c898..7ead0b3882b 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js @@ -342,3 +342,11 @@ let render_employee_attendance = function (frm, data) { }) ); }; + +frappe.ui.form.on('Payroll Employee Detail', { + employee: function(frm) { + if (!frm.doc.payroll_frequency) { + frappe.throw(__("Please set a Payroll Frequency")); + } + } +}); diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index e098ec79b0f..9e68df99ebb 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -41,40 +41,6 @@ class TestPayrollEntry(unittest.TestCase): make_payroll_entry(start_date=dates.start_date, end_date=dates.end_date, payable_account=company_doc.default_payroll_payable_account, currency=company_doc.default_currency) - def test_multi_currency_payroll_entry(self): # pylint: disable=no-self-use - company = erpnext.get_default_company() - employee = make_employee("test_muti_currency_employee@payroll.com", company=company) - for data in frappe.get_all('Salary Component', fields = ["name"]): - if not frappe.db.get_value('Salary Component Account', - {'parent': data.name, 'company': company}, 'name'): - get_salary_component_account(data.name) - - company_doc = frappe.get_doc('Company', company) - salary_structure = make_salary_structure("_Test Multi Currency Salary Structure", "Monthly", company=company, currency='USD') - create_salary_structure_assignment(employee, salary_structure.name, company=company) - frappe.db.sql("""delete from `tabSalary Slip` where employee=%s""",(frappe.db.get_value("Employee", {"user_id": "test_muti_currency_employee@payroll.com"}))) - salary_slip = get_salary_slip("test_muti_currency_employee@payroll.com", "Monthly", "_Test Multi Currency Salary Structure") - dates = get_start_end_dates('Monthly', nowdate()) - payroll_entry = make_payroll_entry(start_date=dates.start_date, end_date=dates.end_date, - payable_account=company_doc.default_payroll_payable_account, currency='USD', exchange_rate=70) - payroll_entry.make_payment_entry() - - salary_slip.load_from_db() - - payroll_je = salary_slip.journal_entry - payroll_je_doc = frappe.get_doc('Journal Entry', payroll_je) - - self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_debit) - self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_credit) - - payment_entry = frappe.db.sql(''' - Select ifnull(sum(je.total_debit),0) as total_debit, ifnull(sum(je.total_credit),0) as total_credit from `tabJournal Entry` je, `tabJournal Entry Account` jea - Where je.name = jea.parent - And jea.reference_name = %s - ''', (payroll_entry.name), as_dict=1) - - self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_debit) - self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_credit) def test_payroll_entry_with_employee_cost_center(self): # pylint: disable=no-self-use for data in frappe.get_all('Salary Component', fields = ["name"]): diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 2d3bc57900d..60aff02b38d 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -1103,10 +1103,10 @@ class SalarySlip(TransactionBase): self.calculate_total_for_salary_slip_based_on_timesheet() else: self.total_deduction = 0.0 - if self.earnings: + if hasattr(self, "earnings"): for earning in self.earnings: self.gross_pay += flt(earning.amount, earning.precision("amount")) - if self.deductions: + if hasattr(self, "deductions"): for deduction in self.deductions: self.total_deduction += flt(deduction.amount, deduction.precision("amount")) self.net_pay = flt(self.gross_pay) - flt(self.total_deduction) - flt(self.total_loan_repayment) diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index a2a723dd774..c96386611bd 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -191,7 +191,6 @@ erpnext.buying.BuyingController = erpnext.TransactionController.extend({ item.rejected_qty = flt(item.received_qty - item.qty, precision("rejected_qty", item)); item.received_stock_qty = flt(item.conversion_factor, precision("conversion_factor", item)) * flt(item.received_qty); } - this._super(doc, cdt, cdn); }, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 9627600a17a..123d9988387 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -40,7 +40,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ cur_frm.cscript.set_gross_profit(item); cur_frm.cscript.calculate_taxes_and_totals(); - + cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn); }); @@ -1121,6 +1121,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ } }); } + me.calculate_stock_uom_rate(doc, cdt, cdn); }, conversion_factor: function(doc, cdt, cdn, dont_fetch_price_list_rate) { @@ -1141,6 +1142,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ frappe.meta.has_field(doc.doctype, "price_list_currency")) { this.apply_price_list(item, true); } + this.calculate_stock_uom_rate(doc, cdt, cdn); } }, @@ -1161,9 +1163,15 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ qty: function(doc, cdt, cdn) { let item = frappe.get_doc(cdt, cdn); this.conversion_factor(doc, cdt, cdn, true); + this.calculate_stock_uom_rate(doc, cdt, cdn); this.apply_pricing_rule(item, true); }, + calculate_stock_uom_rate: function(doc, cdt, cdn) { + let item = frappe.get_doc(cdt, cdn); + item.stock_uom_rate = flt(item.rate)/flt(item.conversion_factor); + refresh_field("stock_uom_rate", item.name, item.parentfield); + }, service_stop_date: function(frm, cdt, cdn) { var child = locals[cdt][cdn]; @@ -1274,7 +1282,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ this.frm.set_currency_labels(["base_rate", "base_net_rate", "base_price_list_rate", "base_amount", "base_net_amount"], company_currency, "items"); - this.frm.set_currency_labels(["rate", "net_rate", "price_list_rate", "amount", "net_amount"], + this.frm.set_currency_labels(["rate", "net_rate", "price_list_rate", "amount", "net_amount", "stock_uom_rate"], this.frm.doc.currency, "items"); if(this.frm.fields_dict["operations"]) { diff --git a/erpnext/regional/india/__init__.py b/erpnext/regional/india/__init__.py index d6221a80aa2..378b735e078 100644 --- a/erpnext/regional/india/__init__.py +++ b/erpnext/regional/india/__init__.py @@ -20,6 +20,7 @@ states = [ 'Jharkhand', 'Karnataka', 'Kerala', + 'Ladakh', 'Lakshadweep Islands', 'Madhya Pradesh', 'Maharashtra', @@ -59,6 +60,7 @@ state_numbers = { "Jharkhand": "20", "Karnataka": "29", "Kerala": "32", + "Ladakh": "38", "Lakshadweep Islands": "31", "Madhya Pradesh": "23", "Maharashtra": "27", @@ -80,4 +82,4 @@ state_numbers = { "West Bengal": "19", } -number_state_mapping = {v: k for k, v in iteritems(state_numbers)} \ No newline at end of file +number_state_mapping = {v: k for k, v in iteritems(state_numbers)} diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js index a5306839b95..e8a7c30e19a 100644 --- a/erpnext/regional/india/e_invoice/einvoice.js +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -188,7 +188,6 @@ const get_ewaybill_fields = (frm) => { 'fieldname': 'vehicle_no', 'label': 'Vehicle No', 'fieldtype': 'Data', - 'depends_on': 'eval:(doc.mode_of_transport === "Road")', 'default': frm.doc.vehicle_no }, { diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 0f604efa1f2..438ec79ed16 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') - - if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction: return + no_taxes_applied = not doc.get('taxes') + + if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction or no_taxes_applied: + return if doc.docstatus == 0 and doc._action == 'save': if doc.irn: @@ -35,7 +37,7 @@ def validate_einvoice_fields(doc): elif doc.docstatus == 1 and doc._action == 'submit' and not doc.irn: frappe.throw(_('You must generate IRN before submitting the document.'), title=_('Missing IRN')) - elif doc.docstatus == 2 and doc._action == 'cancel' and not doc.irn_cancelled: + elif doc.irn and doc.docstatus == 2 and doc._action == 'cancel' and not doc.irn_cancelled: frappe.throw(_('You must cancel IRN before cancelling the document.'), title=_('Cancel Not Allowed')) def raise_document_name_too_long_error(): @@ -158,10 +160,10 @@ def get_item_list(invoice): item.update(d.as_dict()) item.sr_no = d.idx - item.description = d.item_name.replace('"', '\\"') + item.description = json.dumps(d.item_name)[1:-1] item.qty = abs(item.qty) - item.discount_amount = abs(item.discount_amount * item.qty) + item.discount_amount = 0 item.unit_rate = abs(item.base_net_amount / item.qty) item.gross_amount = abs(item.base_net_amount) item.taxable_value = abs(item.base_net_amount) @@ -221,11 +223,12 @@ def get_invoice_value_details(invoice): if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: invoice_value_details.base_total = abs(invoice.base_total) + invoice_value_details.invoice_discount_amt = invoice.base_discount_amount else: invoice_value_details.base_total = abs(invoice.base_net_total) + # since tax already considers discount amount + invoice_value_details.invoice_discount_amt = 0 - # since tax already considers discount amount - invoice_value_details.invoice_discount_amt = 0 # invoice.base_discount_amount invoice_value_details.round_off = invoice.base_rounding_adjustment invoice_value_details.base_grand_total = abs(invoice.base_rounded_total) or abs(invoice.base_grand_total) invoice_value_details.grand_total = abs(invoice.rounded_total) or abs(invoice.grand_total) @@ -302,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') @@ -443,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 @@ -813,4 +818,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/india/gst_state_code_data.json b/erpnext/regional/india/gst_state_code_data.json index ff88e0f9d6c..8481c279728 100644 --- a/erpnext/regional/india/gst_state_code_data.json +++ b/erpnext/regional/india/gst_state_code_data.json @@ -168,5 +168,10 @@ "state_number": "37", "state_code": "AD", "state_name": "Andhra Pradesh (New)" + }, + { + "state_number": "38", + "state_code": "LA", + "state_name": "Ladakh" } ] diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 96dc3f728d9..09b04ff367f 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -236,6 +236,7 @@ class Gstr1Report(object): self.cgst_sgst_invoices = [] unidentified_gst_accounts = [] + unidentified_gst_accounts_invoice = [] for parent, account, item_wise_tax_detail, tax_amount in self.tax_details: if account in self.gst_accounts.cess_account: self.invoice_cess.setdefault(parent, tax_amount) @@ -251,6 +252,7 @@ class Gstr1Report(object): if not (cgst_or_sgst or account in self.gst_accounts.igst_account): if "gst" in account.lower() and account not in unidentified_gst_accounts: unidentified_gst_accounts.append(account) + unidentified_gst_accounts_invoice.append(parent) continue for item_code, tax_amounts in item_wise_tax_detail.items(): @@ -273,7 +275,7 @@ class Gstr1Report(object): # Build itemised tax for export invoices where tax table is blank for invoice, items in iteritems(self.invoice_items): - if invoice not in self.items_based_on_tax_rate \ + if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \ and frappe.db.get_value(self.doctype, invoice, "export_type") == "Without Payment of Tax": self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys()) diff --git a/erpnext/regional/united_arab_emirates/setup.py b/erpnext/regional/united_arab_emirates/setup.py index 013ae5cf73c..776a82c7306 100644 --- a/erpnext/regional/united_arab_emirates/setup.py +++ b/erpnext/regional/united_arab_emirates/setup.py @@ -110,9 +110,11 @@ def make_custom_fields(): 'Purchase Order': purchase_invoice_fields + invoice_fields, 'Purchase Receipt': purchase_invoice_fields + invoice_fields, 'Sales Invoice': sales_invoice_fields + invoice_fields, + 'POS Invoice': sales_invoice_fields + invoice_fields, 'Sales Order': sales_invoice_fields + invoice_fields, 'Delivery Note': sales_invoice_fields + invoice_fields, 'Sales Invoice Item': invoice_item_fields + delivery_date_field + [is_zero_rated, is_exempt], + 'POS Invoice Item': invoice_item_fields + delivery_date_field + [is_zero_rated, is_exempt], 'Purchase Invoice Item': invoice_item_fields, 'Sales Order Item': invoice_item_fields, 'Delivery Note Item': invoice_item_fields, diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 59ae7b23239..a6785f709a1 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -47,6 +47,7 @@ "base_amount", "base_net_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_43", "valuation_rate", @@ -634,12 +635,20 @@ "print_hide": 1, "read_only": 1, "report_hide": 1 + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "options": "currency", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-05-19 20:48:43.222229", + "modified": "2021-01-30 21:39:40.174551", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 1516dd6d954..e56129170c1 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -180,6 +180,7 @@ class SalesOrder(SellingController): update_coupon_code_count(self.coupon_code,'used') def on_cancel(self): + self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') super(SalesOrder, self).on_cancel() # Cannot cancel closed SO diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index e259367e58b..52a0174798e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -17,6 +17,18 @@ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_prod from erpnext.stock.doctype.item.test_item import make_item class TestSalesOrder(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.unlink_setting = int(frappe.db.get_value("Accounts Settings", "Accounts Settings", + "unlink_advance_payment_on_cancelation_of_order")) + + @classmethod + def tearDownClass(cls) -> None: + # reset config to previous state + frappe.db.set_value("Accounts Settings", "Accounts Settings", + "unlink_advance_payment_on_cancelation_of_order", cls.unlink_setting) + def tearDown(self): frappe.set_user("Administrator") @@ -325,6 +337,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 +355,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": [{ @@ -1040,6 +1061,38 @@ class TestSalesOrder(unittest.TestCase): self.assertRaises(frappe.LinkExistsError, so_doc.cancel) + def test_cancel_sales_order_after_cancel_payment_entry(self): + from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry + # make a sales order + so = make_sales_order() + + # disable unlinking of payment entry + frappe.db.set_value("Accounts Settings", "Accounts Settings", + "unlink_advance_payment_on_cancelation_of_order", 0) + + # create a payment entry against sales order + pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Bank - _TC") + pe.reference_no = "1" + pe.reference_date = nowdate() + pe.paid_from_account_currency = so.currency + pe.paid_to_account_currency = so.currency + pe.source_exchange_rate = 1 + pe.target_exchange_rate = 1 + pe.paid_amount = so.grand_total + pe.save(ignore_permissions=True) + pe.submit() + + # Cancel payment entry + po_doc = frappe.get_doc("Payment Entry", pe.name) + po_doc.cancel() + + # Cancel sales order + try: + so_doc = frappe.get_doc('Sales Order', so.name) + so_doc.cancel() + except Exception: + self.fail("Can not cancel sales order with linked cancelled payment entry") + def test_request_for_raw_materials(self): item = make_item("_Test Finished Item", {"is_stock_item": 1, "maintain_stock": 1, @@ -1198,4 +1251,4 @@ def make_sales_order_workflow(): )) workflow.insert(ignore_permissions=True) - return workflow \ No newline at end of file + return workflow diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 159655b74bb..37e47a9d410 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -46,6 +46,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_24", "net_rate", @@ -214,7 +215,6 @@ "fieldtype": "Link", "label": "UOM", "options": "UOM", - "print_hide": 0, "reqd": 1 }, { @@ -780,12 +780,20 @@ "fieldname": "manufacturing_section_section", "fieldtype": "Section Break", "label": "Manufacturing Section" + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "options": "currency", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-012-07 20:54:32.309460", + "modified": "2021-01-30 21:35:07.617320", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index d4cde433590..45b4e30bf01 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -69,6 +69,10 @@ erpnext.PointOfSale.Controller = class { dialog.fields_dict.balance_details.grid.refresh(); }); } + const pos_profile_query = { + query: 'erpnext.accounts.doctype.pos_profile.pos_profile.pos_profile_query', + filters: { company: frappe.defaults.get_default('company') } + } const dialog = new frappe.ui.Dialog({ title: __('Create POS Opening Entry'), static: true, @@ -80,6 +84,7 @@ erpnext.PointOfSale.Controller = class { { fieldtype: 'Link', label: __('POS Profile'), options: 'POS Profile', fieldname: 'pos_profile', reqd: 1, + get_query: () => pos_profile_query, onchange: () => fetch_pos_payment_methods() }, { @@ -124,9 +129,8 @@ erpnext.PointOfSale.Controller = class { }); frappe.db.get_doc("POS Profile", this.pos_profile).then((profile) => { + Object.assign(this.settings, profile); this.settings.customer_groups = profile.customer_groups.map(group => group.customer_group); - this.settings.hide_images = profile.hide_images; - this.settings.auto_add_item_to_cart = profile.auto_add_item_to_cart; this.make_app(); }); } @@ -255,11 +259,9 @@ erpnext.PointOfSale.Controller = class { get_frm: () => this.frm, cart_item_clicked: (item_code, batch_no, uom) => { - const item_row = this.frm.doc.items.find( - i => i.item_code === item_code - && i.uom === uom - && (!batch_no || (batch_no && i.batch_no === batch_no)) - ); + const search_field = batch_no ? 'batch_no' : 'item_code'; + const search_value = batch_no || item_code; + const item_row = this.frm.doc.items.find(i => i[search_field] === search_value && i.uom === uom); this.item_details.toggle_item_details_section(item_row); }, @@ -281,6 +283,7 @@ erpnext.PointOfSale.Controller = class { init_item_details() { this.item_details = new erpnext.PointOfSale.ItemDetails({ wrapper: this.$components_wrapper, + settings: this.settings, events: { get_frm: () => this.frm, @@ -415,6 +418,11 @@ erpnext.PointOfSale.Controller = class { () => this.item_selector.toggle_component(true) ]); }, + delete_order: (name) => { + frappe.model.delete_doc(this.frm.doc.doctype, name, () => { + this.recent_order_list.refresh_list(); + }); + }, new_order: () => { frappe.run_serially([ () => frappe.dom.freeze(), @@ -696,14 +704,14 @@ erpnext.PointOfSale.Controller = class { frappe.dom.freeze(); const { doctype, name, current_item } = this.item_details; - frappe.model.set_value(doctype, name, 'qty', 0); - - this.frm.script_manager.trigger('qty', doctype, name).then(() => { - frappe.model.clear_doc(doctype, name); - this.update_cart_html(current_item, true); - this.item_details.toggle_item_details_section(undefined); - frappe.dom.unfreeze(); - }) + frappe.model.set_value(doctype, name, 'qty', 0) + .then(() => { + frappe.model.clear_doc(doctype, name); + this.update_cart_html(current_item, true); + this.item_details.toggle_item_details_section(undefined); + frappe.dom.unfreeze(); + }) + .catch(e => console.log(e)); } } 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 3938300a2ad..de70f167a52 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -5,6 +5,8 @@ erpnext.PointOfSale.ItemCart = class { this.customer_info = undefined; this.hide_images = settings.hide_images; this.allowed_customer_groups = settings.customer_groups; + this.allow_rate_change = settings.allow_rate_change; + this.allow_discount_change = settings.allow_discount_change; this.init_component(); } @@ -201,7 +203,7 @@ erpnext.PointOfSale.ItemCart = class { me.events.checkout(); me.toggle_checkout_btn(false); - me.$add_discount_elem.removeClass("d-none"); + me.allow_discount_change && me.$add_discount_elem.removeClass("d-none"); }); this.$totals_section.on('click', '.edit-cart-btn', () => { @@ -479,11 +481,15 @@ erpnext.PointOfSale.ItemCart = class { update_totals_section(frm) { if (!frm) frm = this.events.get_frm(); - this.render_net_total(frm.doc.base_net_total); - this.render_grand_total(frm.doc.base_grand_total); + this.render_net_total(frm.doc.net_total); + this.render_grand_total(frm.doc.grand_total); - const taxes = frm.doc.taxes.map(t => { return { description: t.description, rate: t.rate }}) - this.render_taxes(frm.doc.base_total_taxes_and_charges, taxes); + const taxes = frm.doc.taxes.map(t => { + return { + description: t.description, rate: t.rate + }; + }); + this.render_taxes(frm.doc.total_taxes_and_charges, taxes); } render_net_total(value) { @@ -545,7 +551,7 @@ erpnext.PointOfSale.ItemCart = class { get_cart_item({ item_code, batch_no, uom }) { const batch_attr = `[data-batch-no="${escape(batch_no)}"]`; const item_code_attr = `[data-item-code="${escape(item_code)}"]`; - const uom_attr = `[data-uom=${escape(uom)}]`; + const uom_attr = `[data-uom="${escape(uom)}"]`; const item_selector = batch_no ? `.cart-item-wrapper${batch_attr}${uom_attr}` : `.cart-item-wrapper${item_code_attr}${uom_attr}`; @@ -667,7 +673,7 @@ erpnext.PointOfSale.ItemCart = class { update_selector_value_in_cart_item(selector, value, item) { const $item_to_update = this.get_cart_item(item); - $item_to_update.attr(`data-${selector}`, value); + $item_to_update.attr(`data-${selector}`, escape(value)); } toggle_checkout_btn(show_checkout) { @@ -702,14 +708,26 @@ erpnext.PointOfSale.ItemCart = class { on_numpad_event($btn) { const current_action = $btn.attr('data-button-value'); const action_is_field_edit = ['qty', 'discount_percentage', 'rate'].includes(current_action); - - this.highlight_numpad_btn($btn, current_action); + const action_is_allowed = action_is_field_edit ? ( + (current_action == 'rate' && this.allow_rate_change) || + (current_action == 'discount_percentage' && this.allow_discount_change) || + (current_action == 'qty')) : true; const action_is_pressed_twice = this.prev_action === current_action; const first_click_event = !this.prev_action; const field_to_edit_changed = this.prev_action && this.prev_action != current_action; if (action_is_field_edit) { + if (!action_is_allowed) { + const label = current_action == 'rate' ? 'Rate'.bold() : 'Discount'.bold(); + const message = __('Editing {0} is not allowed as per POS Profile settings', [label]); + frappe.show_alert({ + indicator: 'red', + message: message + }); + frappe.utils.play_sound("error"); + return; + } if (first_click_event || field_to_edit_changed) { this.prev_action = current_action; @@ -753,6 +771,7 @@ erpnext.PointOfSale.ItemCart = class { this.numpad_value = current_action; } + this.highlight_numpad_btn($btn, current_action); this.events.numpad_event(this.numpad_value, this.prev_action); } diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js index a4de9f165dc..259631d14d2 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_details.js +++ b/erpnext/selling/page/point_of_sale/pos_item_details.js @@ -1,7 +1,9 @@ erpnext.PointOfSale.ItemDetails = class { - constructor({ wrapper, events }) { + constructor({ wrapper, events, settings }) { this.wrapper = wrapper; this.events = events; + this.allow_rate_change = settings.allow_rate_change; + this.allow_discount_change = settings.allow_discount_change; this.current_item = {}; this.init_component(); @@ -207,17 +209,27 @@ erpnext.PointOfSale.ItemDetails = class { bind_custom_control_change_event() { const me = this; if (this.rate_control) { - this.rate_control.df.onchange = function() { - if (this.value || flt(this.value) === 0) { - me.events.form_updated(me.doctype, me.name, 'rate', this.value).then(() => { - const item_row = frappe.get_doc(me.doctype, me.name); - const doc = me.events.get_frm().doc; - - me.$item_price.html(format_currency(item_row.rate, doc.currency)); - me.render_discount_dom(item_row); - }); - } + if (this.allow_rate_change) { + this.rate_control.df.onchange = function() { + if (this.value || flt(this.value) === 0) { + me.events.form_updated(me.doctype, me.name, 'rate', this.value).then(() => { + const item_row = frappe.get_doc(me.doctype, me.name); + const doc = me.events.get_frm().doc; + + me.$item_price.html(format_currency(item_row.rate, doc.currency)); + me.render_discount_dom(item_row); + }); + } + }; + } else { + this.rate_control.df.read_only = 1; } + this.rate_control.refresh(); + } + + if (this.discount_percentage_control && !this.allow_discount_change) { + this.discount_percentage_control.df.read_only = 1; + this.discount_percentage_control.refresh(); } if (this.warehouse_control) { @@ -294,8 +306,16 @@ erpnext.PointOfSale.ItemDetails = class { } frappe.model.on("POS Invoice Item", "*", (fieldname, value, item_row) => { + const { item_code, batch_no, uom } = this.current_item; + const item_code_is_same = item_code === item_row.item_code; + const batch_is_same = batch_no == item_row.batch_no; + const uom_is_same = uom === item_row.uom; + // check if current_item is same as item_row + const item_is_same = item_code_is_same && batch_is_same && uom_is_same ? true : false; + const field_control = me[`${fieldname}_control`]; - if (field_control) { + + if (item_is_same && field_control && field_control.get_value() !== value) { field_control.set_value(value); cur_pos.update_cart_html(item_row); } diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js index 6fd4c26bea1..598f50f1921 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js @@ -265,6 +265,14 @@ erpnext.PointOfSale.PastOrderSummary = class { this.$summary_wrapper.addClass('d-none'); }); + this.$summary_container.on('click', '.delete-btn', () => { + this.events.delete_order(this.doc.name); + this.show_summary_placeholder(); + // this.toggle_component(false); + // this.$component.find('.no-summary-placeholder').removeClass('d-none'); + // this.$summary_wrapper.addClass('d-none'); + }); + this.$summary_container.on('click', '.new-btn', () => { this.events.new_order(); this.toggle_component(false); @@ -401,7 +409,7 @@ erpnext.PointOfSale.PastOrderSummary = class { return [{ condition: true, visible_btns: ['Print Receipt', 'Email Receipt', 'New Order'] }]; return [ - { condition: this.doc.docstatus === 0, visible_btns: ['Edit Order'] }, + { condition: this.doc.docstatus === 0, visible_btns: ['Edit Order', 'Delete Order'] }, { condition: !this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt', 'Return']}, { condition: this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt']} ]; diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index e4d8965ac2e..e150271fd0b 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -168,30 +168,22 @@ erpnext.PointOfSale.Payment = class { me.toggle_numpad(true); me.selected_mode = me[`${mode}_control`]; - const doc = me.events.get_frm().doc; - me.selected_mode?.$input?.get(0).focus(); - const current_value = me.selected_mode?.get_value() - !current_value && doc.grand_total > doc.paid_amount ? me.selected_mode?.set_value(doc.grand_total - doc.paid_amount) : ''; + me.selected_mode && me.selected_mode.$input.get(0).focus(); + 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', function(e) { const value = $(this).attr('data-value'); @@ -250,6 +242,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').attr("title", `${ctrl_label}+Enter`); @@ -370,9 +397,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 004421d2bcb..fc464b237c6 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -245,6 +245,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/stock/__init__.py b/erpnext/stock/__init__.py index 8d64efe41dd..9e240cc2b3c 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -64,10 +64,10 @@ def get_warehouse_account(warehouse, warehouse_account=None): if not account and warehouse.company: account = get_company_default_inventory_account(warehouse.company) - if not account and warehouse.company: + if not account and warehouse.company and not warehouse.is_group: frappe.throw(_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}") .format(warehouse.name, warehouse.company)) return account def get_company_default_inventory_account(company): - return frappe.get_cached_value('Company', company, 'default_inventory_account') + return frappe.get_cached_value('Company', company, 'default_inventory_account') diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 97f85bafd95..cbd272df4b8 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -298,9 +298,9 @@ class TestBatch(unittest.TestCase): self.assertEqual(details.get('price_list_rate'), 400) def create_batch(item_code, rate, create_item_price_for_batch): - pi = make_purchase_invoice(company="_Test Company with perpetual inventory", - warehouse= "Stores - TCP1", cost_center = "Main - TCP1", update_stock=1, - expense_account ="_Test Account Cost for Goods Sold - TCP1", item_code=item_code) + pi = make_purchase_invoice(company="_Test Company", + warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1, + expense_account ="_Test Account Cost for Goods Sold - _TC", item_code=item_code) batch = frappe.db.get_value('Batch', {'item': item_code, 'reference_name': pi.name}) diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 1088b4127d3..0514bd23942 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -16,8 +16,9 @@ class Bin(Document): def update_stock(self, args, allow_negative_stock=False, via_landed_cost_voucher=False): '''Called from erpnext.stock.utils.update_bin''' self.update_qty(args) + if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation": - from erpnext.stock.stock_ledger import update_entries_after, validate_negative_qty_in_future_sle + from erpnext.stock.stock_ledger import update_entries_after, update_qty_in_future_sle if not args.get("posting_date"): args["posting_date"] = nowdate() @@ -34,11 +35,13 @@ class Bin(Document): "posting_time": args.get("posting_time"), "voucher_type": args.get("voucher_type"), "voucher_no": args.get("voucher_no"), - "sle_id": args.name + "sle_id": args.name, + "creation": args.creation }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher) - # Validate negative qty in future transactions - validate_negative_qty_in_future_sle(args) + # update qty in future ale and Validate negative qty + update_qty_in_future_sle(args, allow_negative_stock) + def update_qty(self, args): # update the stock values (for current quantities) @@ -51,7 +54,7 @@ class Bin(Document): self.reserved_qty = flt(self.reserved_qty) + flt(args.get("reserved_qty")) self.indented_qty = flt(self.indented_qty) + flt(args.get("indented_qty")) self.planned_qty = flt(self.planned_qty) + flt(args.get("planned_qty")) - + self.set_projected_qty() self.db_update() diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 559f8be0dea..d39b22965e3 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -489,7 +489,10 @@ class TestDeliveryNote(unittest.TestCase): def test_closed_delivery_note(self): from erpnext.stock.doctype.delivery_note.delivery_note import update_delivery_note_status - dn = create_delivery_note(company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1", do_not_submit=True) + make_stock_entry(target="Stores - TCP1", qty=5, basic_rate=100) + + dn = create_delivery_note(company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', + cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1", do_not_submit=True) dn.submit() diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 9de088df0ee..17996247c55 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -47,6 +47,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_25", "net_rate", @@ -743,13 +744,21 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "options": "currency", + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-12-26 17:31:27.029803", + "modified": "2021-01-30 21:42:03.767968", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index be845d9d9d5..cda10698919 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -672,13 +672,14 @@ class Item(WebsiteGenerator): if not records: return document = _("Stock Reconciliation") if len(records) == 1 else _("Stock Reconciliations") - msg = _("The items {0} and {1} are present in the following {2} :
" - .format(frappe.bold(old_name), frappe.bold(new_name), document)) + msg = _("The items {0} and {1} are present in the following {2} : ").format( + frappe.bold(old_name), frappe.bold(new_name), document) + msg += '
' msg += ', '.join([get_link_to_form("Stock Reconciliation", d.parent) for d in records]) + "

" - msg += _("Note: To merge the items, create a separate Stock Reconciliation for the old item {0}" - .format(frappe.bold(old_name))) + msg += _("Note: To merge the items, create a separate Stock Reconciliation for the old item {0}").format( + frappe.bold(old_name)) frappe.throw(_(msg), title=_("Merge not allowed")) @@ -971,7 +972,7 @@ class Item(WebsiteGenerator): frappe.throw(_("As there are existing transactions against item {0}, you can not change the value of {1}").format(self.name, frappe.bold(self.meta.get_label(field)))) def check_if_linked_document_exists(self, field): - linked_doctypes = ["Delivery Note Item", "Sales Invoice Item", "Purchase Receipt Item", + linked_doctypes = ["Delivery Note Item", "Sales Invoice Item", "POS Invoice Item", "Purchase Receipt Item", "Purchase Invoice Item", "Stock Entry Detail", "Stock Reconciliation Item"] # For "Is Stock Item", following doctypes is important diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 144101c67d7..984ae46c66c 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -148,7 +148,6 @@ class TestLandedCostVoucher(unittest.TestCase): def test_landed_cost_voucher_for_odd_numbers (self): - pr = make_purchase_receipt(company="_Test Company with perpetual inventory", warehouse = "Stores - TCP1", supplier_warehouse = "Work in Progress - TCP1", do_not_save=True) pr.items[0].cost_center = "Main - TCP1" for x in range(2): @@ -208,6 +207,10 @@ class TestLandedCostVoucher(unittest.TestCase): self.assertEqual(pr.items[1].landed_cost_voucher_amount, 100) def test_multi_currency_lcv(self): + from erpnext.setup.doctype.currency_exchange.test_currency_exchange import test_records, save_new_records + + save_new_records(test_records) + ## Create USD Shipping charges_account usd_shipping = create_account(account_name="Shipping Charges USD", parent_account="Duties and Taxes - TCP1", company="_Test Company with perpetual inventory", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 550c849c5d1..d72101412ea 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -295,7 +295,8 @@ class PurchaseReceipt(BuyingController): "against": warehouse_account[d.warehouse]["account"], "cost_center": d.cost_center, "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(amount["base_amount"]), + "credit": (flt(amount["base_amount"]) if (amount["base_amount"] or + account_currency!=self.company_currency) else flt(amount["amount"])), "credit_in_account_currency": flt(amount["amount"]), "project": d.project }, item=d)) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index ca58ab28236..7741ee7f609 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -94,10 +94,15 @@ class TestPurchaseReceipt(unittest.TestCase): frappe.get_doc('Payment Terms Template', '_Test Payment Terms Template For Purchase Invoice').delete() def test_purchase_receipt_no_gl_entry(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + company = frappe.db.get_value('Warehouse', '_Test Warehouse - _TC', 'company') - existing_bin_stock_value = frappe.db.get_value("Bin", {"item_code": "_Test Item", - "warehouse": "_Test Warehouse - _TC"}, "stock_value") + existing_bin_qty, existing_bin_stock_value = frappe.db.get_value("Bin", {"item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC"}, ["actual_qty", "stock_value"]) + + if existing_bin_qty < 0: + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=abs(existing_bin_qty)) pr = make_purchase_receipt() diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index e99119202e7..8974ad9318d 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -48,6 +48,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_29", "net_rate", @@ -874,6 +875,14 @@ "label": "Received Qty in Stock UOM", "print_hide": 1 }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "options": "currency", + "read_only": 1 + }, { "fieldname": "delivery_note_item", "fieldtype": "Data", @@ -886,7 +895,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2020-12-26 16:50:56.479347", + "modified": "2021-01-30 21:44:06.918515", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index ba2c2c6f446..8436acbed25 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -46,6 +46,9 @@ class RepostItemValuation(Document): def repost(doc): try: + if not frappe.db.exists("Repost Item Valuation", doc.name): + return + doc.set_status('In Progress') frappe.db.commit() @@ -64,7 +67,7 @@ def repost(doc): message += "
" + "Traceback:
" + traceback frappe.db.set_value(doc.doctype, doc.name, 'error_log', message) - notify_error_to_stock_managers(doc) + notify_error_to_stock_managers(doc, message) doc.set_status('Failed') raise finally: diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py index e1fa207a216..9c3e22f0231 100644 --- a/erpnext/stock/doctype/shipment/test_shipment.py +++ b/erpnext/stock/doctype/shipment/test_shipment.py @@ -190,6 +190,7 @@ def create_shipment_company(company_name, abbr): company.abbr = abbr company.default_currency = 'EUR' company.country = 'Germany' + company.enable_perpetual_inventory = 0 company.insert() return company 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..78457e48099 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -37,6 +37,7 @@ class StockLedgerEntry(Document): self.block_transactions_against_group_warehouse() self.validate_with_last_transaction_posting_time() + def on_submit(self): self.check_stock_frozen_date() self.actual_amt_check() diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index dfe8fea67b9..873cfec85ec 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -19,7 +19,7 @@ from erpnext.stock.doctype.item_manufacturer.item_manufacturer import get_item_m from six import string_types, iteritems -sales_doctypes = ['Quotation', 'Sales Order', 'Delivery Note', 'Sales Invoice'] +sales_doctypes = ['Quotation', 'Sales Order', 'Delivery Note', 'Sales Invoice', 'POS Invoice'] purchase_doctypes = ['Material Request', 'Supplier Quotation', 'Purchase Order', 'Purchase Receipt', 'Purchase Invoice'] @frappe.whitelist() diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 46919c8c8c3..f54b3c1bb20 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -23,6 +23,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc cancel = sl_entries[0].get("is_cancelled") if cancel: + validate_cancellation(sl_entries) set_as_cancel(sl_entries[0].get('voucher_type'), sl_entries[0].get('voucher_no')) for sle in sl_entries: @@ -45,6 +46,21 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc args = sle_doc.as_dict() update_bin(args, allow_negative_stock, via_landed_cost_voucher) +def validate_cancellation(args): + if args[0].get("is_cancelled"): + repost_entry = frappe.db.get_value("Repost Item Valuation", { + 'voucher_type': args[0].voucher_type, + 'voucher_no': args[0].voucher_no, + 'docstatus': 1 + }, ['name', 'status'], as_dict=1) + + if repost_entry: + if repost_entry.status == 'In Progress': + frappe.throw(_("Cannot cancel the transaction. Reposting of item valuation on submission is not completed yet.")) + if repost_entry.status == 'Queued': + doc = frappe.get_doc("Repost Item Valuation", repost_entry.name) + doc.cancel() + doc.delete() def set_as_cancel(voucher_type, voucher_no): frappe.db.sql("""update `tabStock Ledger Entry` set is_cancelled=1, @@ -74,7 +90,8 @@ def repost_future_sle(args=None, voucher_type=None, voucher_no=None, allow_negat "item_code": args[i].item_code, "warehouse": args[i].warehouse, "posting_date": args[i].posting_date, - "posting_time": args[i].posting_time + "posting_time": args[i].posting_time, + "creation": args[i].get("creation") }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher) for item_wh, new_sle in iteritems(obj.new_items): @@ -86,7 +103,7 @@ def repost_future_sle(args=None, voucher_type=None, voucher_no=None, allow_negat def get_args_for_voucher(voucher_type, voucher_no): return frappe.db.get_all("Stock Ledger Entry", filters={"voucher_type": voucher_type, "voucher_no": voucher_no}, - fields=["item_code", "warehouse", "posting_date", "posting_time"], + fields=["item_code", "warehouse", "posting_date", "posting_time", "creation"], order_by="creation asc", group_by="item_code, warehouse" ) @@ -155,7 +172,7 @@ class update_entries_after(object): """ self.data.setdefault(args.warehouse, frappe._dict()) warehouse_dict = self.data[args.warehouse] - previous_sle = self.get_sle_before_datetime(args) + previous_sle = self.get_previous_sle_of_current_voucher(args) warehouse_dict.previous_sle = previous_sle for key in ("qty_after_transaction", "valuation_rate", "stock_value"): @@ -167,9 +184,35 @@ class update_entries_after(object): "stock_value_difference": 0.0 }) + def get_previous_sle_of_current_voucher(self, args): + """get stock ledger entries filtered by specific posting datetime conditions""" + + args['time_format'] = '%H:%i:%s' + if not args.get("posting_date"): + args["posting_date"] = "1900-01-01" + if not args.get("posting_time"): + args["posting_time"] = "00:00" + + sle = frappe.db.sql(""" + select *, timestamp(posting_date, posting_time) as "timestamp" + from `tabStock Ledger Entry` + where item_code = %(item_code)s + and warehouse = %(warehouse)s + and is_cancelled = 0 + and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) + order by timestamp(posting_date, posting_time) desc, creation desc + limit 1""", args, as_dict=1) + + return sle[0] if sle else frappe._dict() + + def build(self): + from erpnext.controllers.stock_controller import check_if_future_sle_exists + if self.args.get("sle_id"): - self.process_sle_against_current_voucher() + self.process_sle_against_current_timestamp() + if not check_if_future_sle_exists(self.args): + self.update_bin() else: entries_to_fix = self.get_future_entries_to_fix() @@ -183,17 +226,19 @@ class update_entries_after(object): if sle.dependant_sle_voucher_detail_no: entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) + self.update_bin() + if self.exceptions: self.raise_exceptions() - self.update_bin() - - def process_sle_against_current_voucher(self): + def process_sle_against_current_timestamp(self): sl_entries = self.get_sle_against_current_voucher() for sle in sl_entries: self.process_sle(sle) def get_sle_against_current_voucher(self): + self.args['time_format'] = '%H:%i:%s' + return frappe.db.sql(""" select *, timestamp(posting_date, posting_time) as "timestamp" @@ -202,8 +247,8 @@ 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, %(time_format)s)) = timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) + order by creation ASC for update @@ -230,7 +275,6 @@ class update_entries_after(object): return entries_to_fix elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse in self.data: return entries_to_fix - self.initialize_previous_data(dependant_sle) args = self.data[dependant_sle.warehouse].previous_sle \ @@ -396,7 +440,7 @@ class update_entries_after(object): # Recalculate subcontracted item's rate in case of subcontracted purchase receipt/invoice if frappe.db.get_value(sle.voucher_type, sle.voucher_no, "is_subcontracted"): - doc = frappe.get_cached_doc(sle.voucher_type, sle.voucher_no) + doc = frappe.get_doc(sle.voucher_type, sle.voucher_no) doc.update_valuation_rate(reset_outgoing_rate=False) for d in (doc.items + doc.supplied_items): d.db_update() @@ -637,7 +681,6 @@ class update_entries_after(object): # update bin for each warehouse for warehouse, data in iteritems(self.data): bin_doc = get_bin(self.item_code, warehouse) - bin_doc.update({ "valuation_rate": data.valuation_rate, "actual_qty": data.qty_after_transaction, @@ -763,6 +806,25 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, return valuation_rate +def update_qty_in_future_sle(args, allow_negative_stock=None): + frappe.db.sql(""" + update `tabStock Ledger Entry` + set qty_after_transaction = qty_after_transaction + {qty} + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and voucher_no != %(voucher_no)s + and is_cancelled = 0 + and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s) + or ( + timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) + and creation > %(creation)s + ) + ) + """.format(qty=args.actual_qty), args) + + validate_negative_qty_in_future_sle(args, allow_negative_stock) + def validate_negative_qty_in_future_sle(args, allow_negative_stock=None): allow_negative_stock = allow_negative_stock \ or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) @@ -791,7 +853,7 @@ def get_future_sle_with_negative_qty(args): and voucher_no != %(voucher_no)s and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) and is_cancelled = 0 - and qty_after_transaction + {0} < 0 + and qty_after_transaction < 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 + """, args, as_dict=1) \ No newline at end of file