diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 26693e7dba6..ec282ff0c99 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -476,6 +476,8 @@ frappe.ui.form.on("Payment Entry", { return { query: "erpnext.controllers.queries.employee_query", }; + } else if (["Customer", "Supplier"].includes(frm.doc.party_type)) { + return erpnext.queries.party(frm.doc); } else if (frm.doc.party_type == "Shareholder") { return { filters: { diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 46c192870dd..fce55dc3e2c 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -40,6 +40,7 @@ from erpnext.accounts.party import ( complete_contact_details, get_default_contact, get_party_account, + validate_party_company, ) from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, @@ -2434,6 +2435,7 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost ptype = "select" if frappe.only_has_select_perm(party_type) else "read" frappe.has_permission(party_type, ptype, party, throw=True) + validate_party_company(party_type, party, company) party_account = get_party_account(party_type, party, company) account_currency = get_account_currency(party_account) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 54a578737fa..6bf27f6ee6b 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -140,6 +140,7 @@ def _get_party_details( if not ignore_permissions: ptype = "select" if frappe.only_has_select_perm(party_type) else "read" frappe.has_permission(party_type, ptype, party, throw=True) + validate_party_company(party_type, party.name, company) currency = party.get("default_currency") or currency or get_company_currency(company) @@ -197,6 +198,17 @@ def _get_party_details( return party_details +def validate_party_company(party_type, party, company): + if not company or party_type not in ("Customer", "Supplier"): + return + + from erpnext.stock.doctype.company_restriction.company_restriction import ( + validate_masters_for_company, + ) + + validate_masters_for_company(party_type, [party], company) + + def set_address_details( party_details, party, 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..3ca68c65d32 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -28,6 +28,7 @@ frappe.ui.form.on("Request for Quotation", { is_group: 0, }, })); + frm.set_query("supplier", "suppliers", () => erpnext.queries.supplier(frm.doc)); frm.set_indicator_formatter("item_code", function (doc) { return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : ""; @@ -339,6 +340,7 @@ frappe.ui.form.on("Request for Quotation Supplier", { args: { party: d.supplier, party_type: "Supplier", + company: frm.doc.company, }, callback: function (r) { if (r.message) { diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 6a900abc44e..2c3fcc17111 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -6,7 +6,7 @@ import json from collections import OrderedDict, defaultdict import frappe -from frappe import qb, scrub +from frappe import _, qb, scrub from frappe.permissions import has_permission from frappe.query_builder import Case, Criterion, DocType from frappe.query_builder.functions import ( @@ -212,6 +212,65 @@ def tax_account_query(doctype: str, txt: str, searchfield: str, start: int, page return tax_accounts +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def party_query( + doctype: str, + txt: str, + searchfield: str, + start: int, + page_len: int, + filters: dict | str | None = None, +): + party_name_field = {"Customer": "customer_name", "Supplier": "supplier_name"}.get(doctype) + if not party_name_field: + frappe.throw(_("Invalid party type: {0}").format(doctype)) + + filters = frappe.parse_json(filters) if filters else {} + if not isinstance(filters, dict): + frappe.throw(_("Party query filters must be a dictionary")) + + company = filters.pop("company", None) + fields = get_fields(doctype, ["name", party_name_field]) + party = DocType(doctype) + search_str = f"%{txt}%" + txt_no_percent = txt.replace("%", "") + search_fields = list(dict.fromkeys([searchfield, *fields])) + search_conditions = [party[field].like(search_str) for field in search_fields] + + query = ( + frappe.qb.get_query(doctype, fields=fields, filters=filters, ignore_permissions=False) + .where(party.docstatus < 2) + .where(Criterion.any(search_conditions)) + .orderby( + Case() + .when( + Locate(Lower(txt_no_percent), Lower(party.name)) > 0, + Locate(Lower(txt_no_percent), Lower(party.name)), + ) + .else_(99999) + ) + .orderby( + Case() + .when( + Locate(Lower(txt_no_percent), Lower(party[party_name_field])) > 0, + Locate(Lower(txt_no_percent), Lower(party[party_name_field])), + ) + .else_(99999) + ) + .orderby(party.idx, order=Order.desc) + .orderby(party.name) + .orderby(party[party_name_field]) + .limit(page_len) + .offset(start) + ) + + if company: + query = query.where(get_restriction_criterion(doctype, [company])) + + return query.run() + + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def item_query( diff --git a/erpnext/public/js/queries.js b/erpnext/public/js/queries.js index 32a4e0958c9..a329f20615e 100644 --- a/erpnext/public/js/queries.js +++ b/erpnext/public/js/queries.js @@ -12,8 +12,19 @@ $.extend(erpnext.queries, { return { query: "erpnext.controllers.queries.lead_query" }; }, - customer: function () { - return { filters: { disabled: 0 } }; + party: function (doc) { + return { + query: "erpnext.controllers.queries.party_query", + filters: { disabled: 0, company: doc.company }, + }; + }, + + customer: function (doc) { + return erpnext.queries.party(doc); + }, + + supplier: function (doc) { + return erpnext.queries.party(doc); }, item: function (filters) { diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 1968968f3c8..5a38656d31f 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -204,7 +204,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. set_dynamic_field_label() { if (this.frm.doc.quotation_to == "Customer") { this.frm.set_df_property("party_name", "label", "Customer"); - this.frm.fields_dict.party_name.get_query = null; + this.frm.fields_dict.party_name.get_query = erpnext.queries.customer; } else if (this.frm.doc.quotation_to == "Lead") { this.frm.set_df_property("party_name", "label", "Lead"); this.frm.fields_dict.party_name.get_query = function () { diff --git a/erpnext/stock/doctype/company_restriction/company_restriction.py b/erpnext/stock/doctype/company_restriction/company_restriction.py index d920796990b..f43315f6833 100644 --- a/erpnext/stock/doctype/company_restriction/company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/company_restriction.py @@ -140,16 +140,20 @@ def validate_transaction_company(doc, method=None): return for doctype, names in get_master_references(doc).items(): - if blocked := get_blocked_masters(doctype, names, company): - frappe.throw( - _("{0} {1} cannot be used with Company {2} because of Company Restrictions").format( - _(doctype), - comma_and([frappe.bold(name) for name in blocked], add_quotes=False), - frappe.bold(company), - ), - CompanyRestrictionError, - title=_("Restricted to Other Companies"), - ) + validate_masters_for_company(doctype, names, company) + + +def validate_masters_for_company(doctype, names, company): + if blocked := get_blocked_masters(doctype, names, company): + frappe.throw( + _("{0} {1} cannot be used with Company {2} because of Company Restrictions").format( + _(doctype), + comma_and([frappe.bold(name) for name in blocked], add_quotes=False), + frappe.bold(company), + ), + CompanyRestrictionError, + title=_("Restricted to Other Companies"), + ) def get_master_references(doc): diff --git a/erpnext/stock/doctype/company_restriction/test_company_restriction.py b/erpnext/stock/doctype/company_restriction/test_company_restriction.py index 52c7da50101..5f3062c353d 100644 --- a/erpnext/stock/doctype/company_restriction/test_company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/test_company_restriction.py @@ -3,8 +3,10 @@ import frappe +from erpnext.accounts.party import get_party_details from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.buying.doctype.supplier.test_supplier import create_supplier +from erpnext.controllers.queries import party_query from erpnext.selling.doctype.customer.test_customer import make_customer from erpnext.selling.doctype.quotation.test_quotation import make_quotation from erpnext.stock.doctype.company_restriction.company_restriction import CompanyRestrictionError @@ -51,6 +53,81 @@ class TestCompanyRestriction(ERPNextTestSuite): self.restrict_to_companies("Supplier", supplier.name, ["_Test Company"]) create_purchase_order(supplier=supplier.name, do_not_submit=1) + def test_party_query_filters_customer_and_supplier_by_transaction_company(self): + customer = make_customer("_Test Company Query Restricted Customer") + supplier = create_supplier(supplier_name="_Test Company Query Restricted Supplier") + + for doctype, party in (("Customer", customer), ("Supplier", supplier.name)): + self.restrict_to_companies(doctype, party, ["_Test Company 1"]) + + results = party_query( + doctype, + party, + "name", + 0, + 20, + filters={"disabled": 0, "company": "_Test Company"}, + ) + self.assertNotIn(party, [row[0] for row in results]) + + results = party_query( + doctype, + party, + "name", + 0, + 20, + filters={"disabled": 0, "company": "_Test Company 1"}, + ) + self.assertIn(party, [row[0] for row in results]) + + def test_get_party_details_checks_transaction_company_restriction(self): + customer = make_customer("_Test Party Details Restricted Customer") + supplier = create_supplier(supplier_name="_Test Party Details Restricted Supplier") + + for doctype, party in (("Customer", customer), ("Supplier", supplier.name)): + self.restrict_to_companies(doctype, party, ["_Test Company 1"]) + + self.assertRaises( + CompanyRestrictionError, + get_party_details, + party=party, + party_type=doctype, + company="_Test Company", + ) + + def test_unrestricted_party_ignores_company_permission(self): + customer = make_customer("_Test Party Details Company Permission Customer") + user = self.make_user_with_roles("test_party_details_company@example.com", ["Sales User"]) + permission = { + "user": user, + "allow": "Company", + "for_value": "_Test Company 1", + "apply_to_all_doctypes": 1, + } + if not frappe.db.exists("User Permission", permission): + frappe.get_doc({"doctype": "User Permission", **permission}).insert(ignore_permissions=True) + frappe.clear_cache(user=user) + + frappe.set_user(user) + self.addCleanup(frappe.set_user, "Administrator") + + results = party_query( + "Customer", + customer, + "name", + 0, + 20, + filters={"disabled": 0, "company": "_Test Company"}, + ) + self.assertIn(customer, [row[0] for row in results]) + + details = get_party_details( + party=customer, + party_type="Customer", + company="_Test Company", + ) + self.assertEqual(details.customer, customer) + def test_unrestricted_item_is_not_blocked(self): item = make_item() make_material_request(item_code=item.name)