feat: add grouping by dimension functionality in financial reports (#54650)

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Abdeali Chharchhodawala
2026-07-08 19:04:09 +05:30
committed by GitHub
parent c12e3fba5e
commit 95e2ce6d85
15 changed files with 744 additions and 158 deletions

View File

@@ -359,3 +359,13 @@ def create_accounting_dimensions_for_doctype(doctype):
create_custom_field(doctype, df, ignore_validate=True)
frappe.clear_cache(doctype=doctype)
def get_dimension_fieldname(dim_doctype: str) -> str:
"""
Return the `GL Entry` fieldname for a given dimension.
"""
if dim_doctype in ("Cost Center", "Project"):
return frappe.scrub(dim_doctype)
return frappe.db.get_value("Accounting Dimension", {"document_type": dim_doctype}, "fieldname")

View File

@@ -255,16 +255,27 @@ class FinancialReportEngine:
if filters.get("presentation_currency"):
frappe.msgprint(
title=_("Unsupported Feature"),
msg=_("Currency filters are currently unsupported in Custom Financial Report."),
indicator="orange",
title=_("Not Supported"),
msg=_("Currency filters are currently unsupported in Custom Financial Report"),
)
# Margin view is dependent on first row being an income account. Hence not supported.
# Way to implement this would be using calculated rows with formulas.
supported_views = ("Report", "Growth")
if (view := filters.get("selected_view")) and view not in supported_views:
frappe.msgprint(_("{0} view is currently unsupported in Custom Financial Report.").format(view))
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("{0} view is currently unsupported in Custom Financial Report").format(view),
)
if filters.get("group_by_dimension"):
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("Dimension-based grouping is currently unsupported in Custom Financial Report"),
)
def _initialize_context(self, filters: dict[str, Any]) -> ReportContext:
template_name = filters.get("report_template")

View File

@@ -315,6 +315,77 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
repost_doc.posting_date = today()
repost_doc.save()
def test_dimension_grouped_opening_balance_matches_gl_scan(self):
"""
A dimension-grouped Balance Sheet must produce identical per-dimension
figures whether opening balances come from
- Account Closing Balance (the fast path) or
- from a full GL scan (the fallback).
"""
from frappe.utils import add_days, getdate
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
from erpnext.accounts.report.financial_statements import build_period_list
company = "Test PCV Company"
cc1 = create_cost_center("Test Cost Center 1")
cc2 = create_cost_center("Test Cost Center 2")
# Post to two cost centers, then close the year so balances land in Account Closing Balance.
for amount, cost_center in ((400, cc1), (200, cc2)):
jv = make_journal_entry(
posting_date="2021-03-15",
amount=amount,
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
save=False,
)
jv.company = company
jv.save()
jv.submit()
pcv = self.make_period_closing_voucher(posting_date="2021-03-31")
report_date = add_days(getdate(pcv.period_end_date), 1)
report_filters = frappe._dict(
company=company,
period_start_date=report_date,
period_end_date=report_date,
periodicity="Yearly",
filter_based_on="Date Range",
accumulated_values=True,
group_by_dimension="Cost Center",
)
period_list = build_period_list(report_filters)
period_keys = [p.key for p in period_list]
def key_for(cost_center):
return next(p.key for p in period_list if p.dimension_value == cost_center)
def figures(data):
return {
row["account_name"]: {k: row.get(k) for k in period_keys}
for row in data
if row.get("account_name")
}
# Fast path: opening balance sourced from Account Closing Balance.
acb_figures = figures(execute(report_filters)[1])
# Fallback: force a full GL scan and expect the same numbers.
with self.change_settings("Accounts Settings", {"ignore_account_closing_balance": 1}):
gl_figures = figures(execute(report_filters)[1])
self.assertEqual(acb_figures, gl_figures)
# the fast path must carry per-dimension opening balances, not aggregates or zeros
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")

View File

