Skip to content

Commit c352218

Browse files
authored
feat: Add XLSX styling support to custom financial report templates (#52612)
1 parent ab19b16 commit c352218

6 files changed

Lines changed: 134 additions & 3 deletions

File tree

erpnext/accounts/doctype/financial_report_template/financial_report_engine.py

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import math
77
from abc import ABC, abstractmethod
88
from dataclasses import dataclass, field
9-
from functools import reduce
9+
from functools import cache, reduce
1010
from typing import Any, Union
1111

1212
import frappe
@@ -15,6 +15,7 @@
1515
from frappe.query_builder import Case
1616
from frappe.query_builder.functions import Sum
1717
from frappe.utils import cstr, date_diff, flt, getdate
18+
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
1819
from pypika.terms import Bracket, LiteralValue
1920

2021
from erpnext import get_company_currency
@@ -38,6 +39,9 @@
3839
)
3940
from erpnext.accounts.utils import get_children, get_currency_precision
4041

42+
DEFAULT_BULLET_PREFIX = "• "
43+
SEGMENT_PREFIX = "seg_"
44+
4145
# ============================================================================
4246
# DATA MODELS
4347
# ============================================================================
@@ -141,7 +145,7 @@ class SegmentData:
141145

142146
@property
143147
def id(self) -> str:
144-
return f"seg_{self.index}"
148+
return f"{SEGMENT_PREFIX}{self.index}"
145149

146150

