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/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..d19be15485c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -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", 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/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 2a4aee36fb2..9f19263b2a4 100644 --- a/erpnext/stock/doctype/company_restriction/company_restriction.py +++ b/erpnext/stock/doctype/company_restriction/company_restriction.py @@ -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( 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..28b1680c7c1 --- /dev/null +++ b/erpnext/stock/doctype/company_restriction/test_company_restriction.py @@ -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() 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") 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, }, }; });