mirror of
https://github.com/frappe/erpnext.git
synced 2026-10-29 22:57:42 +00:00
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:
committed by
diptanilsaha
parent
7a90e987c4
commit
b6ab54e852
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user