mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-12 19:35:09 +00:00
Merge pull request #51078 from Abdeali099/custom-financial-statement-pdf-export
This commit is contained in:
committed by
GitHub
parent
7bb0ec836f
commit
4632ddc497
@@ -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),
|
||||
|
||||
@@ -44,3 +44,5 @@ frappe.query_reports[CF_REPORT_NAME]["filters"].push(
|
||||
fieldtype: "Check",
|
||||
}
|
||||
);
|
||||
|
||||
frappe.query_reports[CF_REPORT_NAME]["export_hidden_cols"] = true;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user