mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-11 13:41:47 +00:00
Merge pull request #57352 from mihir-kandoi/company-restriction-transaction-enforcement
fix: enforce company restrictions at transaction level
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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