@@ -8,6 +8,13 @@ frappe.query_reports[BS_REPORT_NAME] = $.extend({}, erpnext.financial_statements
erpnext.utils.add_dimensions(BS_REPORT_NAME, 10);
frappe.query_reports[BS_REPORT_NAME]["filters"].push(
{
fieldname: "group_by_dimension",
label: __("Group by Dimension"),
fieldtype: "Select",
options: erpnext.financial_statements.get_accounting_dimension_options(),
depends_on: "eval: !doc.report_template",
},
{
fieldname: "report_template",
label: __("Report Template"),

View File

@@ -13,6 +13,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
build_period_list,
calculate_values,
compute_growth_view_data,
filter_accounts,
@@ -23,7 +24,7 @@ from erpnext.accounts.report.financial_statements import (
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
get_period_keys_for_total,
prepare_data,
)
@@ -32,15 +33,10 @@ def execute(filters=None):
if filters and filters.report_template:
return FinancialReportEngine().execute(filters)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
if not period_list:
return
filters.period_start_date = period_list[0]["year_start_date"]
@@ -79,7 +75,13 @@ def execute(filters=None):
)
provisional_profit_loss, total_credit = get_provisional_profit_loss(
asset, liability, equity, period_list, filters.company, currency
asset,
liability,
equity,
period_list,
filters.company,
currency,
accumulated_values=filters.accumulated_values,
)
message, opening_balance = check_opening_balance(asset, liability, equity)
@@ -109,7 +111,11 @@ def execute(filters=None):
data.append(total_credit)
columns = get_columns(
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
filters.periodicity,
period_list,
filters.accumulated_values,
company=filters.company,
selected_view=filters.get("selected_view"),
)
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
@@ -125,12 +131,18 @@ def execute(filters=None):
def get_provisional_profit_loss(
asset, liability, equity, period_list, company, currency=None, consolidated=False
asset,
liability,
equity,
period_list,
company,
currency=None,
consolidated=False,
accumulated_values=False,
):
provisional_profit_loss = {}
total_row = {}
if asset:
total = total_row_total = 0
currency = currency or frappe.get_cached_value("Company", company, "default_currency")
total_row = {
"account_name": "'" + _("Total (Credit)") + "'",
@@ -156,11 +168,9 @@ def get_provisional_profit_loss(
if provisional_profit_loss[key]:
has_value = True
total += flt(provisional_profit_loss[key])
provisional_profit_loss["total"] = total
total_row_total += flt(total_row[key])
total_row["total"] = total_row_total
total_keys = get_period_keys_for_total(period_list, accumulated_values, consolidated)
provisional_profit_loss["total"] = flt(sum(provisional_profit_loss.get(k, 0.0) for k in total_keys))
total_row["total"] = flt(sum(total_row.get(k, 0.0) for k in total_keys))
if has_value:
provisional_profit_loss.update(
@@ -204,23 +214,24 @@ def get_report_summary(
):
net_asset, net_liability, net_equity, net_provisional_profit_loss = 0.0, 0.0, 0.0, 0.0
if filters.get("accumulated_values"):
period_list = [period_list[-1]]
# from consolidated financial statement
if filters.get("accumulated_in_group_company"):
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
keys = [period if consolidated else period.key for period in period_list]
else:
keys = get_period_keys_for_total(period_list, filters.accumulated_values, consolidated)
for period in period_list:
key = period if consolidated else period.key
# get_data() output: [...account rows..., total_row, {}] → [-2] = total row, [-1] = blank separator
# [-1] == {} guards against missing total row (e.g. empty liability/equity data)
for key in keys:
if asset:
net_asset += asset[-2].get(key)
net_asset += flt(asset[-2].get(key))
if liability and liability[-1] == {}:
net_liability += liability[-2].get(key)
net_liability += flt(liability[-2].get(key))
if equity and equity[-1] == {}:
net_equity += equity[-2].get(key)
net_equity += flt(equity[-2].get(key))
if provisional_profit_loss:
net_provisional_profit_loss += provisional_profit_loss.get(key)
net_provisional_profit_loss += flt(provisional_profit_loss.get(key))
return [
{"value": net_asset, "label": _("Total Asset"), "datatype": "Currency", "currency": currency},
@@ -283,15 +294,7 @@ def execute_snapshot_report(filters):
if not (conn := get_latest_sync("GL Entry")):
frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
filters.period_start_date = period_list[0]["year_start_date"]
currency = filters.presentation_currency or frappe.get_cached_value(

View File

@@ -5,6 +5,7 @@ import frappe
from frappe.utils.data import today
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
from erpnext.accounts.report.financial_statements import build_period_list, is_dimension_grouped
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company 6"
@@ -106,6 +107,79 @@ class TestBalanceSheet(ERPNextTestSuite):
self.assertIn("'Provisional Profit / Loss (Credit)'", name_and_total)
self.assertEqual(name_and_total["'Provisional Profit / Loss (Credit)'"], 100)
def test_group_by_dimension(self):
create_account("BS Dim Test Bank", f"Bank Accounts - {COMPANY_SHORT_NAME}", COMPANY)
cc1 = frappe.db.get_value("Cost Center", {"company": COMPANY, "is_group": 0}, "name")
parent_cc = frappe.db.get_value("Cost Center", {"company": COMPANY, "is_group": 1}, "name")
cc2 = frappe.new_doc("Cost Center")
cc2.cost_center_name = "BS Test CC 2"
cc2.parent_cost_center = parent_cc
cc2.company = COMPANY
cc2.insert()
make_journal_entry(
[
dict(
account_name="BS Dim Test Bank",
debit_in_account_currency=300,
credit_in_account_currency=0,
cost_center=cc1,
),
dict(
account_name="Capital Stock",
debit_in_account_currency=0,
credit_in_account_currency=300,
cost_center=cc1,
),
]
)
make_journal_entry(
[
dict(
account_name="BS Dim Test Bank",
debit_in_account_currency=500,
credit_in_account_currency=0,
cost_center=cc2.name,
),
dict(
account_name="Capital Stock",
debit_in_account_currency=0,
credit_in_account_currency=500,
cost_center=cc2.name,
),
]
)
filters = frappe._dict(
company=COMPANY,
period_start_date=today(),
period_end_date=today(),
periodicity="Yearly",
filter_based_on="Date Range",
accumulated_values=True,
group_by_dimension="Cost Center",
)
period_list = build_period_list(filters)
self.assertTrue(is_dimension_grouped(period_list))
def key_for(cost_center):
return next(p.key for p in period_list if p.dimension_value == cost_center)
columns, data, *_ = execute(filters)
# each dimension group starts with exactly one flagged column (UI boundary marker)
first_flags = [c["dimension_value"] for c in columns if c.get("is_first_in_dimension")]
self.assertEqual(len(first_flags), len(set(first_flags)))
self.assertLessEqual({cc1, cc2.name}, set(first_flags))
bank_row = next((r for r in data if r.get("account_name") == "BS Dim Test Bank"), None)
self.assertIsNotNone(bank_row)
self.assertEqual(bank_row[key_for(cc1)], 300)
self.assertEqual(bank_row[key_for(cc2.name)], 500)
self.assertEqual(bank_row["total"], 800)
def make_journal_entry(rows):
jv = frappe.new_doc("Journal Entry")

View File

@@ -17,6 +17,13 @@ erpnext.utils.add_dimensions(CF_REPORT_NAME, 10);
frappe.query_reports[CF_REPORT_NAME]["filters"].splice(8, 1);
frappe.query_reports[CF_REPORT_NAME]["filters"].push(
{
fieldname: "group_by_dimension",
label: __("Group by Dimension"),
fieldtype: "Select",
options: erpnext.financial_statements.get_accounting_dimension_options(),
depends_on: "eval: !doc.report_template",
},
{
fieldname: "report_template",
label: __("Report Template"),
@@ -42,6 +49,7 @@ frappe.query_reports[CF_REPORT_NAME]["filters"].push(
fieldname: "show_opening_and_closing_balance",
label: __("Show Opening and Closing Balance"),
fieldtype: "Check",
depends_on: "eval:!doc.group_by_dimension",
}
);

View File

@@ -10,17 +10,23 @@ from frappe.query_builder import DocType
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, flt
from pypika import Order
from pypika.terms import Bracket, LiteralValue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimension_with_children,
)
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
)
from erpnext.accounts.report.financial_statements import (
build_period_list,
get_columns,
get_cost_centers_with_children,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
is_dimension_grouped,
set_gl_entries_by_account,
)
from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import (
@@ -33,15 +39,10 @@ def execute(filters=None):
if filters and filters.report_template:
return FinancialReportEngine().execute(filters)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
if not period_list:
return
cash_flow_sections = get_cash_flow_accounts()
@@ -67,7 +68,13 @@ def execute(filters=None):
ignore_accumulated_values_for_fy=True,
)
net_profit_loss = get_net_profit_loss(income, expense, period_list, filters.company)
net_profit_loss = get_net_profit_loss(
income,
expense,
period_list,
filters.company,
accumulated_values=bool(filters.accumulated_values),
)
data = []
summary_data = {}
@@ -143,8 +150,16 @@ def execute(filters=None):
add_blank_row=False,
)
if filters.show_opening_and_closing_balance:
if filters.show_opening_and_closing_balance and not is_dimension_grouped(period_list):
show_opening_and_closing_balance(data, period_list, company_currency, net_change_in_cash, filters)
elif filters.show_opening_and_closing_balance:
filters.show_opening_and_closing_balance = False
frappe.msgprint(
indicator="orange",
title=_("Not Supported"),
msg=_("Opening and Closing balance is not supported for dimension grouped cash flow statement"),
)
columns = get_columns(
filters.periodicity,
@@ -200,6 +215,8 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
filters.start_date = start_date
filters.end_date = period["to_date"]
filters.account_type = account_type
filters.dimension_field = period.get("dimension_field")
filters.dimension_value = period.get("dimension_value")
amount = get_account_type_based_gl_data(company, filters)
@@ -216,41 +233,71 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
def get_account_type_based_gl_data(company, filters=None):
filters = frappe._dict(filters or {})
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
gl = frappe.qb.DocType("GL Entry")
acc = frappe.qb.DocType("Account")
query = (
frappe.qb.from_(gle)
.select(Sum(gle.credit) - Sum(gle.debit))
frappe.qb.from_(gl)
.select(Sum(gl.credit) - Sum(gl.debit))
.where(gl.company == company)
.where(gl.posting_date >= filters.start_date)
.where(gl.posting_date <= filters.end_date)
.where(gl.voucher_type != "Period Closing Voucher")
.where(
(gle.company == company)
& (gle.posting_date >= filters.start_date)
& (gle.posting_date <= filters.end_date)
& (gle.voucher_type != "Period Closing Voucher")
& gle.account.isin(
frappe.qb.from_(account)
.select(account.name)
.where(account.account_type == filters.account_type)
gl.account.isin(
frappe.qb.from_(acc)
.select(acc.name)
.where(acc.is_group == 0)
.where(acc.company == company)
.where(acc.account_type == filters.account_type)
)
)
)
# finance book
if filters.include_default_book_entries:
company_fb = frappe.get_cached_value("Company", company, "default_finance_book")
query = query.where(
gle.finance_book.isin([filters.finance_book, company_fb, ""]) | gle.finance_book.isnull()
(gl.finance_book.isin([cstr(filters.finance_book), cstr(company_fb), ""]))
| (gl.finance_book.isnull())
)
else:
query = query.where(
gle.finance_book.isin([cstr(filters.finance_book), ""]) | gle.finance_book.isnull()
(gl.finance_book.isin([cstr(filters.finance_book), ""])) | (gl.finance_book.isnull())
)
# cost center (with children)
if filters.get("cost_center"):
cost_centers = get_cost_centers_with_children(filters.cost_center)
query = query.where(gle.cost_center.isin(cost_centers))
query = query.where(gl.cost_center.isin(cost_centers))
gl_sum = query.run()
return gl_sum[0][0] if gl_sum and gl_sum[0][0] else 0
# project
if filters.get("project"):
projects = filters.project
if not isinstance(projects, list):
projects = frappe.parse_json(projects)
query = query.where(gl.project.isin(projects))
# per-period group-by-dimension filter (always a single exact value)
if filters.get("dimension_field") and filters.get("dimension_value"):
query = query.where(gl[filters.dimension_field] == filters.dimension_value)
# accounting dimension filters selected in the filter bar
for dimension in get_accounting_dimensions(as_list=False):
if filters.get(dimension.fieldname):
values = filters[dimension.fieldname]
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
values = get_dimension_with_children(dimension.document_type, values)
query = query.where(gl[dimension.fieldname].isin(values))
# apply permission filters
from frappe.desk.reportview import build_match_conditions
if match_conditions := build_match_conditions("GL Entry"):
query = query.where(Bracket(LiteralValue(match_conditions)))
result = query.run()
return flt(result[0][0]) if result and result[0][0] else 0
def get_start_date(period, accumulated_values, company):

View File

@@ -2,9 +2,10 @@
# For license information, please see license.txt
import frappe
from frappe.utils import today
from frappe.utils import getdate, today
from erpnext.accounts.report.cash_flow.cash_flow import execute
from erpnext.accounts.report.financial_statements import build_period_list, is_dimension_grouped
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
@@ -68,3 +69,45 @@ class TestCashFlow(ERPNextTestSuite):
make_journal_entry(asset_account, "Cash - _TC", 800, posting_date=today(), submit=True)
self.assertEqual(self.net_change_in_cash() - before, -800)
def test_group_by_dimension(self):
"""Cash movements must land in their own cost center's column, not just the overall total."""
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
cc1, cc2 = "_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"
filters = frappe._dict(
company=self.company,
period_start_date=getdate(),
period_end_date=getdate(),
filter_based_on="Date Range",
periodicity="Yearly",
accumulated_values=False,
group_by_dimension="Cost Center",
)
period_list = build_period_list(filters)
self.assertTrue(is_dimension_grouped(period_list))
def key_for(cost_center):
return next(p.key for p in period_list if p.dimension_value == cost_center)
def net_change_row():
rows = execute(filters)[1]
return next((row for row in rows if row.get("section") == "'Net Change in Cash'"), {})
before = net_change_row()
# cash sales: 400 via cc1, 200 via cc2
make_journal_entry(
"Cash - _TC", "Sales - _TC", 400, cost_center=cc1, posting_date=today(), submit=True
)
make_journal_entry(
"Cash - _TC", "Sales - _TC", 200, cost_center=cc2, posting_date=today(), submit=True
)
after = net_change_row()
self.assertEqual(after.get(key_for(cc1), 0) - before.get(key_for(cc1), 0), 400)
self.assertEqual(after.get(key_for(cc2), 0) - before.get(key_for(cc2), 0), 200)
self.assertEqual(after.get("total", 0) - before.get("total", 0), 600)

View File

@@ -192,7 +192,15 @@ def get_income_expense_data(companies, fiscal_year, filters):
expense = get_data(companies, "Expense", "Debit", fiscal_year, filters, True)
net_profit_loss = get_net_profit_loss(income, expense, companies, filters.company, company_currency, True)
net_profit_loss = get_net_profit_loss(
income,
expense,
companies,
filters.company,
company_currency,
consolidated=True,
accumulated_values=bool(filters.accumulated_values),
)
return income, expense, net_profit_loss

View File

@@ -3,6 +3,7 @@
import copy
import datetime
import functools
import math
import re
@@ -15,12 +16,187 @@ from pypika.terms import Bracket, ExistsCriterion, LiteralValue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimension_fieldname,
get_dimension_with_children,
get_doctypes_with_dimensions,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
from erpnext.accounts.utils import get_fiscal_year, get_zero_cutoff
def get_dimension_values(filters: frappe._dict) -> tuple[str | None, list]:
"""
Return (fieldname, [dimension_values]) for the chosen grouping dimension.
NOTE: Disabled dimensions values are not filtered out!
"""
if not filters.group_by_dimension:
return None, []
dim_doctype = filters.group_by_dimension
fieldname = get_dimension_fieldname(dim_doctype)
meta = frappe.get_meta(dim_doctype)
is_tree = bool(meta.is_tree)
dim = frappe.qb.DocType(dim_doctype)
query = frappe.qb.from_(dim).select(dim.name)
if is_tree and meta.has_field("is_group"):
query = query.where(dim.is_group == 0)
if meta.has_field("company"):
query = query.where(dim.company == filters.company)
# Self-filter: narrow to values the user picked for this same dimension.
if selected := filters.get(fieldname):
if isinstance(selected, str):
selected = frappe.parse_json(selected)
if is_tree:
selected = get_dimension_with_children(dim_doctype, selected)
query = query.where(dim.name.isin(selected))
from frappe.desk.reportview import build_match_conditions
if match_conditions := build_match_conditions(dim_doctype):
query = query.where(Bracket(LiteralValue(match_conditions)))
# order by name
query = query.orderby(dim.name)
return fieldname, query.run(pluck=True)
def get_dimension_period_list(filters: frappe._dict) -> list[dict]:
"""
Return a period_list-shaped axis = cross-product of (dimension_value * time period).
Each cell is a `get_period_list` bucket plus dimension keys, e.g.:
```
{
"dimension_field": "cost_center",
"dimension_value": "Main - ATD",
"key": "main___atd_mar_2027",
"label": "Main - ATD - 2026-2027",
"period": "mar_2027",
...
}
```
"""
fieldname, dimensions = get_dimension_values(filters)
if not fieldname or not dimensions:
return []
period_buckets = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
accumulated_values=filters.accumulated_values,
company=filters.company,
)
if not period_buckets:
return []
period_list = []
# Guard against rare collisions where two distinct dimension values
# `frappe.scrub()` to the same key (e.g. "CC-A" and "CC A") and would
# otherwise overwrite each other's column.
used_keys = set()
for dimension in dimensions:
dim_key_base = frappe.scrub(dimension)
for period in period_buckets:
key = f"{dim_key_base}_{period.key}"
if key in used_keys:
key = f"{key}_{len(used_keys)}"
used_keys.add(key)
cell = frappe._dict(period)
cell.update(
{
"key": key,
"label": f"{dimension} - {period.label}",
"dimension_field": fieldname,
"dimension_value": dimension,
"period": period.key,
}
)
period_list.append(cell)
return period_list
def build_period_list(filters: frappe._dict) -> list[dict]:
"""
Build the report `period_list` from filters.
- If `group_by_dimension` is set, returns a dimension * period cross-product via `get_dimension_period_list`.
- Otherwise, returns plain time buckets via `get_period_list`.
"""
if filters.group_by_dimension and not filters.report_template:
return get_dimension_period_list(filters)
return get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
def is_dimension_grouped(period_list: list[dict]) -> bool:
"""
Return True if period_list contains dimension-grouped periods.
"""
if not period_list or not isinstance(period_list, list):
return False
return bool(period_list[0].get("dimension_field"))
def get_period_keys_for_total(
period_list: list[dict],
accumulated_values: bool,
consolidated: bool = False,
) -> list[str]:
"""
Return the period keys whose values should be summed for the row-level
`Total` column / report-summary cards.
- Group by Dimension + accumulated: each dimension's last period
- Accumulated: only the last period
- Not accumulated: all periods (sum of independent period activity)
- Consolidated: list of period keys is the same as the period_list
- In case of consolidated reports
"""
if not period_list:
return []
if consolidated:
return list(period_list)
if is_dimension_grouped(period_list) and accumulated_values:
return list({period.dimension_value: period.key for period in period_list}.values())
# when 'accumulated_values' is enabled, periods have running balance.
# so, last period will have the net amount.
if accumulated_values:
return [period_list[-1].key]
return [period.key for period in period_list]
def get_period_list(
from_fiscal_year,
to_fiscal_year,
@@ -33,18 +209,19 @@ def get_period_list(
reset_period_on_fy_change=True,
ignore_fiscal_year=False,
):
"""Get a list of dict {"from_date": from_date, "to_date": to_date, "key": key, "label": label}
Periodicity can be (Yearly, Quarterly, Monthly)"""
"""
Generate a list of time buckets between the provided from/to fiscal year or date range,
based on the periodicity (Yearly, Half-Yearly, Quarterly, Monthly).
"""
# Resolve the report's overall date range (with validation).
if filter_based_on == "Fiscal Year":
fiscal_year = get_fiscal_year_data(from_fiscal_year, to_fiscal_year)
validate_fiscal_year(fiscal_year, from_fiscal_year, to_fiscal_year)
year_start_date = getdate(fiscal_year.year_start_date)
year_end_date = getdate(fiscal_year.year_end_date)
fy_data = get_fiscal_year_data(from_fiscal_year, to_fiscal_year)
validate_fiscal_year(fy_data, from_fiscal_year, to_fiscal_year)
year_start_date, year_end_date = getdate(fy_data.year_start_date), getdate(fy_data.year_end_date)
else:
validate_dates(period_start_date, period_end_date)
year_start_date = getdate(period_start_date)
year_end_date = getdate(period_end_date)
year_start_date, year_end_date = getdate(period_start_date), getdate(period_end_date)
months_to_add = {"Yearly": 12, "Half-Yearly": 6, "Quarterly": 3, "Monthly": 1}[periodicity]
@@ -233,6 +410,8 @@ def calculate_values(
accumulated_values,
ignore_accumulated_values_for_fy,
):
grouped_by_dimension = is_dimension_grouped(period_list)
for entries in gl_entries_by_account.values():
for entry in entries:
d = accounts_by_name.get(entry.account)
@@ -243,7 +422,8 @@ def calculate_values(
raise_exception=1,
)
for period in period_list:
# check if posting date is within the period
if grouped_by_dimension and entry.get(period.dimension_field) != period.dimension_value:
continue
if entry.posting_date <= period.to_date:
if (accumulated_values or entry.posting_date >= period.from_date) and (
@@ -252,7 +432,8 @@ def calculate_values(
):
d[period.key] = d.get(period.key, 0.0) + flt(entry.debit) - flt(entry.credit)
if entry.posting_date < period_list[0].year_start_date:
# Balance Sheet only: track pre-FY entries as opening_balance (no per-dimension breakdown possible).
if not grouped_by_dimension and entry.posting_date < period_list[0].year_start_date:
d["opening_balance"] = d.get("opening_balance", 0.0) + flt(entry.debit) - flt(entry.credit)
@@ -274,11 +455,11 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
data = []
year_start_date = period_list[0]["year_start_date"].strftime("%Y-%m-%d")
year_end_date = period_list[-1]["year_end_date"].strftime("%Y-%m-%d")
total_keys = get_period_keys_for_total(period_list, accumulated_values)
for d in accounts:
# add to output
has_value = False
total = 0
row = frappe._dict(
{
"account": _(d.name),
@@ -303,21 +484,14 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
# change sign based on Debit or Credit, since calculation is done using (debit - credit)
d[period.key] *= -1
row[period.key] = flt(d.get(period.key, 0.0), 3)
row[period.key] = flt(d.get(period.key, 0), 3)
if abs(row[period.key]) >= get_zero_cutoff(company_currency):
# ignore zero values
has_value = True
total += flt(row[period.key])
if accumulated_values:
# when 'accumulated_values' is enabled, periods have running balance.
# so, last period will have the net amount.
row["has_value"] = has_value
row["total"] = flt(d.get(period_list[-1].key, 0.0), 3)
else:
row["has_value"] = has_value
row["total"] = total
row["has_value"] = has_value
row["total"] = flt(sum(row.get(k, 0) for k in total_keys), 3)
data.append(row)
return data
@@ -547,6 +721,10 @@ def get_accounting_entries(
.where(gl_entry.company == filters.company)
)
if filters.group_by_dimension and doctype in get_doctypes_with_dimensions() and not group_by_account:
dimension_field = get_dimension_fieldname(filters.group_by_dimension)
query = query.select(gl_entry[dimension_field])
if not ignore_reporting_currency:
query = query.select(
gl_entry.debit_in_reporting_currency
@@ -687,7 +865,14 @@ def get_cost_centers_with_children(cost_centers):
return list(set(all_cost_centers))
def get_columns(periodicity, period_list, accumulated_values=1, company=None, cash_flow=False):
def get_columns(
periodicity,
period_list,
accumulated_values=1,
company=None,
cash_flow=False,
selected_view="Report",
):
columns = [
{
"fieldname": "account" if not cash_flow else "section",
@@ -697,6 +882,7 @@ def get_columns(periodicity, period_list, accumulated_values=1, company=None, ca
"width": 300,
}
]
if not cash_flow:
columns.extend(
[
@@ -716,6 +902,7 @@ def get_columns(periodicity, period_list, accumulated_values=1, company=None, ca
},
]
)
if company:
columns.append(
{
@@ -726,27 +913,40 @@ def get_columns(periodicity, period_list, accumulated_values=1, company=None, ca
"hidden": 1,
}
)
seen_dim_values = set()
for period in period_list:
col = {
"fieldname": period.key,
"label": period.label,
"fieldtype": "Currency",
"options": "currency",
"width": 150,
}
if dim_value := period.get("dimension_value"):
# used to identify cross-dimension boundaries
col["dimension_value"] = dim_value
# to handle special view (Growth/Margin) formatting in UI.
if dim_value not in seen_dim_values:
seen_dim_values.add(dim_value)
col["is_first_in_dimension"] = True
columns.append(col)
if selected_view not in ("Growth", "Margin") and (
is_dimension_grouped(period_list) or (periodicity != "Yearly" and not accumulated_values)
):
columns.append(
{
"fieldname": period.key,
"label": period.label,
"fieldname": "total",
"label": _("Total"),
"fieldtype": "Currency",
"options": "currency",
"width": 150,
"options": "currency",
}
)
if periodicity != "Yearly":
if not accumulated_values:
columns.append(
{
"fieldname": "total",
"label": _("Total"),
"fieldtype": "Currency",
"width": 150,
"options": "currency",
}
)
return columns
@@ -768,6 +968,10 @@ def compute_growth_view_data(data, columns):
continue
for column_idx in range(1, len(columns)):
# No growth comparison across dimension boundaries
if columns[column_idx - 1].get("dimension_value") != columns[column_idx].get("dimension_value"):
continue
previous_period_key = columns[column_idx - 1].get("key")
current_period_key = columns[column_idx].get("key")
current_period_value = data_copy[row_idx].get(current_period_key)
@@ -789,13 +993,10 @@ def compute_growth_view_data(data, columns):
data[row_idx][current_period_key] = growth_percent
def compute_margin_view_data(data, columns, accumulated_values):
def compute_margin_view_data(data, columns):
if not columns:
return
if not accumulated_values:
columns.append({"key": "total"})
data_copy = copy.deepcopy(data)
base_row = None

View File

@@ -8,6 +8,13 @@ frappe.query_reports[PL_REPORT_NAME] = $.extend({}, erpnext.financial_statements
erpnext.utils.add_dimensions(PL_REPORT_NAME, 10);
frappe.query_reports[PL_REPORT_NAME]["filters"].push(
{
fieldname: "group_by_dimension",
label: __("Group by Dimension"),
fieldtype: "Select",
options: erpnext.financial_statements.get_accounting_dimension_options(),
depends_on: "eval: !doc.report_template",
},
{
fieldname: "report_template",
label: __("Report Template"),

View File

@@ -13,6 +13,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
build_period_list,
calculate_values,
compute_growth_view_data,
compute_margin_view_data,
@@ -23,7 +24,7 @@ from erpnext.accounts.report.financial_statements import (
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
get_period_keys_for_total,
prepare_data,
)
@@ -32,15 +33,10 @@ def execute(filters=None):
if filters and filters.report_template:
return FinancialReportEngine().execute(filters)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
if not period_list:
return
income = get_data(
filters.company,
@@ -63,7 +59,12 @@ def execute(filters=None):
)
net_profit_loss = get_net_profit_loss(
income, expense, period_list, filters.company, filters.presentation_currency
income,
expense,
period_list,
filters.company,
filters.presentation_currency,
accumulated_values=bool(filters.accumulated_values),
)
data = []
@@ -72,7 +73,13 @@ def execute(filters=None):
if net_profit_loss:
data.append(net_profit_loss)
columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
columns = get_columns(
filters.periodicity,
period_list,
filters.accumulated_values,
filters.company,
selected_view=filters.get("selected_view"),
)
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
@@ -87,39 +94,38 @@ def execute(filters=None):
compute_growth_view_data(data, period_list)
if filters.get("selected_view") == "Margin":
compute_margin_view_data(data, period_list, filters.accumulated_values)
compute_margin_view_data(data, period_list)
return columns, data, None, chart, report_summary, primitive_summary
def get_report_summary(
period_list, periodicity, income, expense, net_profit_loss, currency, filters, consolidated=False
period_list,
periodicity,
income,
expense,
net_profit_loss,
currency,
filters,
consolidated=False,
):
net_income, net_expense, net_profit = 0.0, 0.0, 0.0
# from consolidated financial statement
if filters.get("accumulated_in_group_company"):
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
if filters.accumulated_values:
# when 'accumulated_values' is enabled, periods have running balance.
# so, last period will have the net amount.
key = period_list[-1].key
if income:
net_income = income[-2].get(key)
if expense:
net_expense = expense[-2].get(key)
if net_profit_loss:
net_profit = net_profit_loss.get(key)
keys = [period if consolidated else period.key for period in period_list]
else:
for period in period_list:
key = period if consolidated else period.key
if income:
net_income += income[-2].get(key)
if expense:
net_expense += expense[-2].get(key)
if net_profit_loss:
net_profit += net_profit_loss.get(key)
keys = get_period_keys_for_total(period_list, filters.accumulated_values, consolidated)
# get_data() output: [...account rows..., total_row, {}] → [-2] = total row, [-1] = blank separator
for key in keys:
if income:
net_income += flt(income[-2].get(key))
if expense:
net_expense += flt(expense[-2].get(key))
if net_profit_loss:
net_profit += flt(net_profit_loss.get(key))
if len(period_list) == 1 and periodicity == "Yearly":
profit_label = _("Profit This Year")
@@ -143,8 +149,15 @@ def get_report_summary(
], net_profit
def get_net_profit_loss(income, expense, period_list, company, currency=None, consolidated=False):
total = 0
def get_net_profit_loss(
income,
expense,
period_list,
company,
currency=None,
consolidated=False,
accumulated_values=False,
):
net_profit_loss = {
"account_name": "'" + _("Profit for the year") + "'",
"account": "'" + _("Profit for the year") + "'",
@@ -164,8 +177,9 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co
if net_profit_loss[key]:
has_value = True
total += flt(net_profit_loss[key])
net_profit_loss["total"] = total
total_keys = get_period_keys_for_total(period_list, accumulated_values, consolidated)
net_profit_loss["total"] = flt(sum(net_profit_loss.get(k, 0.0) for k in total_keys))
if has_value:
return net_profit_loss
@@ -215,15 +229,7 @@ def execute_snapshot_report(filters):
_("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
period_list = build_period_list(filters)
income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)

View File

@@ -6,7 +6,11 @@ from frappe.desk.query_report import export_query
from frappe.utils import add_days, getdate, today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.financial_statements import get_period_list
from erpnext.accounts.report.financial_statements import (
build_period_list,
get_period_list,
is_dimension_grouped,
)
from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
@@ -60,6 +64,75 @@ class TestProfitAndLossStatement(ERPNextTestSuite, AccountsTestMixin):
accumulated_values=False,
)
def _create_cost_center(self, name):
parent = frappe.db.get_value("Cost Center", self.cost_center, "parent_cost_center")
cc = frappe.new_doc("Cost Center")
cc.cost_center_name = name
cc.parent_cost_center = parent
cc.company = self.company
cc.insert()
return cc.name
def test_group_by_dimension(self):
second_cc = self._create_cost_center("P&L Test CC 2")
# 100 to default cost center, 200 to second cost center
self.create_sales_invoice(rate=100)
si2 = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=second_cc,
cost_center=second_cc,
rate=200,
price_list_rate=200,
qty=1,
)
si2.submit()
filters = self.get_report_filters()
filters.group_by_dimension = "Cost Center"
period_list = build_period_list(filters)
self.assertTrue(is_dimension_grouped(period_list))
posting_date = getdate()
def key_for(cost_center):
return next(
p.key
for p in period_list
if p.dimension_value == cost_center and p.from_date <= posting_date <= p.to_date
)
columns, data, *_ = execute(filters)
self.assertLessEqual({self.cost_center, second_cc}, {c.get("dimension_value") for c in columns})
income_account = frappe.db.get_value("Company", self.company, "default_income_account")
income_row = next((r for r in data if r.get("account") == income_account), None)
self.assertIsNotNone(income_row)
cc1_key, cc2_key = key_for(self.cost_center), key_for(second_cc)
self.assertEqual(income_row[cc1_key], 100)
self.assertEqual(income_row[cc2_key], 200)
# no leakage into other dimension or period columns
for period in period_list:
if period.key not in (cc1_key, cc2_key):
self.assertEqual(income_row[period.key], 0)
# non-accumulated: total = sum of all dimension-period values
self.assertEqual(income_row["total"], 300.0)
# accumulated: total must take each dimension's last running balance once,
# not sum every accumulated column
filters.accumulated_values = True
data = execute(filters)[1]
income_row = next(r for r in data if r.get("account") == income_account)
self.assertEqual(income_row["total"], 300.0)
def test_profit_and_loss_output_and_summary(self):
self.create_sales_invoice(qty=1, rate=150)

View File

@@ -40,10 +40,15 @@ erpnext.financial_statements = {
_is_special_view: function (column, data) {
if (!data) return false;
const view = get_filter_value("selected_view");
if (!["Growth", "Margin"].includes(view)) return false;
// First period of each dim has no prior in Growth → show raw currency, not %.
// Margin always shows % for all period columns (income row = 100%).
if (view === "Growth" && column.is_first_in_dimension) return false;
if (get_filter_value("report_template")) {
const columnInfo = erpnext.financial_statements._parse_column_info(column.fieldname, data);
// Account column
@@ -392,6 +397,18 @@ erpnext.financial_statements = {
});
}
},
get_accounting_dimension_options: function () {
const options = ["", "Cost Center", "Project"];
frappe.db
.get_list("Accounting Dimension", { fields: ["document_type"], filters: { disabled: 0 } })
.then((res) => {
res.forEach((dimension) => {
options.push(dimension.document_type);
});
});
return options;
},
};
function get_filters() {