mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-19 01:18:43 +00:00
feat: add grouping by dimension functionality in financial reports (#54650)
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
committed by
GitHub
parent
c12e3fba5e
commit
95e2ce6d85
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user