|
6 | 6 | import math |
7 | 7 | from abc import ABC, abstractmethod |
8 | 8 | from dataclasses import dataclass, field |
9 | | -from functools import reduce |
| 9 | +from functools import cache, reduce |
10 | 10 | from typing import Any, Union |
11 | 11 |
|
12 | 12 | import frappe |
|
15 | 15 | from frappe.query_builder import Case |
16 | 16 | from frappe.query_builder.functions import Sum |
17 | 17 | from frappe.utils import cstr, date_diff, flt, getdate |
| 18 | +from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder |
18 | 19 | from pypika.terms import Bracket, LiteralValue |
19 | 20 |
|
20 | 21 | from erpnext import get_company_currency |
|
38 | 39 | ) |
39 | 40 | from erpnext.accounts.utils import get_children, get_currency_precision |
40 | 41 |
|
| 42 | +DEFAULT_BULLET_PREFIX = "• " |
| 43 | +SEGMENT_PREFIX = "seg_" |
| 44 | + |
41 | 45 | # ============================================================================ |
42 | 46 | # DATA MODELS |
43 | 47 | # ============================================================================ |
@@ -141,7 +145,7 @@ class SegmentData: |
141 | 145 |
|
142 | 146 | @property |
143 | 147 | def id(self) -> str: |
144 | | - return f"seg_{self.index}" |
| 148 | + return f"{SEGMENT_PREFIX}{self.index}" |
145 | 149 |
|
146 | 150 |
|
147 | 151 | @dataclass |
@@ -1392,7 +1396,8 @@ def initialize_rules(self): |
1392 | 1396 | condition=lambda rd: getattr(rd.row, "italic_text", False), format_properties={"italic": True} |
1393 | 1397 | ), |
1394 | 1398 | 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}, |
1396 | 1401 | ), |
1397 | 1402 | FormattingRule( |
1398 | 1403 | 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 |
1838 | 1843 | return 0.0 |
1839 | 1844 | else: |
1840 | 1845 | 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 |
0 commit comments