feat: Add XLSX styling support to custom financial report templates (backport #52612) (#54485)

Co-authored-by: Abdeali Chharchhodawala <99460106+Abdeali099@users.noreply.github.com>
This commit is contained in:
Smit Vora
2026-04-23 17:50:52 +05:30
committed by GitHub
6 changed files with 134 additions and 3 deletions

View File

@@ -6,7 +6,7 @@ import json
import math import math
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import reduce from functools import cache, reduce
from typing import Any, Union from typing import Any, Union
import frappe import frappe
@@ -15,6 +15,7 @@ from frappe.database.operator_map import OPERATOR_MAP
from frappe.query_builder import Case from frappe.query_builder import Case
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Sum
from frappe.utils import cstr, date_diff, flt, getdate from frappe.utils import cstr, date_diff, flt, getdate
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
from pypika.terms import Bracket, LiteralValue from pypika.terms import Bracket, LiteralValue
from erpnext import get_company_currency from erpnext import get_company_currency
@@ -38,6 +39,9 @@ from erpnext.accounts.report.financial_statements import (
) )
from erpnext.accounts.utils import get_children, get_currency_precision from erpnext.accounts.utils import get_children, get_currency_precision
DEFAULT_BULLET_PREFIX = ""
SEGMENT_PREFIX = "seg_"
# ============================================================================ # ============================================================================
# DATA MODELS # DATA MODELS
# ============================================================================ # ============================================================================
@@ -141,7 +145,7 @@ class SegmentData:
@property @property
def id(self) -> str: def id(self) -> str:
return f"seg_{self.index}" return f"{SEGMENT_PREFIX}{self.index}"
@dataclass @dataclass
@@ -1392,7 +1396,8 @@ class FormattingEngine:
condition=lambda rd: getattr(rd.row, "italic_text", False), format_properties={"italic": True} condition=lambda rd: getattr(rd.row, "italic_text", False), format_properties={"italic": True}
), ),
FormattingRule( FormattingRule(
condition=lambda rd: rd.is_detail_row, format_properties={"is_detail": True, "prefix": ""} condition=lambda rd: rd.is_detail_row,
format_properties={"is_detail": True, "prefix": DEFAULT_BULLET_PREFIX},
), ),
FormattingRule( FormattingRule(
condition=lambda rd: getattr(rd.row, "warn_if_negative", False), condition=lambda rd: getattr(rd.row, "warn_if_negative", False),
@@ -1838,3 +1843,124 @@ class GrowthViewTransformer:
return 0.0 return 0.0
else: else:
return flt(((current_value - previous_value) / abs(previous_value)) * 100, 2) return flt(((current_value - previous_value) / abs(previous_value)) * 100, 2)
# ============================================================================
# XLSX EXPORT STYLING
# ============================================================================
def get_xlsx_styles(metadata: XLSXMetadata) -> dict | None:
"""
Generate XLSX styles for financial report templates.
NOTE: Currently only custom report generated with "Report Template" filter will have styles applied.
"""
# skip styling
if not metadata.filters.get("report_template"):
return
builder = XLSXStyleBuilder(metadata, default_styling=False)
builder.apply_default_styles(currency_formatting=False)
# currency is fixed for all columns (only if report template filter is applied)
currency = get_company_currency(metadata.filters.get("company"))
styles = {
"bold": builder.register_style({"bold": True}),
"italic": builder.register_style({"italic": True}),
"warning": builder.register_style({"font_color": "#dc3545"}), # text-danger
}
fieldtype_formats = {
"Int": builder.register_style({"num_format": "General"}),
"Float": builder.register_style({"num_format": builder.get_number_format("Float")}),
"Percent": builder.register_style({"num_format": builder.get_number_format("Percent")}),
"Currency": builder.register_style({"num_format": builder.get_number_format("Currency", currency)}),
}
# quick access for hot loop
style_cell = builder.style_cell
@cache
def get_color_style(color: str) -> int:
return builder.register_style({"font_color": color})
@cache
def get_prefix_style(prefix: str) -> int:
prefix = f"{prefix or DEFAULT_BULLET_PREFIX}@"
return builder.register_style({"num_format": prefix})
@cache
def get_indent_style(indent: int) -> int:
return builder.register_style({"align": "left", "indent": indent})
# column level styling of currency columns
for col_idx, col in metadata.column_map.items():
if col.get("fieldtype") != "Currency":
continue
builder.style_column(col_idx, fieldtype_formats["Currency"])
# cell level styling
for row_idx, row in metadata.row_map.items():
# skip total row
if metadata.has_total_row and row_idx == builder.last_row_index:
continue
is_segmented = (row.get("_segment_info", {}).get("total_segments", 1) or 1) > 1
segment_values = row.get("segment_values", {}) or {}
for col_idx, col in metadata.column_map.items():
fieldname = col.get("fieldname")
is_account = fieldname == "account"
# determine formatting bucket
if is_segmented and fieldname.startswith(SEGMENT_PREFIX):
formatting = row.copy()
_, seg_idx, seg_fieldname = fieldname.split("_", 2)
is_account = seg_fieldname == "account"
formatting.update(segment_values.get(f"{SEGMENT_PREFIX}{seg_idx}", {}) or {})
else:
formatting = row # default formatting bucket.
if not is_account and formatting.get("is_blank_line"):
continue
col_fieldtype = col.get("fieldtype")
cell_fieldtype = formatting.get("fieldtype") or col_fieldtype
cell_value = row.get(fieldname)
if cell_value in (None, ""):
continue
# account column and other fieldtype styling
if is_account:
if formatting.get("is_detail") or (prefix := formatting.get("prefix")):
style_cell(row_idx, col_idx, get_prefix_style(prefix))
# custom indentation (different segment might have different indentation levels)
if is_segmented and (indent := formatting.get("indent")) and indent > 0:
style_cell(row_idx, col_idx, get_indent_style(indent))
else:
if col_fieldtype != cell_fieldtype and cell_fieldtype in fieldtype_formats:
style_cell(row_idx, col_idx, fieldtype_formats[cell_fieldtype])
# text styles
for style_key in ("bold", "italic"):
if formatting.get(style_key):
style_cell(row_idx, col_idx, styles[style_key])
# color styles
if (
formatting.get("warn_if_negative")
and cell_fieldtype in frappe.model.numeric_fieldtypes
and flt(cell_value) < 0
):
style_cell(row_idx, col_idx, styles["warning"])
elif color := formatting.get("color"):
style_cell(row_idx, col_idx, get_color_style(color))
return builder.result

View File

@@ -8,6 +8,7 @@ from frappe.utils import cint, flt
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine, FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
) )
from erpnext.accounts.report.financial_statements import ( from erpnext.accounts.report.financial_statements import (
compute_growth_view_data, compute_growth_view_data,

View File

@@ -12,6 +12,7 @@ from pypika import Order
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine, FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
) )
from erpnext.accounts.report.financial_statements import ( from erpnext.accounts.report.financial_statements import (
get_columns, get_columns,

View File

@@ -3,6 +3,7 @@
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine, FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
) )

View File

@@ -8,6 +8,7 @@ from frappe.utils import flt
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine, FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
) )
from erpnext.accounts.report.financial_statements import ( from erpnext.accounts.report.financial_statements import (
compute_growth_view_data, compute_growth_view_data,

View File

@@ -455,6 +455,7 @@ function get_filters() {
label: __("Currency"), label: __("Currency"),
fieldtype: "Select", fieldtype: "Select",
options: erpnext.get_presentation_currency_list(), options: erpnext.get_presentation_currency_list(),
depends_on: "eval: !doc.report_template",
}, },
{ {
fieldname: "cost_center", fieldname: "cost_center",