Merge pull request #51078 from Abdeali099/custom-financial-statement-pdf-export

This commit is contained in:
Abdeali Chharchhodawala
2026-01-02 13:29:49 +05:30
committed by GitHub
parent 7bb0ec836f
commit 4632ddc497
4 changed files with 150 additions and 58 deletions

View File

@@ -71,7 +71,9 @@ class PeriodValue:
class AccountData:
"""Account data across all periods"""
account_name: str
account: str # docname
account_name: str = "" # account name
account_number: str = ""
period_values: dict[str, PeriodValue] = field(default_factory=dict)
def add_period(self, period_value: PeriodValue) -> None:
@@ -103,7 +105,11 @@ class AccountData:
# movement is unaccumulated by default
def copy(self):
copied = AccountData(account_name=self.account_name)
copied = AccountData(
account=self.account,
account_name=self.account_name,
account_number=self.account_number,
)
copied.period_values = {k: v.copy() for k, v in self.period_values.items()}
return copied
@@ -329,12 +335,10 @@ class DataCollector:
self.account_fields = {field.fieldname for field in frappe.get_meta("Account").fields}
def add_account_request(self, row):
accounts = self._parse_account_filter(self.company, row)
self.account_requests.append(
{
"row": row,
"accounts": accounts,
"accounts": self._parse_account_filter(self.company, row),
"balance_type": row.balance_type,
"reference_code": row.reference_code,
"reverse_sign": row.reverse_sign,
@@ -345,12 +349,12 @@ class DataCollector:
if not self.account_requests:
return {"account_data": {}, "summary": {}, "account_details": {}}
# Get all unique accounts
all_accounts = set()
for request in self.account_requests:
all_accounts.update(request["accounts"])
# Get all accounts
all_accounts = []
for request in self.account_requests:
all_accounts.extend(request["accounts"])
all_accounts = list(all_accounts)
if not all_accounts:
return {"account_data": {}, "summary": {}, "account_details": {}}
@@ -373,7 +377,9 @@ class DataCollector:
total_values = [0.0] * len(self.periods)
request_account_details = {}
for account_name in accounts:
for account in accounts:
account_name = account.name
if account_name not in account_data:
continue
@@ -396,20 +402,21 @@ class DataCollector:
return {"account_data": account_data, "summary": summary, "account_details": account_details}
@staticmethod
def _parse_account_filter(company, report_row) -> list[str]:
def _parse_account_filter(company, report_row) -> list[dict]:
"""
Find accounts matching filter criteria.
Example:
Input: '["account_type", "=", "Cash"]'
Output: ["Cash - COMP", "Petty Cash - COMP", "Bank - COMP"]
- Input: '["account_type", "=", "Cash"]'
- Output: [{"name": "Cash - COMP", "account_name": "Cash", "account_number": "1001"}]
"""
filter_parser = FilterExpressionParser()
account = frappe.qb.DocType("Account")
query = (
frappe.qb.from_(account)
.select(account.name)
.select(account.name, account.account_name, account.account_number)
.where(account.disabled == 0)
.where(account.is_group == 0)
)
@@ -423,8 +430,8 @@ class DataCollector:
query = query.where(where_condition)
query = query.orderby(account.name)
result = query.run(as_dict=True)
return [row.name for row in result]
return query.run(as_dict=True)
@staticmethod
def get_filtered_accounts(company: str, account_rows: list) -> list[str]:
@@ -456,17 +463,35 @@ class FinancialQueryBuilder:
self.filters = filters
self.periods = periods
self.company = filters.get("company")
self.account_meta = {} # {name: {account_name, account_number}}
def fetch_account_balances(self, accounts: list[str]) -> dict[str, AccountData]:
def fetch_account_balances(self, accounts: list[dict]) -> dict[str, AccountData]:
"""
Fetch account balances for all periods with optimization.
Steps: get opening balances → fetch GL entries → calculate running totals
- accounts: list of accounts with details
```
{
"name": "Cash - COMP",
"account_name": "Cash",
"account_number": "1001",
}
```
Returns:
dict: {account: AccountData}
"""
balances_data = self._get_opening_balances(accounts)
gl_data = self._get_gl_movements(accounts)
account_names = list({acc.name for acc in accounts})
# NOTE: do not change accounts list as it is used in caller function
self.account_meta = {
acc.name: {"account_name": acc.account_name, "account_number": acc.account_number}
for acc in accounts
}
balances_data = self._get_opening_balances(account_names)
gl_data = self._get_gl_movements(account_names)
self._calculate_running_balances(balances_data, gl_data)
self._handle_balance_accumulation(balances_data)
@@ -543,7 +568,8 @@ class FinancialQueryBuilder:
gap_movement = gap_movements.get(account, 0.0)
opening_balance = closing_balance + gap_movement
account_data = AccountData(account)
account_data = AccountData(account=account, **self._get_account_meta(account))
account_data.add_period(PeriodValue(first_period_key, opening_balance, 0, 0))
balances_data[account] = account_data
@@ -613,7 +639,7 @@ class FinancialQueryBuilder:
for row in gl_data:
account = row["account"]
if account not in balances_data:
balances_data[account] = AccountData(account)
balances_data[account] = AccountData(account=account, **self._get_account_meta(account))
account_data: AccountData = balances_data[account]
@@ -714,6 +740,9 @@ class FinancialQueryBuilder:
return query.run(as_dict=True)
def _get_account_meta(self, account: str) -> dict[str, Any]:
return self.account_meta.get(account, {})
class FilterExpressionParser:
"""Direct filter expression to SQL condition builder"""
@@ -1544,20 +1573,29 @@ class RowFormatterBase(ABC):
pass
def _get_values(self, row_data: RowData) -> dict[str, Any]:
# TODO: can be commonify COA? @abdeali
def _get_row_data(key: str, default: Any = "") -> Any:
return getattr(row_data.row, key, default) or default
def _get_filter_value(key: str, default: Any = "") -> Any:
return getattr(self.context.filters, key, default) or default
child_accounts = []
if row_data.account_details:
child_accounts = list(row_data.account_details.keys())
display_name = _get_row_data("display_name", "")
values = {
"account": _get_row_data("account", "") or display_name,
"account_name": display_name,
"acc_name": _get_row_data("account_name", ""),
"acc_number": _get_row_data("account_number", ""),
"child_accounts": child_accounts,
"account": getattr(row_data.row, "display_name", "") or "",
"indent": getattr(row_data.row, "indentation_level", 0),
"account_name": getattr(row_data.row, "account", "") or "",
"currency": self.context.currency or "",
"period_start_date": getattr(self.context.filters, "period_start_date", "") or "",
"period_end_date": getattr(self.context.filters, "period_end_date", "") or "",
"indent": _get_row_data("indentation_level", 0),
"period_start_date": _get_filter_value("period_start_date", ""),
"period_end_date": _get_filter_value("period_end_date", ""),
"total": 0,
}
@@ -1670,8 +1708,8 @@ class DetailRowBuilder:
detail_rows = []
parent_row = self.parent_row_data.row
for account_name, account_data in self.parent_row_data.account_details.items():
detail_row = self._create_detail_row_object(account_name, parent_row)
for account_data in self.parent_row_data.account_details.values():
detail_row = self._create_detail_row_object(account_data, parent_row)
balance_type = getattr(parent_row, "balance_type", "Closing Balance")
values = account_data.get_values_by_type(balance_type)
@@ -1687,16 +1725,20 @@ class DetailRowBuilder:
return detail_rows
def _create_detail_row_object(self, account_name: str, parent_row):
short_name = account_name.rsplit(" - ", 1)[0].strip()
def _create_detail_row_object(self, account_data: AccountData, parent_row):
acc_name = account_data.account_name or ""
acc_number = account_data.account_number or ""
display_name = f"{_(acc_number)} - {_(acc_name)}" if acc_number else _(acc_name)
return type(
"DetailRow",
(),
{
"display_name": short_name,
"account": account_name,
"account_name": short_name,
"account": account_data.account,
"display_name": display_name,
"account_name": acc_name,
"account_number": acc_number,
"data_source": "Account Detail",
"indentation_level": getattr(parent_row, "indentation_level", 0) + 1,
"fieldtype": getattr(parent_row, "fieldtype", None),

View File

@@ -44,3 +44,5 @@ frappe.query_reports[CF_REPORT_NAME]["filters"].push(
fieldtype: "Check",
}
);
frappe.query_reports[CF_REPORT_NAME]["export_hidden_cols"] = true;

View File

@@ -13,7 +13,7 @@
}
.financial-statements-blank-row td {
height: 37px;
height: 20px;
}
</style>
@@ -25,30 +25,37 @@
{% endif %}
<h3 class="text-center">{%= filters.fiscal_year %}</h3>
<h5 class="text-center">
{%= __("Currency") %} : {%= filters.presentation_currency || erpnext.get_currency(filters.company) %}
<strong>{%= __("Currency") %}:</strong> {%= filters.presentation_currency || erpnext.get_currency(filters.company) %}
</h5>
{% if (filters.from_date) { %}
<h5 class="text-center">
{%= frappe.datetime.str_to_user(filters.from_date) %} - {%= frappe.datetime.str_to_user(filters.to_date) %}
</h5>
{% } %}
<hr>
<div class="show-filters">
{% if subtitle %}
{{ subtitle }}
<hr>
{% endif %}
</div>
{% if subtitle %}
<div class="show-filters">
{{ subtitle }}
<hr>
</div>
{% endif %}
<table class="table table-bordered">
<thead>
<tr>
<th style="width: {%= 100 - (report_columns.length - 1) * 13 %}%"></th>
<th style="width: {%= 100 - (report_columns.length - 1) * 16 %}%">{%= report_columns[0].label %}</th>
{% for (let i=1, l=report_columns.length; i<l; i++) { %}
<th class="text-right">{%= report_columns[i].label %}</th>
{% } %}
</tr>
</thead>
<tbody>
{% for(let j=0, k=data.length; j<k; j++) { %}
{%
@@ -60,6 +67,7 @@
<td>
<span style="padding-left: {%= cint(data[j].indent) * 2 %}em">{%= row.account_name || row.section %}</span>
</td>
{% for(let i=1, l=report_columns.length; i<l; i++) { %}
<td class="text-right">
{% const fieldname = report_columns[i].fieldname; %}
@@ -72,6 +80,7 @@
{% } %}
</tbody>
</table>
<p class="text-right text-muted">
{%= __("Printed on {0}", [frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string())]) %}
</p>

View File

@@ -3,15 +3,18 @@ frappe.provide("erpnext.financial_statements");
erpnext.financial_statements = {
filters: get_filters(),
baseData: null,
get_pdf_format: function (report, custom_format) {
// If report template is selected, use default pdf formatting
return report.get_filter_value("report_template") ? null : custom_format;
},
formatter: function (value, row, column, data, default_formatter, filter) {
const report_params = [value, row, column, data, default_formatter, filter];
// Growth/Margin
if (erpnext.financial_statements._is_special_view(column, data))
return erpnext.financial_statements._format_special_view(...report_params);
if (frappe.query_report.get_filter_value("report_template"))
return erpnext.financial_statements._format_custom_report(...report_params);
if (frappe.query_report.get_filter_value("report_template"))
return erpnext.financial_statements._format_custom_report(...report_params);
else return erpnext.financial_statements._format_standard_report(...report_params);
@@ -56,7 +59,7 @@ erpnext.financial_statements = {
const isPeriodColumn = periodKeys.includes(baseName);
return {
isAccount: baseName === "account",
isAccount: baseName === erpnext.financial_statements.name_field,
isPeriod: isPeriodColumn,
segmentIndex: valueMatch && valueMatch[1] ? parseInt(valueMatch[1]) : null,
fieldname: baseName,
@@ -74,15 +77,26 @@ erpnext.financial_statements = {
},
_format_custom_account_column: function (value, data, formatting, column, default_formatter, row) {
// account name to display in the report
// 1. section_name for sections
// 2. account_name for accounts
// 3. formatting.account_name for segments
// 4. value as last fallback
value = data.section_name || data.account_name || formatting.account_name || value;
if (!value) return "";
// Link to open ledger
const should_link_to_ledger =
formatting.is_detail || (formatting.account_filters && formatting.child_accounts);
formatting.is_detail ||
(formatting.account_filters && formatting.child_accounts && formatting.child_accounts.length);
if (should_link_to_ledger) {
const glData = {
account: formatting.account_name || formatting.child_accounts || value,
account:
Array.isArray(formatting.child_accounts) && formatting.child_accounts.length
? formatting.child_accounts
: formatting.account ?? value,
from_date: formatting.from_date || formatting.period_start_date,
to_date: formatting.to_date || formatting.period_end_date,
account_type: formatting.account_type,
@@ -125,16 +139,41 @@ erpnext.financial_statements = {
return erpnext.financial_statements._style_custom_value(formattedValue, formatting, value);
},
_style_custom_value(formattedValue, formatting, value) {
let $element = $(`<span>${formattedValue}</span>`);
_style_custom_value(formatted_value, formatting, value) {
const styles = [];
if (formatting.bold) $element.css("font-weight", "bold");
if (formatting.italic) $element.css("font-style", "italic");
if (formatting.warn_if_negative && typeof value === "number" && value < 0)
$element.addClass("text-danger");
if (formatting.color) $element.css("color", formatting.color);
if (formatting.bold) styles.push("font-weight: bold");
if (formatting.italic) styles.push("font-style: italic");
return $element.wrap("<p></p>").parent().html();
if (formatting.warn_if_negative && typeof value === "number" && value < 0) {
styles.push("color: #dc3545"); // text-danger
} else if (formatting.color) {
styles.push(`color: ${formatting.color}`);
}
if (styles.length === 0) return formatted_value;
const style_string = styles.join("; ");
// formatted value contains HTML tags/elements
if (/<[^>]+>/.test(formatted_value)) {
const temp_div = document.createElement("div");
temp_div.innerHTML = formatted_value;
// parse HTML and inject styles into the first element
const first_element = temp_div.querySelector("*");
if (first_element) {
const existing_style = first_element.getAttribute("style") || "";
first_element.setAttribute(
"style",
existing_style ? `${existing_style}; ${style_string}` : style_string
);
return temp_div.innerHTML;
}
}
return `<span style="${style_string}">${formatted_value}</span>`;
},
_format_special_view: function (value, row, column, data, default_formatter) {