diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 489dc705b47..6501c716aa2 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -21,6 +21,7 @@ "enable_common_party_accounting", "allow_multi_currency_invoices_against_single_party_account", "confirm_before_resetting_posting_date", + "preview_mode", "analytics_section", "enable_accounting_dimensions", "column_break_vtnr", @@ -716,6 +717,13 @@ "fieldtype": "Table", "label": "Allowed Doctypes", "options": "Repost Allowed Types" + }, + { + "default": "0", + "description": "Runs a preview check on save before submission without making any actual changes.", + "fieldname": "preview_mode", + "fieldtype": "Check", + "label": "Preview Mode" } ], "grid_page_length": 50, @@ -724,7 +732,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-04-13 15:30:28.729627", + "modified": "2026-04-22 01:38:42.418238", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index fa36f1de183..a35727e78c8 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -89,6 +89,7 @@ class AccountsSettings(Document): make_payment_via_journal_entry: DF.Check merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency + preview_mode: DF.Check receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int diff --git a/erpnext/accounts/test/test_pre_submit_validation.py b/erpnext/accounts/test/test_pre_submit_validation.py new file mode 100644 index 00000000000..9ade1318302 --- /dev/null +++ b/erpnext/accounts/test/test_pre_submit_validation.py @@ -0,0 +1,264 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from unittest.mock import patch + +import frappe + +from erpnext.accounts.utils import ( + _check_credit_limit_warn, + _check_packed_qty_warn, +) +from erpnext.selling.doctype.customer.test_customer import set_credit_limit +from erpnext.tests.utils import ERPNextTestSuite + + +def _get_orange_warnings(): + return [m for m in frappe.message_log if m.get("indicator") == "orange"] + + +class _CreditLimitBase(ERPNextTestSuite): + COMPANY = "_Test Company" + CUSTOMER = "_Test Customer" + CREDIT_LIMIT = 100.0 + OVER = 200.0 + UNDER = 50.0 + + def setUp(self): + set_credit_limit(self.CUSTOMER, self.COMPANY, self.CREDIT_LIMIT) + frappe.message_log.clear() + + +class TestCreditLimitWarnSalesInvoice(_CreditLimitBase): + def _make_si(self, amount, is_return=0): + """Build an in-memory (unsaved) draft SI.""" + si = frappe.new_doc("Sales Invoice") + si.company = self.COMPANY + si.customer = self.CUSTOMER + si.is_return = is_return + si.base_grand_total = amount + si.append("items", {"item_code": "_Test Item", "qty": 1, "rate": amount}) + return si + + def test_warns_when_amount_exceeds_credit_limit(self): + """Orange warning must appear when base_grand_total > credit_limit.""" + si = self._make_si(self.OVER) + _check_credit_limit_warn(si) + self.assertTrue(_get_orange_warnings(), "Expected an orange credit-limit warning") + + def test_no_warning_when_amount_within_credit_limit(self): + """No warning when base_grand_total is safely within the credit limit.""" + si = self._make_si(self.UNDER) + _check_credit_limit_warn(si) + self.assertFalse(_get_orange_warnings()) + + def test_no_warning_for_return_invoices(self): + """Credit limit check is skipped entirely for return transactions.""" + si = self._make_si(self.OVER, is_return=1) + _check_credit_limit_warn(si) + self.assertFalse(_get_orange_warnings()) + + def test_no_warning_when_customer_has_no_credit_limit(self): + """If the customer has no credit limit configured, no warning is shown.""" + frappe.db.delete("Customer Credit Limit", {"parent": self.CUSTOMER}) + si = self._make_si(self.OVER) + _check_credit_limit_warn(si) + self.assertFalse(_get_orange_warnings()) + + def test_no_warning_when_all_items_linked_to_so_or_dn(self): + """ + When every item on the SI already has a sales_order or delivery_note + reference, the check is skipped (the SO/DN already counted this amount). + """ + si = self._make_si(self.OVER) + si.items[0].sales_order = "SO-TEST-0001" + _check_credit_limit_warn(si) + self.assertFalse(_get_orange_warnings()) + + +class TestCreditLimitWarnSalesOrder(_CreditLimitBase): + def _make_so(self, amount): + """Build an in-memory (unsaved) draft SO.""" + so = frappe.new_doc("Sales Order") + so.company = self.COMPANY + so.customer = self.CUSTOMER + so.base_grand_total = amount + so.append("items", {"item_code": "_Test Item", "qty": 1, "rate": amount}) + return so + + def test_warns_on_first_save_when_limit_exceeded(self): + so = self._make_so(self.OVER) + self.assertTrue(so.is_new(), "Doc should be new (not yet in DB)") + _check_credit_limit_warn(so) + self.assertTrue(_get_orange_warnings()) + + def test_warns_when_amount_exceeds_credit_limit(self): + so = self._make_so(self.OVER) + _check_credit_limit_warn(so) + self.assertTrue(_get_orange_warnings()) + + def test_no_warning_when_amount_within_credit_limit(self): + so = self._make_so(self.UNDER) + _check_credit_limit_warn(so) + self.assertFalse(_get_orange_warnings()) + + def test_no_warning_when_bypass_is_set(self): + """ + When bypass_credit_limit_check=1 on the Customer Credit Limit row, + SO's check_credit_limit skips entirely. + """ + frappe.db.set_value( + "Customer Credit Limit", + {"parent": self.CUSTOMER, "company": self.COMPANY}, + "bypass_credit_limit_check", + 1, + ) + so = self._make_so(self.OVER) + _check_credit_limit_warn(so) + self.assertFalse(_get_orange_warnings()) + + +class TestCreditLimitWarnDeliveryNote(_CreditLimitBase): + def _make_dn(self, amount, bypass=False, against_sales_order=None, against_sales_invoice=None): + """Build an in-memory (unsaved) draft DN.""" + dn = frappe.new_doc("Delivery Note") + dn.company = self.COMPANY + dn.customer = self.CUSTOMER + dn.base_grand_total = amount + dn.base_net_total = amount + item = { + "item_code": "_Test Item", + "qty": 1, + "rate": amount, + "amount": amount, + "base_amount": amount, + } + if against_sales_order: + item["against_sales_order"] = against_sales_order + if against_sales_invoice: + item["against_sales_invoice"] = against_sales_invoice + dn.append("items", item) + + if bypass: + frappe.db.set_value( + "Customer Credit Limit", + {"parent": self.CUSTOMER, "company": self.COMPANY}, + "bypass_credit_limit_check", + 1, + ) + + return dn + + def test_bypass_false_warns_for_existing_draft(self): + """bypass=False, existing draft: proportional extra_amount path still applies.""" + dn = self._make_dn(self.OVER) + _check_credit_limit_warn(dn) + self.assertTrue(_get_orange_warnings()) + + def test_bypass_false_no_warning_when_under_limit(self): + dn = self._make_dn(self.UNDER) + _check_credit_limit_warn(dn) + self.assertFalse(_get_orange_warnings()) + + def test_bypass_false_no_warning_when_all_items_linked_to_so(self): + """ + Items fully linked to a SO are excluded from unlinked_net. + extra_amount becomes 0 → check is skipped. + """ + dn = self._make_dn(self.OVER, against_sales_order="SO-TEST-0001") + _check_credit_limit_warn(dn) + self.assertFalse(_get_orange_warnings()) + + def test_bypass_false_partial_link_warns_proportionally(self): + """ + Two items: one linked to SO, one unlinked. + Only the unlinked portion should count toward the credit limit check. + """ + dn = frappe.new_doc("Delivery Note") + dn.company = self.COMPANY + dn.customer = self.CUSTOMER + dn.append("items", {"item_code": "_Test Item", "qty": 1, "rate": 60, "amount": 60, "base_amount": 60}) + dn.append( + "items", + { + "item_code": "_Test Item", + "qty": 1, + "rate": 50, + "amount": 50, + "base_amount": 50, + "against_sales_order": "SO-TEST-0001", + }, + ) + dn.base_net_total = 110 + dn.base_grand_total = 110 + + _check_credit_limit_warn(dn) + self.assertFalse(_get_orange_warnings(), "60 < 100 credit limit, should not warn") + + # bypass=True ----------------------------------------------------------- + + def test_bypass_true_warns_on_first_save_new_doc(self): + """ + bypass=True: existing doc.check_credit_limit() handles extra_amount + internally (base_grand_total for items not against SI). + """ + dn = self._make_dn(self.OVER, bypass=True) + self.assertTrue(dn.is_new()) + _check_credit_limit_warn(dn) + self.assertTrue(_get_orange_warnings()) + + def test_bypass_true_no_warning_when_all_items_billed(self): + """ + bypass=True: items already linked to a SI are excluded from extra_amount. + If all items have against_sales_invoice set, extra_amount=0 → no check. + """ + dn = self._make_dn(self.OVER, bypass=True, against_sales_invoice="SINV-TEST-0001") + _check_credit_limit_warn(dn) + self.assertFalse(_get_orange_warnings()) + + +# --------------------------------------------------------------------------- +# Packed Qty +# --------------------------------------------------------------------------- + + +class TestPackedQtyWarn(ERPNextTestSuite): + COMPANY = "_Test Company" + CUSTOMER = "_Test Customer" + + def setUp(self): + frappe.message_log.clear() + + def _make_dn(self): + dn = frappe.new_doc("Delivery Note") + dn.company = self.COMPANY + dn.customer = self.CUSTOMER + dn.append( + "items", + {"item_code": "_Test Item", "qty": 2, "rate": 100, "amount": 200, "base_amount": 200}, + ) + return dn + + def test_no_warning_for_new_doc(self): + """New doc has no packing slip in DB, so validate_packed_qty is skipped.""" + dn = self._make_dn() + _check_packed_qty_warn(dn) + self.assertFalse(_get_orange_warnings()) + + def test_warns_when_packed_qty_mismatches(self): + """When validate_packed_qty raises, an orange warning is produced.""" + dn = self._make_dn() + with patch.object( + dn, + "validate_packed_qty", + side_effect=frappe.ValidationError("Packed Qty must be equal to qty"), + ): + _check_packed_qty_warn(dn) + self.assertTrue(_get_orange_warnings()) + + def test_no_warning_when_packed_qty_matches(self): + """When validate_packed_qty passes silently, no warning is produced.""" + dn = self._make_dn() + with patch.object(dn, "validate_packed_qty", return_value=None): + _check_packed_qty_warn(dn) + self.assertFalse(_get_orange_warnings()) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index c80571d7a68..bc7794b880b 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2724,3 +2724,119 @@ def build_qb_match_conditions(doctype, user=None) -> list: def is_immutable_ledger_enabled(): return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger") + + +PRE_SUBMIT_DOCTYPE_CONFIG = { + "Sales Invoice": { + "check_prev_docstatus": True, + "check_credit_limit": True, + }, + "Purchase Invoice": { + "check_prev_docstatus": True, + }, + "Delivery Note": { + "check_prev_docstatus": True, + "check_credit_limit": True, + "check_packed_qty": True, + }, + "Purchase Receipt": { + "check_prev_docstatus": True, + }, + "Sales Order": { + "check_credit_limit": True, + }, +} + + +def pre_submit_validation(doc, method=None): + cfg = PRE_SUBMIT_DOCTYPE_CONFIG.get(doc.doctype) + if ( + doc.docstatus != 0 + or not frappe.get_cached_value("Accounts Settings", None, "preview_mode") + or not cfg + or not doc.company + ): + return + _run_pre_submit_checks(doc, cfg) + + +def _run_pre_submit_checks(doc, cfg): + if cfg.get("check_prev_docstatus"): + _check_prev_docstatus(doc) + + if cfg.get("check_credit_limit"): + _check_credit_limit_warn(doc) + + if cfg.get("check_packed_qty"): + _check_packed_qty_warn(doc) + + +def _check_prev_docstatus(doc): + try: + if hasattr(doc, "check_prev_docstatus"): + doc.check_prev_docstatus() + except Exception as e: + frappe.msgprint(str(e), title=_("Pre-Submit Warning"), indicator="orange") + + +def _check_credit_limit_warn(doc): + if doc.get("is_return") or not doc.get("customer"): + return + + from erpnext.selling.doctype.customer.customer import check_credit_limit + + try: + bypass = cint( + frappe.db.get_value( + "Customer Credit Limit", + filters={"parent": doc.customer, "parenttype": "Customer", "company": doc.company}, + fieldname="bypass_credit_limit_check", + ) + or 0 + ) + + if doc.doctype == "Sales Invoice": + validate_against_credit_limit = bypass or any( + not (d.sales_order or d.delivery_note) for d in doc.get("items") + ) + if validate_against_credit_limit: + check_credit_limit(doc.customer, doc.company, bypass, extra_amount=flt(doc.base_grand_total)) + + elif doc.doctype == "Sales Order": + if not bypass: + check_credit_limit(doc.customer, doc.company, extra_amount=flt(doc.base_grand_total)) + + elif doc.doctype == "Delivery Note": + if doc.per_billed == 100: + return + + if bypass: + doc.check_credit_limit() + else: + unlinked = [ + d for d in doc.get("items") if not (d.against_sales_order or d.against_sales_invoice) + ] + if unlinked and flt(doc.base_net_total): + unlinked_net = sum(flt(d.base_amount) for d in unlinked) + extra_amount = (unlinked_net / flt(doc.base_net_total)) * flt(doc.base_grand_total) + if extra_amount: + check_credit_limit(doc.customer, doc.company, False, extra_amount=extra_amount) + + except frappe.ValidationError as e: + frappe.msgprint( + _("Credit limit warning — submission may be blocked: {0}").format(str(e)), + title=_("Pre-Submit Warning: Credit Limit"), + indicator="orange", + ) + + +def _check_packed_qty_warn(doc): + try: + if hasattr(doc, "validate_packed_qty"): + doc.validate_packed_qty() + except frappe.ValidationError as e: + frappe.msgprint( + str(e), + title=_("Pre-Submit Warning: Packed Qty"), + indicator="orange", + ) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 89b9b06d1d0..074d323dbd3 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -340,6 +340,14 @@ period_closing_doctypes = [ "Subcontracting Receipt", ] +pre_submit_validation_doctypes = [ + "Sales Invoice", + "Purchase Invoice", + "Delivery Note", + "Purchase Receipt", + "Sales Order", +] + doc_events = { "*": { "validate": [ @@ -350,6 +358,9 @@ doc_events = { tuple(period_closing_doctypes): { "validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save", }, + tuple(pre_submit_validation_doctypes): { + "validate": "erpnext.accounts.utils.pre_submit_validation", + }, "Stock Entry": { "on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty", "on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",