147151
@dataclass
@@ -1392,7 +1396,8 @@ def initialize_rules(self):
13921396
condition=lambda rd: getattr(rd.row, "italic_text", False), format_properties={"italic": True}
13931397
),
13941398
FormattingRule(
1395-
condition=lambda rd: rd.is_detail_row, format_properties={"is_detail": True, "prefix": "• "}
1399+
condition=lambda rd: rd.is_detail_row,
1400+
format_properties={"is_detail": True, "prefix": DEFAULT_BULLET_PREFIX},
13961401
),
13971402
FormattingRule(
13981403
condition=lambda rd: getattr(rd.row, "warn_if_negative", False),
@@ -1838,3 +1843,124 @@ def _calculate_growth(self, previous_value: float, current_value: float) -> floa
18381843
return 0.0
18391844
else:
18401845
return flt(((current_value - previous_value) / abs(previous_value)) * 100, 2)
1846+
1847+
1848+
# ============================================================================
1849+
# XLSX EXPORT STYLING
1850+
# ============================================================================
1851+
1852+
1853+
def get_xlsx_styles(metadata: XLSXMetadata) -> dict | None:
1854+
"""
1855+
Generate XLSX styles for financial report templates.
1856+
1857+
NOTE: Currently only custom report generated with "Report Template" filter will have styles applied.
1858+
"""
1859+
# skip styling
1860+
if not metadata.filters.get("report_template"):
1861+
return
1862+
1863+
builder = XLSXStyleBuilder(metadata, default_styling=False)
1864+
builder.apply_default_styles(currency_formatting=False)
1865+
1866+
# currency is fixed for all columns (only if report template filter is applied)
1867+
currency = get_company_currency(metadata.filters.get("company"))
1868+
1869+
styles = {
1870+
"bold": builder.register_style({"bold": True}),
1871+
"italic": builder.register_style({"italic": True}),
1872+
"warning": builder.register_style({"font_color": "#dc3545"}), # text-danger
1873+
}
1874+
1875+
fieldtype_formats = {
1876+
"Int": builder.register_style({"num_format": "General"}),
1877+
"Float": builder.register_style({"num_format": builder.get_number_format("Float")}),
1878+
"Percent": builder.register_style({"num_format": builder.get_number_format("Percent")}),
1879+
"Currency": builder.register_style({"num_format": builder.get_number_format("Currency", currency)}),
1880+
}
1881+
1882+
# quick access for hot loop
1883+
style_cell = builder.style_cell
1884+
1885+
@cache
1886+
def get_color_style(color: str) -> int:
1887+
return builder.register_style({"font_color": color})
1888+
1889+
@cache
1890+
def get_prefix_style(prefix: str) -> int:
1891+
prefix = f"{prefix or DEFAULT_BULLET_PREFIX}@"
1892+
1893+
return builder.register_style({"num_format": prefix})
1894+
1895+
@cache
1896+
def get_indent_style(indent: int) -> int:
1897+
return builder.register_style({"align": "left", "indent": indent})
1898+
1899+
# column level styling of currency columns
1900+
for col_idx, col in metadata.column_map.items():
1901+
if col.get("fieldtype") != "Currency":
1902+
continue
1903+
1904+
builder.style_column(col_idx, fieldtype_formats["Currency"])
1905+
1906+
# cell level styling
1907+
for row_idx, row in metadata.row_map.items():
1908+
# skip total row
1909+
if metadata.has_total_row and row_idx == builder.last_row_index:
1910+
continue
1911+
1912+
is_segmented = (row.get("_segment_info", {}).get("total_segments", 1) or 1) > 1
1913+
segment_values = row.get("segment_values", {}) or {}
1914+
1915+
for col_idx, col in metadata.column_map.items():
1916+
fieldname = col.get("fieldname")
1917+
is_account = fieldname == "account"
1918+
1919+
# determine formatting bucket
1920+
if is_segmented and fieldname.startswith(SEGMENT_PREFIX):
1921+
formatting = row.copy()
1922+
1923+
_, seg_idx, seg_fieldname = fieldname.split("_", 2)
1924+
is_account = seg_fieldname == "account"
1925+
formatting.update(segment_values.get(f"{SEGMENT_PREFIX}{seg_idx}", {}) or {})
1926+
else:
1927+
formatting = row # default formatting bucket.
1928+
1929+
if not is_account and formatting.get("is_blank_line"):
1930+
continue
1931+
1932+
col_fieldtype = col.get("fieldtype")
1933+
cell_fieldtype = formatting.get("fieldtype") or col_fieldtype
1934+
cell_value = row.get(fieldname)
1935+
1936+
if cell_value in (None, ""):
1937+
continue
1938+
1939+
# account column and other fieldtype styling
1940+
if is_account:
1941+
if formatting.get("is_detail") or (prefix := formatting.get("prefix")):
1942+
style_cell(row_idx, col_idx, get_prefix_style(prefix))
1943+
1944+
# custom indentation (different segment might have different indentation levels)
1945+
if is_segmented and (indent := formatting.get("indent")) and indent > 0:
1946+
style_cell(row_idx, col_idx, get_indent_style(indent))
1947+
else:
1948+
if col_fieldtype != cell_fieldtype and cell_fieldtype in fieldtype_formats:
1949+
style_cell(row_idx, col_idx, fieldtype_formats[cell_fieldtype])
1950+
1951+
# text styles
1952+
for style_key in ("bold", "italic"):
1953+
if formatting.get(style_key):
1954+
style_cell(row_idx, col_idx, styles[style_key])
1955+
1956+
# color styles
1957+
if (
1958+
formatting.get("warn_if_negative")
1959+
and cell_fieldtype in frappe.model.numeric_fieldtypes
1960+
and flt(cell_value) < 0
1961+
):
1962+
style_cell(row_idx, col_idx, styles["warning"])
1963+
elif color := formatting.get("color"):
1964+
style_cell(row_idx, col_idx, get_color_style(color))
1965+
1966+
return builder.result

erpnext/accounts/report/balance_sheet/balance_sheet.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
1010
FinancialReportEngine,
11+
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
1112
)
1213
from erpnext.accounts.report.financial_statements import (
1314
compute_growth_view_data,

erpnext/accounts/report/cash_flow/cash_flow.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
1414
FinancialReportEngine,
15+
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
1516
)
1617
from erpnext.accounts.report.financial_statements import (
1718
get_columns,

erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
55
FinancialReportEngine,
6+
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
67
)
78

89

erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
1010
FinancialReportEngine,
11+
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
1112
)
1213
from erpnext.accounts.report.financial_statements import (
1314
compute_growth_view_data,

erpnext/public/js/financial_statements.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ function get_filters() {
455455
label: __("Currency"),
456456
fieldtype: "Select",
457457
options: erpnext.get_presentation_currency_list(),
458+
depends_on: "eval: !doc.report_template",
458459
},
459460
{
460461
fieldname: "cost_center",

0 commit comments

Comments
 (0)