From 88b8ce38887711d10c7edfb925de964f2820e997 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:13:22 +0530 Subject: [PATCH 1/5] 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, }, }; }); From 01892c2e268604967597db33482b2ea8ba17e62a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:17:32 +0530 Subject: [PATCH 2/5] refactor: hook validate_allowed_companies instead of calling per master Item, Customer and Supplier each imported and called it in their validate; register it once in doc_events instead. --- erpnext/buying/doctype/supplier/supplier.py | 2 -- erpnext/hooks.py | 3 +++ erpnext/selling/doctype/customer/customer.py | 2 -- .../stock/doctype/company_restriction/company_restriction.py | 2 +- .../doctype/company_restriction/test_company_restriction.py | 5 +++++ erpnext/stock/doctype/item/item.py | 2 -- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index e36b9c05546..4e138721f77 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -20,7 +20,6 @@ from erpnext.controllers.website_list_for_contact import ( add_role_for_portal_user, link_portal_users_to_contacts, ) -from erpnext.stock.doctype.company_restriction.company_restriction import validate_allowed_companies from erpnext.utilities.transaction_base import TransactionBase @@ -154,7 +153,6 @@ class Supplier(TransactionBase): self.validate_internal_supplier() self.add_role_for_user() self.validate_currency_for_receivable_payable_and_advance_account() - validate_allowed_companies(self) @frappe.whitelist() def get_supplier_group_details(self): diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 35f69dae11f..54ab7f03ea0 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -400,6 +400,9 @@ doc_events = { tuple(company_restricted_transaction_doctypes): { "validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_transaction_company", }, + ("Item", "Customer", "Supplier"): { + "validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_allowed_companies", + }, "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/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 6ff2b49a33e..2d7a562715f 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -28,7 +28,6 @@ from erpnext.controllers.website_list_for_contact import ( add_role_for_portal_user, link_portal_users_to_contacts, ) -from erpnext.stock.doctype.company_restriction.company_restriction import validate_allowed_companies from erpnext.utilities.transaction_base import TransactionBase from .mapper import ( @@ -193,7 +192,6 @@ class Customer(TransactionBase): self.validate_internal_customer() self.add_role_for_user() self.validate_currency_for_receivable_payable_and_advance_account() - validate_allowed_companies(self) # set loyalty program tier if not self.is_new() and (customer := self.get_doc_before_save()): diff --git a/erpnext/stock/doctype/company_restriction/company_restriction.py b/erpnext/stock/doctype/company_restriction/company_restriction.py index eb9b3b602ce..e02dafac024 100644 --- a/erpnext/stock/doctype/company_restriction/company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/company_restriction.py @@ -79,7 +79,7 @@ def has_permission(doc, ptype=None, user=None): return any(row.company in allowed_companies for row in doc.get("allowed_companies") or []) -def validate_allowed_companies(doc): +def validate_allowed_companies(doc, method=None): if not doc.get("restrict_to_companies"): doc.set("allowed_companies", []) elif not doc.get("allowed_companies") and not doc.flags.ignore_mandatory: diff --git a/erpnext/stock/doctype/company_restriction/test_company_restriction.py b/erpnext/stock/doctype/company_restriction/test_company_restriction.py index e29bb329a1b..d33f5791830 100644 --- a/erpnext/stock/doctype/company_restriction/test_company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/test_company_restriction.py @@ -54,3 +54,8 @@ class TestCompanyRestriction(ERPNextTestSuite): def test_unrestricted_item_is_not_blocked(self): item = make_item() make_material_request(item_code=item.name) + + def test_allowed_companies_is_mandatory_when_restricted(self): + item = make_item() + item.restrict_to_companies = 1 + self.assertRaises(frappe.MandatoryError, item.save) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index e00fe9c8fd8..8da6652d1bb 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -30,7 +30,6 @@ from erpnext.controllers.item_variant import ( make_variant_item_code, validate_item_variant_attributes, ) -from erpnext.stock.doctype.company_restriction.company_restriction import validate_allowed_companies from erpnext.stock.doctype.item_default.item_default import ItemDefault from erpnext.stock.serial_batch_bundle import SerialBatchCreation from erpnext.stock.utils import get_valuation_method @@ -246,7 +245,6 @@ class Item(Document): self.validate_serialized_change_with_bundle() self.validate_standard_cost_change() self.validate_item_tax_net_rate_range() - validate_allowed_companies(self) if not self.is_new(): self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") From ce01fa0e34e7c915174afa73be678fc77530d2e4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:28:13 +0530 Subject: [PATCH 3/5] fix: cover manufacturing, logistics, asset and service doctypes Extend company restriction enforcement to the remaining user-entered transactions (BOM, Work Order, Job Card, Production Plan, Pick List, Blanket Order, asset and maintenance documents). Ledger and repost doctypes stay excluded so cancelling or reposting older documents keeps working after a restriction changes. --- erpnext/hooks.py | 13 +++++++++++++ .../company_restriction/test_company_restriction.py | 8 ++++++++ 2 files changed, 21 insertions(+) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 54ab7f03ea0..73b72334b55 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -382,6 +382,19 @@ company_restricted_transaction_doctypes = [ "Journal Entry", "Subcontracting Order", "Subcontracting Receipt", + "BOM", + "Work Order", + "Job Card", + "Production Plan", + "Pick List", + "Blanket Order", + "Asset Capitalization", + "Asset Repair", + "Dunning", + "Installation Note", + "Maintenance Schedule", + "Maintenance Visit", + "Warranty Claim", ] doc_events = { diff --git a/erpnext/stock/doctype/company_restriction/test_company_restriction.py b/erpnext/stock/doctype/company_restriction/test_company_restriction.py index d33f5791830..811ce58bb40 100644 --- a/erpnext/stock/doctype/company_restriction/test_company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/test_company_restriction.py @@ -59,3 +59,11 @@ class TestCompanyRestriction(ERPNextTestSuite): item = make_item() item.restrict_to_companies = 1 self.assertRaises(frappe.MandatoryError, item.save) + + def test_hooked_doctypes_have_company_field(self): + from erpnext.hooks import company_restricted_transaction_doctypes + + for doctype in company_restricted_transaction_doctypes: + self.assertTrue( + frappe.get_meta(doctype).has_field("company"), f"{doctype} has no company field" + ) From 1982816a7019a6bc6b8aa9d1c4b6ff08bcc0cdbf Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:36:48 +0530 Subject: [PATCH 4/5] refactor: enforce company restrictions on any doctype with a Company link Replace the manually maintained transaction allowlist with a wildcard validate hook: any doctype carrying a Company link field is checked, so new doctypes are covered automatically. System-managed doctypes (ledger entries, reposts, bundles, bins, POS consolidation, bank feeds) are exempted so cancel, repost and reconciliation of documents created before a restriction changed keep working; that guarantee is pinned by a cancel-after-restriction test. --- erpnext/hooks.py | 37 +------------------ .../company_restriction.py | 34 +++++++++++++++++ .../test_company_restriction.py | 24 +++++++++--- 3 files changed, 53 insertions(+), 42 deletions(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 73b72334b55..d19be15485c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -364,44 +364,12 @@ 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", - "BOM", - "Work Order", - "Job Card", - "Production Plan", - "Pick List", - "Blanket Order", - "Asset Capitalization", - "Asset Repair", - "Dunning", - "Installation Note", - "Maintenance Schedule", - "Maintenance Visit", - "Warranty Claim", -] - doc_events = { "*": { "validate": [ "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply", "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job", + "erpnext.stock.doctype.company_restriction.company_restriction.validate_transaction_company", ], }, tuple(period_closing_doctypes): { @@ -410,9 +378,6 @@ 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", - }, ("Item", "Customer", "Supplier"): { "validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_allowed_companies", }, diff --git a/erpnext/stock/doctype/company_restriction/company_restriction.py b/erpnext/stock/doctype/company_restriction/company_restriction.py index e02dafac024..c45f4f495f9 100644 --- a/erpnext/stock/doctype/company_restriction/company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/company_restriction.py @@ -11,6 +11,33 @@ from pypika.terms import Bracket, ExistsCriterion RESTRICTABLE_MASTER_DOCTYPES = ("Item", "Customer", "Supplier") +COMPANY_RESTRICTION_EXEMPT_DOCTYPES = frozenset( + { + "Advance Payment Ledger Entry", + "Asset", + "Bank Transaction", + "Bin", + "Exchange Rate Revaluation", + "GL Entry", + "Landed Cost Voucher", + "Loyalty Point Entry", + "POS Closing Entry", + "POS Invoice Merge Log", + "Payment Ledger Entry", + "Payment Reconciliation", + "Process Payment Reconciliation", + "Repost Accounting Ledger", + "Repost Item Valuation", + "Repost Payment Ledger", + "Serial No", + "Serial and Batch Bundle", + "Stock Closing Balance", + "Stock Ledger Entry", + "Stock Reservation Entry", + "Unreconcile Payment", + } +) + class CompanyRestrictionError(frappe.ValidationError): pass @@ -109,6 +136,13 @@ def validate_allowed_companies(doc, method=None): def validate_transaction_company(doc, method=None): + if doc.doctype in COMPANY_RESTRICTION_EXEMPT_DOCTYPES: + return + + company_field = doc.meta.get_field("company") + if not company_field or company_field.fieldtype != "Link" or company_field.options != "Company": + return + company = doc.get("company") if not company: return diff --git a/erpnext/stock/doctype/company_restriction/test_company_restriction.py b/erpnext/stock/doctype/company_restriction/test_company_restriction.py index 811ce58bb40..28b1680c7c1 100644 --- a/erpnext/stock/doctype/company_restriction/test_company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/test_company_restriction.py @@ -60,10 +60,22 @@ class TestCompanyRestriction(ERPNextTestSuite): item.restrict_to_companies = 1 self.assertRaises(frappe.MandatoryError, item.save) - def test_hooked_doctypes_have_company_field(self): - from erpnext.hooks import company_restricted_transaction_doctypes + def test_exempt_doctypes_exist(self): + from erpnext.stock.doctype.company_restriction.company_restriction import ( + COMPANY_RESTRICTION_EXEMPT_DOCTYPES, + ) - for doctype in company_restricted_transaction_doctypes: - self.assertTrue( - frappe.get_meta(doctype).has_field("company"), f"{doctype} has no company field" - ) + for doctype in COMPANY_RESTRICTION_EXEMPT_DOCTYPES: + self.assertTrue(frappe.db.exists("DocType", doctype), f"{doctype} is not a DocType") + + def test_cancel_works_after_restriction_change(self): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item = make_item() + stock_entry = make_stock_entry( + item_code=item.name, qty=5, to_warehouse="_Test Warehouse - _TC", rate=100 + ) + + self.restrict_to_companies("Item", item.name, ["_Test Company 1"]) + stock_entry.reload() + stock_entry.cancel() From e3ec8d2975fadfcdcce84e5a7f3dd9d2115d70ee Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:40:27 +0530 Subject: [PATCH 5/5] refactor: exempt system doctypes via in_create flag Doctypes marked In Create (GL Entry, Stock Ledger Entry, Bin, ledger entries) are system-created by definition, so derive their exemption from meta instead of listing them. --- .../doctype/company_restriction/company_restriction.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/erpnext/stock/doctype/company_restriction/company_restriction.py b/erpnext/stock/doctype/company_restriction/company_restriction.py index c45f4f495f9..9f19263b2a4 100644 --- a/erpnext/stock/doctype/company_restriction/company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/company_restriction.py @@ -13,17 +13,12 @@ RESTRICTABLE_MASTER_DOCTYPES = ("Item", "Customer", "Supplier") COMPANY_RESTRICTION_EXEMPT_DOCTYPES = frozenset( { - "Advance Payment Ledger Entry", "Asset", "Bank Transaction", - "Bin", "Exchange Rate Revaluation", - "GL Entry", "Landed Cost Voucher", - "Loyalty Point Entry", "POS Closing Entry", "POS Invoice Merge Log", - "Payment Ledger Entry", "Payment Reconciliation", "Process Payment Reconciliation", "Repost Accounting Ledger", @@ -31,9 +26,6 @@ COMPANY_RESTRICTION_EXEMPT_DOCTYPES = frozenset( "Repost Payment Ledger", "Serial No", "Serial and Batch Bundle", - "Stock Closing Balance", - "Stock Ledger Entry", - "Stock Reservation Entry", "Unreconcile Payment", } ) @@ -136,7 +128,7 @@ def validate_allowed_companies(doc, method=None): def validate_transaction_company(doc, method=None): - if doc.doctype in COMPANY_RESTRICTION_EXEMPT_DOCTYPES: + if doc.doctype in COMPANY_RESTRICTION_EXEMPT_DOCTYPES or doc.meta.in_create: return company_field = doc.meta.get_field("company")