From 1982816a7019a6bc6b8aa9d1c4b6ff08bcc0cdbf Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:36:48 +0530 Subject: [PATCH] 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()