feat: company-wise restriction for Item, Customer and Supplier masters (#57124)

This commit is contained in:
Mihir Kandoi
2026-07-14 16:00:47 +05:30
committed by GitHub
parent 823dbb7a2b
commit b6cce627a8
15 changed files with 282 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <b>Allowed Companies</b> 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",

View File

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

View File

@@ -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": []
}

View File

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

View File

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

View File

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

View File

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