From b6cce627a8cfd7bfac24d5402cac2a84314ffc1a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 14 Jul 2026 16:00:47 +0530 Subject: [PATCH] feat: company-wise restriction for Item, Customer and Supplier masters (#57124) --- erpnext/buying/doctype/supplier/supplier.js | 3 + erpnext/buying/doctype/supplier/supplier.json | 18 ++- erpnext/buying/doctype/supplier/supplier.py | 4 + erpnext/hooks.py | 12 ++ erpnext/selling/doctype/customer/customer.js | 3 + .../selling/doctype/customer/customer.json | 25 +++- erpnext/selling/doctype/customer/customer.py | 4 + .../global_defaults/global_defaults.json | 33 ++++- .../global_defaults/global_defaults.py | 2 + .../doctype/company_restriction/__init__.py | 0 .../company_restriction.json | 39 ++++++ .../company_restriction.py | 116 ++++++++++++++++++ erpnext/stock/doctype/item/item.js | 3 + erpnext/stock/doctype/item/item.json | 31 +++-- erpnext/stock/doctype/item/item.py | 4 + 15 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 erpnext/stock/doctype/company_restriction/__init__.py create mode 100644 erpnext/stock/doctype/company_restriction/company_restriction.json create mode 100644 erpnext/stock/doctype/company_restriction/company_restriction.py diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index 4d2d64cfcc1..acdbed969e8 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -3,6 +3,9 @@ frappe.ui.form.on("Supplier", { setup: function (frm) { + frm.set_query("allowed_companies", () => ({ + query: "erpnext.stock.doctype.company_restriction.company_restriction.company_query", + })); frm.set_query("default_price_list", { buying: 1 }); if (frm.doc.__islocal == 1) { frm.set_value("represents_company", ""); diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index 12a40cbca7b..caee355c57c 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -54,6 +54,8 @@ "tax_withholding_category", "tax_withholding_group", "settings_tab", + "company_restrictions_section", + "allowed_companies", "invoice_settings_section", "is_transporter", "allow_purchase_invoice_creation_without_purchase_order", @@ -425,6 +427,20 @@ "fieldtype": "Tab Break", "label": "Settings" }, + { + "fieldname": "company_restrictions_section", + "fieldtype": "Section Break", + "label": "Company Restrictions", + "description": "If set, this Supplier is only available for transactions in the listed companies. Leave empty for no restriction.", + "depends_on": "eval:cint(frappe.sys_defaults.enable_company_wise_masters)" + }, + { + "fieldname": "allowed_companies", + "fieldtype": "Table MultiSelect", + "label": "Allowed Companies", + "options": "Company Restriction", + "depends_on": "eval:cint(frappe.sys_defaults.enable_company_wise_masters)" + }, { "fieldname": "contact_and_address_tab", "fieldtype": "Tab Break", @@ -562,7 +578,7 @@ "link_fieldname": "party" } ], - "modified": "2026-06-27 16:12:33.190257", + "modified": "2026-07-14 21:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Supplier", diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 1de54ed9313..dfed0be4198 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -17,6 +17,7 @@ from erpnext.accounts.party import ( validate_party_currency_before_merging, ) from erpnext.controllers.website_list_for_contact import add_role_for_portal_user +from erpnext.stock.doctype.company_restriction.company_restriction import validate_allowed_companies from erpnext.utilities.transaction_base import TransactionBase @@ -36,12 +37,14 @@ class Supplier(TransactionBase): from erpnext.buying.doctype.customer_number_at_supplier.customer_number_at_supplier import ( CustomerNumberAtSupplier, ) + from erpnext.stock.doctype.company_restriction.company_restriction import CompanyRestriction from erpnext.utilities.doctype.portal_user.portal_user import PortalUser accounts: DF.Table[PartyAccount] alias: DF.Data | None allow_purchase_invoice_creation_without_purchase_order: DF.Check allow_purchase_invoice_creation_without_purchase_receipt: DF.Check + allowed_companies: DF.TableMultiSelect[CompanyRestriction] companies: DF.Table[AllowedToTransactWith] country: DF.Link | None customer_numbers: DF.Table[CustomerNumberAtSupplier] @@ -146,6 +149,7 @@ 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 e783d9e0fc4..38fb883d3a3 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -307,6 +307,18 @@ sounds = [ has_upload_permission = {"Employee": "erpnext.setup.doctype.employee.employee.has_upload_permission"} +permission_query_conditions = { + "Item": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions", + "Customer": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions", + "Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions", +} + +has_permission = { + "Item": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission", + "Customer": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission", + "Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission", +} + has_website_permission = { "Sales Order": "erpnext.controllers.website_list_for_contact.has_website_permission", "Quotation": "erpnext.controllers.website_list_for_contact.has_website_permission", diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index a21cc00b991..5ee6dd871c2 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -3,6 +3,9 @@ frappe.ui.form.on("Customer", { setup: function (frm) { + frm.set_query("allowed_companies", () => ({ + query: "erpnext.stock.doctype.company_restriction.company_restriction.company_query", + })); frm.custom_make_buttons = { Opportunity: "Opportunity", Quotation: "Quotation", diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 6dd308d319d..848d99e6a8f 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -4,7 +4,7 @@ "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", - "creation": "2013-06-11 14:26:44", + "creation": "2026-07-14 12:46:50.256889", "description": "Buyer of Goods and Services.", "doctype": "DocType", "document_type": "Setup", @@ -65,6 +65,9 @@ "tax_withholding_group", "tax_withholding_category", "settings_tab", + "company_restrictions_section", + "allowed_companies", + "section_break_ario", "so_required", "dn_required", "column_break_53", @@ -512,6 +515,20 @@ "fieldtype": "Tab Break", "label": "Settings" }, + { + "description": "If set, this Customer is only available for transactions in the listed companies. Leave empty for no restriction.", + "fieldname": "company_restrictions_section", + "fieldtype": "Section Break", + "label": "Company Restrictions", + "depends_on": "eval:cint(frappe.sys_defaults.enable_company_wise_masters)" + }, + { + "fieldname": "allowed_companies", + "fieldtype": "Table MultiSelect", + "label": "Allowed Companies", + "options": "Company Restriction", + "depends_on": "eval:cint(frappe.sys_defaults.enable_company_wise_masters)" + }, { "collapsible": 1, "collapsible_depends_on": "default_sales_partner", @@ -683,6 +700,10 @@ "label": "Alias", "no_copy": 1, "unique": 1 + }, + { + "fieldname": "section_break_ario", + "fieldtype": "Section Break" } ], "icon": "fa fa-user", @@ -696,7 +717,7 @@ "link_fieldname": "party" } ], - "modified": "2026-06-27 16:12:10.457900", + "modified": "2026-07-14 21:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Customer", diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index fd16c5d7aed..064e3068716 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -25,6 +25,7 @@ from erpnext.accounts.party import ( validate_party_currency_before_merging, ) from erpnext.controllers.website_list_for_contact import add_role_for_portal_user +from erpnext.stock.doctype.company_restriction.company_restriction import validate_allowed_companies from erpnext.utilities.transaction_base import TransactionBase from .mapper import ( @@ -51,11 +52,13 @@ class Customer(TransactionBase): from erpnext.selling.doctype.supplier_number_at_customer.supplier_number_at_customer import ( SupplierNumberAtCustomer, ) + from erpnext.stock.doctype.company_restriction.company_restriction import CompanyRestriction from erpnext.utilities.doctype.portal_user.portal_user import PortalUser account_manager: DF.Link | None accounts: DF.Table[PartyAccount] alias: DF.Data | None + allowed_companies: DF.TableMultiSelect[CompanyRestriction] companies: DF.Table[AllowedToTransactWith] credit_limits: DF.Table[CustomerCreditLimit] customer_details: DF.Text | None @@ -186,6 +189,7 @@ 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/setup/doctype/global_defaults/global_defaults.json b/erpnext/setup/doctype/global_defaults/global_defaults.json index 55ff08d21fe..908da5ff912 100644 --- a/erpnext/setup/doctype/global_defaults/global_defaults.json +++ b/erpnext/setup/doctype/global_defaults/global_defaults.json @@ -5,16 +5,20 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "defaults_section", "default_company", "country", - "default_distance_unit", "column_break_8", "default_currency", + "default_distance_unit", + "demo_company", + "general_settings_section", "hide_currency_symbol", "disable_rounded_total", "disable_in_words", + "column_break_hnew", "use_posting_datetime_for_naming_documents", - "demo_company" + "enable_company_wise_masters" ], "fields": [ { @@ -27,7 +31,7 @@ { "fieldname": "country", "fieldtype": "Link", - "label": "Country", + "label": "Default Country", "options": "Country" }, { @@ -88,6 +92,27 @@ "fieldname": "use_posting_datetime_for_naming_documents", "fieldtype": "Check", "label": "Use Posting Datetime for Naming Documents" + }, + { + "default": "0", + "description": "When enabled, Supplier, Customer, and Item records can be restricted to specific companies via their Allowed Companies table. Transactions will only show masters configured for the selected company.", + "fieldname": "enable_company_wise_masters", + "fieldtype": "Check", + "label": "Enable Company-wise Master Filtering" + }, + { + "fieldname": "defaults_section", + "fieldtype": "Section Break", + "label": "Defaults" + }, + { + "fieldname": "general_settings_section", + "fieldtype": "Section Break", + "label": "General Settings" + }, + { + "fieldname": "column_break_hnew", + "fieldtype": "Column Break" } ], "grid_page_length": 50, @@ -96,7 +121,7 @@ "in_create": 1, "issingle": 1, "links": [], - "modified": "2026-07-14 13:37:46.177444", + "modified": "2026-07-14 15:18:25.829886", "modified_by": "Administrator", "module": "Setup", "name": "Global Defaults", diff --git a/erpnext/setup/doctype/global_defaults/global_defaults.py b/erpnext/setup/doctype/global_defaults/global_defaults.py index a85b04530b0..911888b095f 100644 --- a/erpnext/setup/doctype/global_defaults/global_defaults.py +++ b/erpnext/setup/doctype/global_defaults/global_defaults.py @@ -18,6 +18,7 @@ keydict = { "account_url": "account_url", "disable_rounded_total": "disable_rounded_total", "disable_in_words": "disable_in_words", + "enable_company_wise_masters": "enable_company_wise_masters", } ROUNDED_TOTAL_DOCTYPES = ( @@ -51,6 +52,7 @@ class GlobalDefaults(Document): demo_company: DF.Link | None disable_in_words: DF.Check disable_rounded_total: DF.Check + enable_company_wise_masters: DF.Check hide_currency_symbol: DF.Literal["", "No", "Yes"] use_posting_datetime_for_naming_documents: DF.Check # end: auto-generated types diff --git a/erpnext/stock/doctype/company_restriction/__init__.py b/erpnext/stock/doctype/company_restriction/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/company_restriction/company_restriction.json b/erpnext/stock/doctype/company_restriction/company_restriction.json new file mode 100644 index 00000000000..2c7c0c804cf --- /dev/null +++ b/erpnext/stock/doctype/company_restriction/company_restriction.json @@ -0,0 +1,39 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "creation": "2026-07-13 21:39:49.805859", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company" + ], + "fields": [ + { + "allow_on_submit": 1, + "fieldname": "company", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-14 00:15:00.000000", + "modified_by": "Administrator", + "module": "Stock", + "name": "Company Restriction", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/stock/doctype/company_restriction/company_restriction.py b/erpnext/stock/doctype/company_restriction/company_restriction.py new file mode 100644 index 00000000000..6b995e75a33 --- /dev/null +++ b/erpnext/stock/doctype/company_restriction/company_restriction.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from pypika.terms import Bracket, ExistsCriterion + + +class CompanyRestriction(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + company: DF.Link + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + +def get_allowed_companies(user, doctype): + from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions + + if not frappe.get_single_value("Global Defaults", "enable_company_wise_masters"): + return None + + user_permissions = get_user_permissions(user or frappe.session.user) + if "Company" not in user_permissions: + return None + return get_allowed_docs_for_doctype(user_permissions["Company"], doctype) or None + + +def get_permission_query_conditions(user, doctype=None): + if not doctype: + return None + + allowed_companies = get_allowed_companies(user, doctype) + if not allowed_companies: + return None + + parent = frappe.qb.DocType(doctype) + restriction = frappe.qb.DocType("Company Restriction") + restriction_rows = ( + frappe.qb.from_(restriction) + .select(restriction.name) + .where( + (restriction.parenttype == doctype) + & (restriction.parentfield == "allowed_companies") + & (restriction.parent == parent.name) + ) + ) + allowed_rows = restriction_rows.where(restriction.company.isin(allowed_companies)) + return Bracket(ExistsCriterion(allowed_rows) | ExistsCriterion(restriction_rows).negate()) + + +def has_permission(doc, ptype=None, user=None): + allowed_companies = get_allowed_companies(user, doc.doctype) + if not allowed_companies: + return True + + companies = [row.company for row in doc.get("allowed_companies") or []] + if not companies: + return True + return any(company in allowed_companies for company in companies) + + +def validate_allowed_companies(doc): + if doc.flags.ignore_permissions: + return + + allowed_companies = get_allowed_companies(frappe.session.user, doc.doctype) + if not allowed_companies: + return + + previous_companies = set() + if previous_doc := doc.get_doc_before_save(): + previous_companies = {row.company for row in previous_doc.get("allowed_companies") or []} + + current_companies = {row.company for row in doc.get("allowed_companies") or []} + for company in current_companies.symmetric_difference(previous_companies): + if company not in allowed_companies: + frappe.throw( + _("You are not permitted to add or remove Company {0} in Allowed Companies").format(company), + frappe.PermissionError, + ) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def company_query( + doctype: str, + txt: str, + searchfield: str, + start: int, + page_len: int, + filters: dict | str | None = None, +): + filters = frappe.parse_json(filters) if filters else {} + if isinstance(filters, list): + filters.append(["Company", "name", "like", f"%{txt}%"]) + else: + filters["name"] = ("like", f"%{txt}%") + + return frappe.get_list( + "Company", + filters=filters, + limit_start=start, + limit_page_length=page_len, + order_by="name", + as_list=True, + ) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index b20f53f74e7..eb8034b57c4 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -59,6 +59,9 @@ frappe.ui.form.on("Item", { }, setup: function (frm) { + frm.set_query("allowed_companies", () => ({ + query: "erpnext.stock.doctype.company_restriction.company_restriction.company_query", + })); frm.add_fetch("attribute", "numeric_values", "numeric_values"); frm.add_fetch("attribute", "from_range", "from_range"); frm.add_fetch("attribute", "to_range", "to_range"); diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 6da7ec333b9..81975cd50f1 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -3,7 +3,7 @@ "allow_import": 1, "allow_rename": 1, "autoname": "field:item_code", - "creation": "2026-02-02 14:41:23.105228", + "creation": "2026-07-13 23:00:47.512490", "description": "A Product or a Service that is bought, sold or kept in stock.", "doctype": "DocType", "document_type": "Setup", @@ -40,6 +40,8 @@ "over_delivery_receipt_allowance", "column_break_wugd", "over_billing_allowance", + "company_restrictions_section", + "allowed_companies", "section_break_11", "brand", "description", @@ -240,7 +242,6 @@ "description": "ERPNext will make a stock ledger entry for each transaction of this item. Keep unchecked for non-stock or service items.", "fieldname": "is_stock_item", "fieldtype": "Check", - "in_list_view": 0, "label": "Maintain Stock", "oldfieldname": "is_stock_item", "oldfieldtype": "Select", @@ -281,9 +282,9 @@ "description": "Enable if this item is a company asset like machinery or furniture.", "fieldname": "is_fixed_asset", "fieldtype": "Check", + "in_list_view": 1, "label": "Is Fixed Asset", - "read_only_depends_on": "eval:doc.is_stock_item", - "in_list_view": 1 + "read_only_depends_on": "eval:doc.is_stock_item" }, { "allow_in_quick_entry": 1, @@ -596,7 +597,7 @@ "oldfieldtype": "Currency" }, { - "description": "Minimum stock level to maintain as a buffer. Used to calculate recommended reorder level: Reorder Level = Safety Stock + (Average Daily Consumption × Lead Time).", + "description": "Minimum stock level to maintain as a buffer. Used to calculate recommended reorder level: Reorder Level = Safety Stock + (Average Daily Consumption \u00d7 Lead Time).", "fieldname": "safety_stock", "fieldtype": "Float", "label": "Safety Stock", @@ -699,9 +700,9 @@ "description": "Allow this item to be used in sales transactions.", "fieldname": "is_sales_item", "fieldtype": "Check", + "in_list_view": 1, "label": "Allow Sales", - "show_description_on_click": 1, - "in_list_view": 1 + "show_description_on_click": 1 }, { "fieldname": "column_break3", @@ -1084,6 +1085,20 @@ "fieldname": "item_prices_column", "fieldtype": "Column Break", "label": "Item Prices" + }, + { + "fieldname": "company_restrictions_section", + "fieldtype": "Section Break", + "label": "Company Restrictions", + "description": "If set, this Item is only available for transactions in the listed companies. Leave empty for no restriction.", + "depends_on": "eval:cint(frappe.sys_defaults.enable_company_wise_masters)" + }, + { + "fieldname": "allowed_companies", + "fieldtype": "Table MultiSelect", + "label": "Allowed Companies", + "options": "Company Restriction", + "depends_on": "eval:cint(frappe.sys_defaults.enable_company_wise_masters)" } ], "icon": "fa fa-tag", @@ -1091,7 +1106,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-07-05 23:24:45.734144", + "modified": "2026-07-14 21:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 3fff0cb1c28..1fc62169daa 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -30,6 +30,7 @@ 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 @@ -60,6 +61,7 @@ class Item(Document): if TYPE_CHECKING: from frappe.types import DF + from erpnext.stock.doctype.company_restriction.company_restriction import CompanyRestriction from erpnext.stock.doctype.item_barcode.item_barcode import ItemBarcode from erpnext.stock.doctype.item_customer_detail.item_customer_detail import ItemCustomerDetail from erpnext.stock.doctype.item_default.item_default import ItemDefault @@ -71,6 +73,7 @@ class Item(Document): allow_alternative_item: DF.Check allow_negative_stock: DF.Check + allowed_companies: DF.TableMultiSelect[CompanyRestriction] asset_category: DF.Link | None asset_naming_series: DF.Literal[None] attributes: DF.Table[ItemVariantAttribute] @@ -242,6 +245,7 @@ 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")