From 90937ce6d93c42ee767a768991f2facb2ba52a7e Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Mon, 10 Aug 2026 18:02:16 +0530 Subject: [PATCH 1/4] fix: block disabled/frozen customers on Opportunity Opportunity inherits TransactionBase instead of AccountsController, so it never ran validate_party_frozen_disabled like Quotation, Sales Order and Sales Invoice do. A disabled Customer could be saved as an Opportunity's party and only get caught later at Quotation stage. Also fixes the party_name Link query on the client: it referenced erpnext.queries.customer, which was never defined, so disabled customers showed up in the picker. --- erpnext/crm/doctype/opportunity/opportunity.py | 5 +++++ erpnext/crm/doctype/opportunity/test_opportunity.py | 9 +++++++++ erpnext/public/js/queries.js | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 79b71818117..c74c9280d13 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -12,6 +12,7 @@ from frappe.query_builder import DocType, Interval from frappe.query_builder.functions import Now from frappe.utils import flt, get_fullname +from erpnext.accounts.party import validate_party_frozen_disabled from erpnext.crm.utils import ( CRMNote, copy_comments, @@ -132,6 +133,7 @@ class Opportunity(TransactionBase, CRMNote): self.validate_item_details() self.validate_uom_is_integer("uom", "qty") self.validate_cust_name() + self.validate_party() self.map_fields() self.validate_qty() self.set_exchange_rate() @@ -355,6 +357,9 @@ class Opportunity(TransactionBase, CRMNote): return False return True + def validate_party(self) -> None: + validate_party_frozen_disabled(self.company, self.opportunity_from, self.party_name) + def validate_cust_name(self): if self.party_name: if self.opportunity_from == "Customer": diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index e1c3cdaa2bf..c8ad0972c1a 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -9,6 +9,7 @@ from erpnext.crm.doctype.lead.test_lead import make_lead from erpnext.crm.doctype.opportunity.mapper import make_quotation from erpnext.crm.doctype.opportunity.opportunity import auto_close_opportunity, get_item_details from erpnext.crm.utils import get_linked_communication_list +from erpnext.exceptions import PartyDisabled from erpnext.tests.utils import ERPNextTestSuite @@ -71,6 +72,14 @@ class TestOpportunity(ERPNextTestSuite): opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2) self.assertEqual(opportunity_doc.total, 2200) + def test_disabled_customer_not_allowed(self): + frappe.db.set_value("Customer", "_Test Customer", "disabled", 1) + + self.assertRaises(PartyDisabled, make_opportunity, with_items=0) + + frappe.db.set_value("Customer", "_Test Customer", "disabled", 0) + make_opportunity(with_items=0) + def test_carry_forward_of_email_and_comments(self): frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1) lead_doc = make_lead() diff --git a/erpnext/public/js/queries.js b/erpnext/public/js/queries.js index 8b174da244f..32a4e0958c9 100644 --- a/erpnext/public/js/queries.js +++ b/erpnext/public/js/queries.js @@ -12,6 +12,10 @@ $.extend(erpnext.queries, { return { query: "erpnext.controllers.queries.lead_query" }; }, + customer: function () { + return { filters: { disabled: 0 } }; + }, + item: function (filters) { var args = { query: "erpnext.controllers.queries.item_query" }; if (filters) args["filters"] = filters; From 4bf65ffc1de15e9f39daf77511327ff35fa3cdd6 Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Mon, 10 Aug 2026 18:02:34 +0530 Subject: [PATCH 2/4] fix: block disabled/frozen suppliers on Request for Quotation Request for Quotation overrides validate() entirely and never calls super().validate(), so it never goes through AccountsController's party validation. Suppliers also sit in a child table, so the shared PartyValidator wouldn't have caught it anyway (it only checks a single top-level party field). A disabled or frozen Supplier could be added to an RFQ and the RFQ submitted without any warning. Also filters the suppliers grid's supplier Link field to disabled=0, matching the same client-side fix applied to Opportunity's party_name. --- .../request_for_quotation/request_for_quotation.js | 4 ++++ .../request_for_quotation/request_for_quotation.py | 3 +++ .../test_request_for_quotation.py | 12 ++++++++++++ 3 files changed, 19 insertions(+) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 33e09c00de2..567fc9ba685 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -22,6 +22,10 @@ frappe.ui.form.on("Request for Quotation", { }; }; + frm.set_query("supplier", "suppliers", function () { + return { filters: { disabled: 0 } }; + }); + frm.set_query("warehouse", "items", () => ({ filters: { company: frm.doc.company, diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 82a5b0c6103..336685819f2 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -13,6 +13,7 @@ from frappe.utils import get_url from frappe.utils.print_format import download_pdf from frappe.utils.user import get_user_fullname +from erpnext.accounts.party import validate_party_frozen_disabled from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController @@ -122,6 +123,8 @@ class RequestforQuotation(BuyingController): def validate_supplier_list(self): for d in self.suppliers: + validate_party_frozen_disabled(self.company, "Supplier", d.supplier) + prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs") if prevent_rfqs: standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status") diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index 6777d1bb2ca..ee4cae18782 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -18,6 +18,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity +from erpnext.exceptions import PartyDisabled from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.material_request.test_material_request import make_material_request from erpnext.templates.pages.rfq import check_supplier_has_docname_access @@ -89,6 +90,17 @@ class TestRequestforQuotation(ERPNextTestSuite): ) self.assertRaises(frappe.ValidationError, rfq.save) + def test_rfq_blocked_for_disabled_supplier(self): + frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1) + rfq = make_request_for_quotation( + supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}], + do_not_save=True, + ) + self.assertRaises(PartyDisabled, rfq.save) + + frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0) + rfq.save() + def test_rfq_status_lifecycle(self): rfq = make_request_for_quotation() self.assertEqual(rfq.status, "Submitted") From 8c0a94541708a6d10be19900a45305be24063ac9 Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Tue, 11 Aug 2026 11:02:29 +0530 Subject: [PATCH 3/4] fix: scope Opportunity party validation to Customer only validate_party_frozen_disabled only enforces Customer/Supplier/Employee, so passing opportunity_from straight through silently no-op'd for Lead and Prospect. Made the Customer-only scope explicit instead of relying on that implicit fallthrough. Lead.disabled is not enforced anywhere else in the codebase (lead_query, the picker used for this same field, only filters status/docstatus), so deliberately not extending validation to Lead-sourced Opportunities. --- erpnext/crm/doctype/opportunity/opportunity.py | 3 ++- erpnext/crm/doctype/opportunity/test_opportunity.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index c74c9280d13..83df1fdeee0 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -358,7 +358,8 @@ class Opportunity(TransactionBase, CRMNote): return True def validate_party(self) -> None: - validate_party_frozen_disabled(self.company, self.opportunity_from, self.party_name) + if self.opportunity_from == "Customer": + validate_party_frozen_disabled(self.company, "Customer", self.party_name) def validate_cust_name(self): if self.party_name: diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index c8ad0972c1a..a10451f75ed 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -80,6 +80,15 @@ class TestOpportunity(ERPNextTestSuite): frappe.db.set_value("Customer", "_Test Customer", "disabled", 0) make_opportunity(with_items=0) + def test_disabled_lead_not_blocked(self): + # Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only + # excludes Converted leads), so it shouldn't block Opportunity creation either. + lead_doc = make_lead() + frappe.db.set_value("Lead", lead_doc.name, "disabled", 1) + + opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name) + self.assertEqual(opp_doc.party_name, lead_doc.name) + def test_carry_forward_of_email_and_comments(self): frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1) lead_doc = make_lead() From 6b35c51ff1dfd974db5865ff93c4e421a9d0e654 Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Tue, 11 Aug 2026 15:35:28 +0530 Subject: [PATCH 4/4] refactor: move RFQ supplier disabled filter to link_filters Static filters with no doc-dependent values belong on the field definition, not in JS. Matches the existing pattern used for Warehouse/Item link_filters elsewhere (e.g. job_card_item.json, product_bundle_item.json). --- .../doctype/request_for_quotation/request_for_quotation.js | 4 ---- .../request_for_quotation_supplier.json | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 567fc9ba685..33e09c00de2 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -22,10 +22,6 @@ frappe.ui.form.on("Request for Quotation", { }; }; - frm.set_query("supplier", "suppliers", function () { - return { filters: { disabled: 0 } }; - }); - frm.set_query("warehouse", "items", () => ({ filters: { company: frm.doc.company, diff --git a/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json b/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json index 34ce6200db2..2242aa82c5f 100644 --- a/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json +++ b/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json @@ -40,6 +40,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Supplier", + "link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]", "options": "Supplier", "reqd": 1 },