From 26d3a25d18ffd45010485c0eecf0bdb00fdd481d Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Wed, 22 Apr 2026 03:33:10 +0530 Subject: [PATCH 1/2] feat: add pre-submit credit limit warning on save --- .../accounts_settings/accounts_settings.json | 12 +- .../accounts_settings/accounts_settings.py | 1 + .../test/test_pre_submit_validation.py | 225 ++++++++++++++++++ erpnext/accounts/utils.py | 103 ++++++++ erpnext/hooks.py | 11 + 5 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 erpnext/accounts/test/test_pre_submit_validation.py diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 489dc705b47c..7bbe5e9f9de4 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", @@ -707,7 +708,7 @@ "label": "Fetch Payment Schedule In Payment Request" }, { - "fieldname": "repost_section", + "fieldname": "repost_section", "fieldtype": "Section Break", "label": "Repost" }, @@ -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 fa36f1de1838..a35727e78c81 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 000000000000..25ee3fc62799 --- /dev/null +++ b/erpnext/accounts/test/test_pre_submit_validation.py @@ -0,0 +1,225 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe + +from erpnext.accounts.utils import _check_credit_limit_warn +from erpnext.selling.doctype.customer.test_customer import ( + get_customer_dict, + set_credit_limit, +) +from erpnext.tests.utils import ERPNextTestSuite + +COMPANY = "_Test Company" +CREDIT_LIMIT = 100.0 +OVER = 200.0 +UNDER = 50.0 + + +def _make_customer(name): + if not frappe.db.exists("Customer", name): + frappe.get_doc({**get_customer_dict(name), "customer_name": name}).insert() + return name + + +def _get_orange_warnings(): + return [m for m in frappe.message_log if m.get("indicator") == "orange"] + + +class _CreditLimitBase(ERPNextTestSuite): + CUSTOMER = "_Pre Submit Test Customer" + + def setUp(self): + _make_customer(self.CUSTOMER) + set_credit_limit(self.CUSTOMER, COMPANY, 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 = 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(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(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(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(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(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 = 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(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(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(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": COMPANY}, + "bypass_credit_limit_check", + 1, + ) + so = self._make_so(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 = 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": COMPANY}, + "bypass_credit_limit_check", + 1, + ) + + return dn + + # bypass=False (default) ------------------------------------------------ + + def test_bypass_false_warns_for_existing_draft(self): + """bypass=False, existing draft: proportional extra_amount path still applies.""" + dn = self._make_dn(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(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(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 = 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(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(OVER, bypass=True, against_sales_invoice="SINV-TEST-0001") + _check_credit_limit_warn(dn) + self.assertFalse(_get_orange_warnings()) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index cce6f61cf834..1547f80bd6a9 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2724,3 +2724,106 @@ 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, + }, + "Purchase Receipt": { + "check_prev_docstatus": True, + }, + "Sales Order": { + "check_prev_docstatus": True, + "check_credit_limit": True, + }, +} + + +def pre_submit_validation(doc, method=None): + if doc.docstatus != 0: + return + + if not frappe.get_cached_value("Accounts Settings", None, "preview_mode"): + return + cfg = PRE_SUBMIT_DOCTYPE_CONFIG.get(doc.doctype) + if 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) + + +def _check_prev_docstatus(doc): + try: + if doc.get("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"): + return + if 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", + ) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 89b9b06d1d0d..074d323dbd3d 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -340,6 +340,14 @@ "Subcontracting Receipt", ] +pre_submit_validation_doctypes = [ + "Sales Invoice", + "Purchase Invoice", + "Delivery Note", + "Purchase Receipt", + "Sales Order", +] + doc_events = { "*": { "validate": [ @@ -350,6 +358,9 @@ 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", From 96696eb61d13550168e3a6c4d08a24cbf89cdda0 Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Mon, 27 Apr 2026 11:19:00 +0530 Subject: [PATCH 2/2] feat: pre-submit validation error for packed quantity mismatch --- .../test/test_pre_submit_validation.py | 51 ++++++++++++++++++- erpnext/accounts/utils.py | 19 ++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/test/test_pre_submit_validation.py b/erpnext/accounts/test/test_pre_submit_validation.py index 25ee3fc62799..9055b00ef49b 100644 --- a/erpnext/accounts/test/test_pre_submit_validation.py +++ b/erpnext/accounts/test/test_pre_submit_validation.py @@ -1,9 +1,14 @@ # 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 +from erpnext.accounts.utils import ( + _check_credit_limit_warn, + _check_packed_qty_warn, +) from erpnext.selling.doctype.customer.test_customer import ( get_customer_dict, set_credit_limit, @@ -223,3 +228,47 @@ def test_bypass_true_no_warning_when_all_items_billed(self): dn = self._make_dn(OVER, bypass=True, against_sales_invoice="SINV-TEST-0001") _check_credit_limit_warn(dn) self.assertFalse(_get_orange_warnings()) + + +# --------------------------------------------------------------------------- +# Packed Qty +# --------------------------------------------------------------------------- + + +class TestPackedQtyWarn(ERPNextTestSuite): + def setUp(self): + frappe.message_log.clear() + + def _make_dn(self): + dn = frappe.new_doc("Delivery Note") + dn.company = COMPANY + dn.customer = "_Test 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 1547f80bd6a9..6055ab0858c1 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2737,12 +2737,12 @@ def is_immutable_ledger_enabled(): "Delivery Note": { "check_prev_docstatus": True, "check_credit_limit": True, + "check_packed_qty": True, }, "Purchase Receipt": { "check_prev_docstatus": True, }, "Sales Order": { - "check_prev_docstatus": True, "check_credit_limit": True, }, } @@ -2767,10 +2767,13 @@ def _run_pre_submit_checks(doc, cfg): 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 doc.get("check_prev_docstatus"): + if hasattr(doc, "check_prev_docstatus"): doc.check_prev_docstatus() except Exception as e: frappe.msgprint(str(e), title=_("Pre-Submit Warning"), indicator="orange") @@ -2827,3 +2830,15 @@ def _check_credit_limit_warn(doc): 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", + )