fix(profitability-analysis): apply user permissions to cost centers, projects and dimensions (backport #62523) (#62549)

* fix(profitability-analysis): check company permission before running report

(cherry picked from commit 7c91a78f5c)

# Conflicts:
#	erpnext/accounts/report/profitability_analysis/test_profitability_analysis.py

* chore: resolve backport conflicts
This commit is contained in:
diptanilsaha
2026-10-27 07:14:30 +00:00
committed by diptanilsaha
parent 7a90e987c4
commit b6ab54e852
3 changed files with 225 additions and 16 deletions
@@ -12,7 +12,10 @@ from erpnext.accounts.report.financial_statements import (
filter_accounts,
filter_out_zero_value_rows,
)
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.report.trial_balance.trial_balance import (
validate_company_permission,
validate_filters,
)
from erpnext.accounts.utils import get_zero_cutoff
value_fields = ("income", "expense", "gross_profit_loss")
@@ -34,23 +37,28 @@ def execute(filters=None):
def get_accounts_data(based_on, company):
if based_on == "Cost Center":
cc = qb.DocType("Cost Center")
return (
qb.from_(cc)
.select(
cc.name,
cc.parent_cost_center.as_("parent_account"),
cc.cost_center_name.as_("account_name"),
cc.lft,
cc.rgt,
)
.where(cc.company.eq(company))
.orderby(cc.name)
.run(as_dict=True)
cost_centers = frappe.get_list(
"Cost Center",
fields=[
"name",
"parent_cost_center as parent_account",
"cost_center_name as account_name",
"lft",
"rgt",
],
filters={"company": company},
order_by="name",
)
# show a cost center as a root when the user cannot read its parent
names = {cc.name for cc in cost_centers}
for cc in cost_centers:
if cc.parent_account not in names:
cc.parent_account = None
return cost_centers
elif based_on == "Project":
return frappe.get_all("Project", fields=["name"], filters={"company": company}, order_by="name")
return frappe.get_list("Project", fields=["name"], filters={"company": company}, order_by="name")
else:
validate_company_permission(company)
filters = {}
doctype = frappe.unscrub(based_on)
has_company = frappe.db.has_column(doctype, "company")
@@ -58,7 +66,7 @@ def get_accounts_data(based_on, company):
if has_company:
filters.update({"company": company})
return frappe.get_all(doctype, fields=["name"], filters=filters, order_by="name")
return frappe.get_list(doctype, fields=["name"], filters=filters, order_by="name")
def get_data(accounts, filters, based_on):
@@ -0,0 +1,196 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.profitability_analysis.profitability_analysis import execute
INCOME = "Sales - _TC"
EXPENSE = "_Test Account Cost for Goods Sold - _TC"
BANK = "_Test Bank - _TC"
class TestProfitabilityAnalysis(FrappeTestCase):
def setUp(self):
for year in (2025, 2026):
fiscal_year = f"_Test Fiscal Year {year}"
if not frappe.db.exists("Fiscal Year", fiscal_year):
frappe.get_doc(
{
"doctype": "Fiscal Year",
"year": fiscal_year,
"year_start_date": f"{year}-01-01",
"year_end_date": f"{year}-12-31",
"companies": [{"company": "_Test Company"}],
}
).insert(ignore_if_duplicate=True)
def tearDown(self):
frappe.db.rollback()
def run_report(self, fiscal_year="_Test Fiscal Year 2026", **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"based_on": "Cost Center",
"fiscal_year": fiscal_year,
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)[1]
def make_cc(self, name, **args):
create_cost_center(cost_center_name=name, **args)
return name + " - _TC"
def row(self, data, account):
return next(r for r in data if r.get("account") == account)
def book_income(self, cost_center, amount, posting_date="2026-06-01"):
create_sales_invoice(
cost_center=cost_center, income_account=INCOME, rate=amount, qty=1, posting_date=posting_date
)
def book_expense(self, cost_center, amount, posting_date="2026-06-01"):
make_journal_entry(
EXPENSE, BANK, amount, cost_center=cost_center, posting_date=posting_date, submit=True
)
def test_income_expense_and_gross_profit(self):
# a dedicated leaf cost center keeps these exact assertions free of GL that
# other tests may book against a shared cost center in the same fiscal year
cc = self.make_cc("_Test PA Income Expense")
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
row = self.row(self.run_report(), cc)
self.assertEqual(row["income"], 10000)
self.assertEqual(row["expense"], 4000)
self.assertEqual(row["gross_profit_loss"], 6000)
def test_parent_cost_center_accumulates_children(self):
parent = self.make_cc("_Test PA Parent", is_group=1)
child_1 = self.make_cc("_Test PA Child 1", parent_cost_center=parent)
child_2 = self.make_cc("_Test PA Child 2", parent_cost_center=parent)
self.book_income(child_1, 10000)
self.book_expense(child_2, 3000)
data = self.run_report()
self.assertEqual(self.row(data, child_1)["income"], 10000)
self.assertEqual(self.row(data, child_2)["expense"], 3000)
parent_row = self.row(data, parent)
self.assertEqual(parent_row["income"], 10000)
self.assertEqual(parent_row["expense"], 3000)
self.assertEqual(parent_row["gross_profit_loss"], 7000)
def test_date_range_excludes_out_of_period_entries(self):
cc = self.make_cc("_Test PA Date Range")
self.book_income(cc, 10000, posting_date="2025-06-01")
# the 2025 income must not appear in a 2026 report (zero-value rows are dropped)
accounts_2026 = {r.get("account") for r in self.run_report()}
self.assertNotIn(cc, accounts_2026)
row_2025 = self.row(
self.run_report(
fiscal_year="_Test Fiscal Year 2025", from_date="2025-01-01", to_date="2025-12-31"
),
cc,
)
self.assertEqual(row_2025["income"], 10000)
def test_total_row_sums_income_and_expense(self):
cc = "_Test Cost Center - _TC"
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
data = self.run_report()
# the report appends a blank separator row and a totals row at the end
total_row = data[-1]
# the report wraps the (possibly translated) "Total" label in single quotes
self.assertEqual(total_row["account"], "'" + frappe._("Total") + "'")
# total is built from direct (non-accumulated) values, so it stays internally consistent
self.assertEqual(total_row["gross_profit_loss"], total_row["income"] - total_row["expense"])
# and it includes this test's bookings
self.assertGreaterEqual(total_row["income"], 10000)
self.assertGreaterEqual(total_row["expense"], 4000)
def test_restricted_user_sees_only_permitted_cost_center_figures(self):
permitted = self.make_cc("_Test PA Permitted")
restricted = self.make_cc("_Test PA Restricted")
self.book_income(permitted, 400)
self.book_income(restricted, 1000)
user = make_restricted_user("test_profitability_analysis@example.com", "Cost Center", permitted)
frappe.set_user(user)
try:
data = self.run_report(show_zero_values=1)
finally:
frappe.set_user("Administrator")
self.assertEqual(data[-1]["income"], 400)
cost_centers = {row.get("account") for row in data}
self.assertIn(permitted, cost_centers)
self.assertNotIn(restricted, cost_centers)
def test_restricted_user_sees_only_permitted_projects(self):
permitted, restricted = (
frappe.get_doc({"doctype": "Project", "project_name": name, "company": "_Test Company"})
.insert()
.name
for name in ("_Test PA Permitted Project", "_Test PA Restricted Project")
)
user = make_restricted_user(
"test_profitability_analysis_project@example.com",
"Project",
permitted,
roles=["Accounts User", "Projects User"],
)
frappe.set_user(user)
try:
data = self.run_report(based_on="Project", show_zero_values=1)
finally:
frappe.set_user("Administrator")
projects = {row.get("account") for row in data}
self.assertIn(permitted, projects)
self.assertNotIn(restricted, projects)
def test_user_without_company_permission_cannot_run_report_on_accounting_dimension(self):
user = make_restricted_user(
"test_profitability_analysis_company@example.com", "Company", "_Test Company 1"
)
frappe.set_user(user)
try:
self.assertRaises(
frappe.PermissionError,
self.run_report,
based_on="Accounting Dimension",
accounting_dimension="Location",
)
finally:
frappe.set_user("Administrator")
def make_restricted_user(user, doctype, value, roles=("Accounts User",)):
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": "Profitability Analysis",
"roles": [{"role": role} for role in roles],
}
).insert()
frappe.permissions.add_user_permission(doctype, value, user)
return user
@@ -44,6 +44,11 @@ def execute(filters=None):
return columns, data
def validate_company_permission(company: str | None) -> None:
if company and not frappe.has_permission("Company", "select", company):
frappe.throw(_("You are not permitted to view {0}").format(company), frappe.PermissionError)
def validate_filters(filters):
if not filters.fiscal_year:
frappe.throw(_("Fiscal Year {0} is required").format(filters.fiscal_year))