From 88b8ce38887711d10c7edfb925de964f2820e997 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:13:22 +0530 Subject: [PATCH] fix: enforce company restrictions at transaction level Restrict to Companies only filtered list views and document reads, and only for users with Company user permissions. Any user could still use a master restricted to Company A in a Company B transaction, and users without Company user permissions bypassed the feature entirely. Validate on save of transactions that every linked Item, Customer and Supplier allows the transaction company, and filter item link queries by the transaction company so restricted items don't show up in the item selector. --- erpnext/controllers/queries.py | 5 ++ erpnext/hooks.py | 23 ++++++ erpnext/public/js/controllers/buying.js | 9 ++- erpnext/public/js/utils/sales_common.js | 7 +- .../company_restriction.py | 77 ++++++++++++++++++- .../test_company_restriction.py | 56 ++++++++++++++ .../material_request/material_request.js | 5 +- .../stock/doctype/stock_entry/stock_entry.js | 2 +- .../stock_reconciliation.js | 1 + 9 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 erpnext/stock/doctype/company_restriction/test_company_restriction.py diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 3cfb5a527ab..492726141ee 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -25,6 +25,7 @@ from pypika import Order import erpnext from erpnext.accounts.utils import build_qb_match_conditions +from erpnext.stock.doctype.company_restriction.company_restriction import get_restriction_criterion from erpnext.stock.get_item_details import _get_item_tax_template from erpnext.stock.utils import get_combine_datetime from erpnext.utilities.query import get_filter_conditions_qb @@ -214,6 +215,7 @@ def item_query( doctype = "Item" filters = frappe.parse_json(filters) + company = filters.pop("company", None) if isinstance(filters, dict) else None if filters and isinstance(filters, dict): if filters.get("customer") or filters.get("supplier"): @@ -361,6 +363,9 @@ def item_query( .offset(start) ) + if company: + query = query.where(get_restriction_criterion("Item", [company])) + return query.run(as_dict=as_dict) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 7459f4b0df2..35f69dae11f 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -364,6 +364,26 @@ pre_submit_validation_doctypes = [ "Sales Order", ] +company_restricted_transaction_doctypes = [ + "Quotation", + "Sales Order", + "Delivery Note", + "Sales Invoice", + "POS Invoice", + "Material Request", + "Request for Quotation", + "Supplier Quotation", + "Purchase Order", + "Purchase Receipt", + "Purchase Invoice", + "Stock Entry", + "Stock Reconciliation", + "Payment Entry", + "Journal Entry", + "Subcontracting Order", + "Subcontracting Receipt", +] + doc_events = { "*": { "validate": [ @@ -377,6 +397,9 @@ doc_events = { tuple(pre_submit_validation_doctypes): { "validate": "erpnext.accounts.utils.pre_submit_validation", }, + tuple(company_restricted_transaction_doctypes): { + "validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_transaction_company", + }, "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", diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 14b9f7f8483..f34ff7ba36c 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -91,7 +91,7 @@ erpnext.buying = { this.frm.set_query("item_code", "items", function () { if (me.frm.doc.is_subcontracted) { - var filters = { supplier: me.frm.doc.supplier }; + var filters = { supplier: me.frm.doc.supplier, company: me.frm.doc.company }; filters["is_stock_item"] = 0; return { @@ -101,7 +101,12 @@ erpnext.buying = { } else { return { query: "erpnext.controllers.queries.item_query", - filters: { supplier: me.frm.doc.supplier, is_purchase_item: 1, has_variants: 0 }, + filters: { + supplier: me.frm.doc.supplier, + is_purchase_item: 1, + has_variants: 0, + company: me.frm.doc.company, + }, }; } }); diff --git a/erpnext/public/js/utils/sales_common.js b/erpnext/public/js/utils/sales_common.js index 5dafc9c61dc..478c8481602 100644 --- a/erpnext/public/js/utils/sales_common.js +++ b/erpnext/public/js/utils/sales_common.js @@ -81,7 +81,12 @@ erpnext.sales_common = { } return { query: "erpnext.controllers.queries.item_query", - filters: { is_sales_item: 1, customer: customer, has_variants: 0 }, + filters: { + is_sales_item: 1, + customer: customer, + has_variants: 0, + company: me.frm.doc.company, + }, }; }); } diff --git a/erpnext/stock/doctype/company_restriction/company_restriction.py b/erpnext/stock/doctype/company_restriction/company_restriction.py index 2a4aee36fb2..eb9b3b602ce 100644 --- a/erpnext/stock/doctype/company_restriction/company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/company_restriction.py @@ -1,11 +1,20 @@ # Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import defaultdict + import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import comma_and from pypika.terms import Bracket, ExistsCriterion +RESTRICTABLE_MASTER_DOCTYPES = ("Item", "Customer", "Supplier") + + +class CompanyRestrictionError(frappe.ValidationError): + pass + class CompanyRestriction(Document): # begin: auto-generated types @@ -40,6 +49,10 @@ def get_permission_query_conditions(user, doctype=None): if not allowed_companies: return None + return get_restriction_criterion(doctype, allowed_companies) + + +def get_restriction_criterion(doctype, companies): parent = frappe.qb.DocType(doctype) restriction = frappe.qb.DocType("Company Restriction") allowed_rows = ( @@ -49,7 +62,7 @@ def get_permission_query_conditions(user, doctype=None): (restriction.parenttype == doctype) & (restriction.parentfield == "allowed_companies") & (restriction.parent == parent.name) - & (restriction.company.isin(allowed_companies)) + & (restriction.company.isin(companies)) ) ) return Bracket((parent.restrict_to_companies == 0) | ExistsCriterion(allowed_rows)) @@ -95,6 +108,68 @@ def validate_allowed_companies(doc): ) +def validate_transaction_company(doc, method=None): + company = doc.get("company") + if not company: + 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"), + ) + + +def get_master_references(doc): + references = defaultdict(set) + collect_master_references(doc, references) + for table_field in doc.meta.get_table_fields(): + for row in doc.get(table_field.fieldname) or []: + collect_master_references(row, references) + + return references + + +def collect_master_references(row, references): + meta = frappe.get_meta(row.doctype) + for field in meta.get_link_fields(): + if field.options in RESTRICTABLE_MASTER_DOCTYPES and (value := row.get(field.fieldname)): + references[field.options].add(value) + + for field in meta.get_dynamic_link_fields(): + doctype = row.get(field.options) + if doctype in RESTRICTABLE_MASTER_DOCTYPES and (value := row.get(field.fieldname)): + references[doctype].add(value) + + +def get_blocked_masters(doctype, names, company): + restricted = frappe.get_all( + doctype, + filters={"name": ("in", sorted(names)), "restrict_to_companies": 1}, + pluck="name", + ) + if not restricted: + return [] + + allowed = frappe.get_all( + "Company Restriction", + filters={ + "parenttype": doctype, + "parentfield": "allowed_companies", + "parent": ("in", restricted), + "company": company, + }, + pluck="parent", + ) + return sorted(set(restricted) - set(allowed)) + + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def company_query( diff --git a/erpnext/stock/doctype/company_restriction/test_company_restriction.py b/erpnext/stock/doctype/company_restriction/test_company_restriction.py new file mode 100644 index 00000000000..e29bb329a1b --- /dev/null +++ b/erpnext/stock/doctype/company_restriction/test_company_restriction.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe + +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.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 +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.tests.utils import ERPNextTestSuite + + +class TestCompanyRestriction(ERPNextTestSuite): + def restrict_to_companies(self, doctype, name, companies): + doc = frappe.get_doc(doctype, name) + doc.restrict_to_companies = 1 + doc.set("allowed_companies", []) + for company in companies: + doc.append("allowed_companies", {"company": company}) + doc.save() + + def test_restricted_item_blocks_transaction_in_other_company(self): + item = make_item() + self.restrict_to_companies("Item", item.name, ["_Test Company 1"]) + + self.assertRaises(CompanyRestrictionError, make_material_request, item_code=item.name) + + self.restrict_to_companies("Item", item.name, ["_Test Company 1", "_Test Company"]) + make_material_request(item_code=item.name) + + def test_restricted_customer_blocks_transaction_in_other_company(self): + customer = make_customer("_Test Company Restricted Customer") + self.restrict_to_companies("Customer", customer, ["_Test Company 1"]) + + self.assertRaises(CompanyRestrictionError, make_quotation, party_name=customer, do_not_submit=1) + + self.restrict_to_companies("Customer", customer, ["_Test Company"]) + make_quotation(party_name=customer, do_not_submit=1) + + def test_restricted_supplier_blocks_transaction_in_other_company(self): + supplier = create_supplier(supplier_name="_Test Company Restricted Supplier") + self.restrict_to_companies("Supplier", supplier.name, ["_Test Company 1"]) + + self.assertRaises( + CompanyRestrictionError, create_purchase_order, supplier=supplier.name, do_not_submit=1 + ) + + self.restrict_to_companies("Supplier", supplier.name, ["_Test Company"]) + create_purchase_order(supplier=supplier.name, do_not_submit=1) + + def test_unrestricted_item_is_not_blocked(self): + item = make_item() + make_material_request(item_code=item.name) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index f9fee795c2b..b5a8c0560cd 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -22,9 +22,10 @@ frappe.ui.form.on("Material Request", { return doc.stock_qty <= doc.ordered_qty ? "green" : "orange"; }); - frm.set_query("item_code", "items", function () { + frm.set_query("item_code", "items", function (doc) { return { query: "erpnext.controllers.queries.item_query", + filters: { company: doc.company }, }; }); @@ -604,7 +605,7 @@ erpnext.buying.MaterialRequestController = class MaterialRequestController exten onload() { this.frm.set_query("item_code", "items", function (doc, cdt, cdn) { - let filters = { is_stock_item: 1 }; + let filters = { is_stock_item: 1, company: doc.company }; if (doc.material_request_type == "Customer Provided") { filters.customer = doc.customer; diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 0c95e192032..1d19e4b50af 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -1238,7 +1238,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle }; this.frm.fields_dict.items.grid.get_field("item_code").get_query = function () { - return erpnext.queries.item({ is_stock_item: 1 }); + return erpnext.queries.item({ is_stock_item: 1, company: me.frm.doc.company }); }; this.frm.set_query("subcontracting_order", function () { diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index 3cbd52ffa22..38e7d3a8f8a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -22,6 +22,7 @@ frappe.ui.form.on("Stock Reconciliation", { query: "erpnext.controllers.queries.item_query", filters: { is_stock_item: 1, + company: doc.company, }, }; });