mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-31 23:33:43 +00:00
fix: enforce company restrictions at transaction level
Restrict to Companies only filtered list views and document reads, and only for users with Company user permissions. Any user could still use a master restricted to Company A in a Company B transaction, and users without Company user permissions bypassed the feature entirely. Validate on save of transactions that every linked Item, Customer and Supplier allows the transaction company, and filter item link queries by the transaction company so restricted items don't show up in the item selector.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -364,6 +364,26 @@ 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",
|
||||
]
|
||||
|
||||
doc_events = {
|
||||
"*": {
|
||||
"validate": [
|
||||
@@ -377,6 +397,9 @@ 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",
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# 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")
|
||||
|
||||
|
||||
class CompanyRestrictionError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class CompanyRestriction(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -40,6 +49,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 +62,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))
|
||||
@@ -95,6 +108,68 @@ def validate_allowed_companies(doc):
|
||||
)
|
||||
|
||||
|
||||
def validate_transaction_company(doc, method=None):
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -22,6 +22,7 @@ frappe.ui.form.on("Stock Reconciliation", {
|
||||
query: "erpnext.controllers.queries.item_query",
|
||||
filters: {
|
||||
is_stock_item: 1,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user