diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index bff12b0dda2..1505a912eb6 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -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") diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py index 96431d265fc..0a4da97d400 100644 --- a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py @@ -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") diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index 806a5934940..6abc5a7a8f1 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -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") diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.js b/erpnext/accounts/report/balance_sheet/balance_sheet.js index 17d31cd1683..ae6ff1dbe46 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.js +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.js @@ -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"), diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index 1090b7f4b9c..863a758c89b 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -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( diff --git a/erpnext/accounts/report/balance_sheet/test_balance_sheet.py b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py index 683aeecffbd..542c61c64c1 100644 --- a/erpnext/accounts/report/balance_sheet/test_balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py @@ -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") diff --git a/erpnext/accounts/report/cash_flow/cash_flow.js b/erpnext/accounts/report/cash_flow/cash_flow.js index cf196f13037..2fe5f4a19f6 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.js +++ b/erpnext/accounts/report/cash_flow/cash_flow.js @@ -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", } ); diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index e6eae689ca9..31da6d5c41f 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -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): diff --git a/erpnext/accounts/report/cash_flow/test_cash_flow.py b/erpnext/accounts/report/cash_flow/test_cash_flow.py index 82555b3cfe5..26c74f43687 100644 --- a/erpnext/accounts/report/cash_flow/test_cash_flow.py +++ b/erpnext/accounts/report/cash_flow/test_cash_flow.py @@ -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) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 237f37e767f..9327883535f 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -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 diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py index 320637721d7..1f655fe4ce3 100644 --- a/erpnext/accounts/report/financial_statements.py +++ b/erpnext/accounts/report/financial_statements.py @@ -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 diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js index 5c9aaa62c94..7a64b32af5b 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js @@ -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"), diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 25eca6f4c79..892e6365a83 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -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) diff --git a/erpnext/accounts/report/profit_and_loss_statement/test_profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/test_profit_and_loss_statement.py index 725aec07011..fc736a24430 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/test_profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/test_profit_and_loss_statement.py @@ -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) diff --git a/erpnext/public/js/financial_statements.js b/erpnext/public/js/financial_statements.js index af062e80e9c..b1bf9fff072 100644 --- a/erpnext/public/js/financial_statements.js +++ b/erpnext/public/js/financial_statements.js @@ -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() {