Merge pull request #57352 from mihir-kandoi/company-restriction-transaction-enforcement

fix: enforce company restrictions at transaction level
This commit is contained in:
Mihir Kandoi
2026-07-22 14:52:54 +05:30
committed by GitHub
12 changed files with 211 additions and 14 deletions

View File

@@ -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):

View File

@@ -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)

View File

@@ -369,6 +369,7 @@ 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): {
@@ -377,6 +378,9 @@ doc_events = {
tuple(pre_submit_validation_doctypes): {
"validate": "erpnext.accounts.utils.pre_submit_validation",
},
("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",

View File

@@ -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,
},
};
}
});

View File

@@ -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,
},
};
});
}

View File

@@ -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()):

View File

@@ -1,11 +1,39 @@
# 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")
COMPANY_RESTRICTION_EXEMPT_DOCTYPES = frozenset(
{
"Asset",
"Bank Transaction",
"Exchange Rate Revaluation",
"Landed Cost Voucher",
"POS Closing Entry",
"POS Invoice Merge Log",
"Payment Reconciliation",
"Process Payment Reconciliation",
"Repost Accounting Ledger",
"Repost Item Valuation",
"Repost Payment Ledger",
"Serial No",
"Serial and Batch Bundle",
"Unreconcile Payment",
}
)
class CompanyRestrictionError(frappe.ValidationError):
pass
class CompanyRestriction(Document):
# begin: auto-generated types
@@ -40,6 +68,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 +81,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))
@@ -66,7 +98,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:
@@ -95,6 +127,75 @@ def validate_allowed_companies(doc):
)
def validate_transaction_company(doc, method=None):
if doc.doctype in COMPANY_RESTRICTION_EXEMPT_DOCTYPES or doc.meta.in_create:
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
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(

View File

@@ -0,0 +1,81 @@
# 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)
def test_allowed_companies_is_mandatory_when_restricted(self):
item = make_item()
item.restrict_to_companies = 1
self.assertRaises(frappe.MandatoryError, item.save)
def test_exempt_doctypes_exist(self):
from erpnext.stock.doctype.company_restriction.company_restriction import (
COMPANY_RESTRICTION_EXEMPT_DOCTYPES,
)
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()

View File

@@ -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")

View File

@@ -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;

View File

@@ -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 () {

View File

@@ -22,6 +22,7 @@ frappe.ui.form.on("Stock Reconciliation", {
query: "erpnext.controllers.queries.item_query",
filters: {
is_stock_item: 1,
company: doc.company,
},
};
});