diff --git a/erpnext/accounts/doctype/account/account.json b/erpnext/accounts/doctype/account/account.json index b84f50f97c3..8b74dd823ec 100644 --- a/erpnext/accounts/doctype/account/account.json +++ b/erpnext/accounts/doctype/account/account.json @@ -21,6 +21,7 @@ "account_currency", "column_break1", "parent_account", + "account_category", "account_type", "tax_rate", "freeze_account", @@ -189,13 +190,20 @@ "fieldname": "disabled", "fieldtype": "Check", "label": "Disable" + }, + { + "description": "Used with Financial Report Template", + "fieldname": "account_category", + "fieldtype": "Link", + "label": "Account Category", + "options": "Account Category" } ], "icon": "fa fa-money", "idx": 1, "is_tree": 1, "links": [], - "modified": "2025-01-22 10:40:35.766017", + "modified": "2025-08-02 06:26:44.657146", "modified_by": "Administrator", "module": "Accounts", "name": "Account", @@ -250,6 +258,7 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "account_number", "show_name_in_global_search": 1, "show_preview_popup": 1, @@ -257,4 +266,4 @@ "sort_order": "ASC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 5ca373c2e96..1e6467e502f 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -31,6 +31,7 @@ class Account(NestedSet): if TYPE_CHECKING: from frappe.types import DF + account_category: DF.Link | None account_currency: DF.Link | None account_name: DF.Data account_number: DF.Data | None diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py index 3513464fa77..c905bc56943 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py @@ -23,15 +23,7 @@ def create_charts( if root_account: root_type = child.get("root_type") - if account_name not in [ - "account_name", - "account_number", - "account_type", - "root_type", - "is_group", - "tax_rate", - "account_currency", - ]: + if account_name not in get_chart_metadata_fields(): account_number = cstr(child.get("account_number")).strip() account_name, account_name_in_db = add_suffix_if_duplicate( account_name, account_number, accounts @@ -55,6 +47,7 @@ def create_charts( "report_type": report_type, "account_number": account_number, "account_type": child.get("account_type"), + "account_category": child.get("account_category"), "account_currency": child.get("account_currency") if custom_chart else frappe.get_cached_value("Company", company, "default_currency"), @@ -97,20 +90,7 @@ def add_suffix_if_duplicate(account_name, account_number, accounts): def identify_is_group(child): if child.get("is_group"): is_group = child.get("is_group") - elif len( - set(child.keys()) - - set( - [ - "account_name", - "account_type", - "root_type", - "is_group", - "tax_rate", - "account_number", - "account_currency", - ] - ) - ): + elif len(set(child.keys()) - set(get_chart_metadata_fields())): is_group = 1 else: is_group = 0 @@ -253,13 +233,7 @@ def validate_bank_account(coa, bank_account): def _get_account_names(account_master): for account_name, child in account_master.items(): - if account_name not in [ - "account_number", - "account_type", - "root_type", - "is_group", - "tax_rate", - ]: + if account_name not in get_chart_metadata_fields(): accounts.append(account_name) _get_account_names(child) @@ -284,15 +258,7 @@ def build_tree_from_json(chart_template, chart_data=None, from_coa_importer=Fals """recursively called to form a parent-child based list of dict from chart template""" for account_name, child in children.items(): account = {} - if account_name in [ - "account_name", - "account_number", - "account_type", - "root_type", - "is_group", - "tax_rate", - "account_currency", - ]: + if account_name in get_chart_metadata_fields(): continue if from_coa_importer: @@ -310,3 +276,16 @@ def build_tree_from_json(chart_template, chart_data=None, from_coa_importer=Fals _import_accounts(chart, None) return accounts + + +def get_chart_metadata_fields(): + return [ + "account_name", + "account_number", + "account_type", + "account_category", + "root_type", + "is_group", + "tax_rate", + "account_currency", + ] diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py index 6f34a652822..0786da4f11b 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py @@ -9,103 +9,192 @@ def get(): return { _("Application of Funds (Assets)"): { _("Current Assets"): { - _("Accounts Receivable"): {_("Debtors"): {"account_type": "Receivable"}}, - _("Bank Accounts"): {"account_type": "Bank", "is_group": 1}, - _("Cash In Hand"): {_("Cash"): {"account_type": "Cash"}, "account_type": "Cash"}, + _("Accounts Receivable"): { + _("Debtors"): {"account_type": "Receivable", "account_category": "Trade Receivables"} + }, + _("Bank Accounts"): { + "account_type": "Bank", + "is_group": 1, + "account_category": "Cash and Cash Equivalents", + }, + _("Cash In Hand"): { + _("Cash"): {"account_type": "Cash", "account_category": "Cash and Cash Equivalents"}, + "account_type": "Cash", + "account_category": "Cash and Cash Equivalents", + }, _("Loans and Advances (Assets)"): { - _("Employee Advances"): {"account_type": "Payable"}, + _("Employee Advances"): { + "account_type": "Payable", + "account_category": "Other Receivables", + }, }, - _("Securities and Deposits"): {_("Earnest Money"): {}}, + _("Securities and Deposits"): { + _("Earnest Money"): {"account_category": "Other Current Assets"} + }, + _("Prepaid Expenses"): {"account_category": "Other Current Assets"}, + _("Short-term Investments"): {"account_category": "Short-term Investments"}, _("Stock Assets"): { - _("Stock In Hand"): {"account_type": "Stock"}, + _("Stock In Hand"): {"account_type": "Stock", "account_category": "Stock Assets"}, "account_type": "Stock", + "account_category": "Stock Assets", }, - _("Tax Assets"): {"is_group": 1}, + _("Tax Assets"): {"is_group": 1, "account_category": "Other Current Assets"}, }, _("Fixed Assets"): { - _("Capital Equipment"): {"account_type": "Fixed Asset"}, - _("Electronic Equipment"): {"account_type": "Fixed Asset"}, - _("Furniture and Fixtures"): {"account_type": "Fixed Asset"}, - _("Office Equipment"): {"account_type": "Fixed Asset"}, - _("Plants and Machineries"): {"account_type": "Fixed Asset"}, - _("Buildings"): {"account_type": "Fixed Asset"}, - _("Software"): {"account_type": "Fixed Asset"}, - _("Accumulated Depreciation"): {"account_type": "Accumulated Depreciation"}, + _("Capital Equipment"): { + "account_type": "Fixed Asset", + "account_category": "Tangible Assets", + }, + _("Electronic Equipment"): { + "account_type": "Fixed Asset", + "account_category": "Tangible Assets", + }, + _("Furniture and Fixtures"): { + "account_type": "Fixed Asset", + "account_category": "Tangible Assets", + }, + _("Office Equipment"): {"account_type": "Fixed Asset", "account_category": "Tangible Assets"}, + _("Plants and Machineries"): { + "account_type": "Fixed Asset", + "account_category": "Tangible Assets", + }, + _("Buildings"): {"account_type": "Fixed Asset", "account_category": "Tangible Assets"}, + _("Software"): {"account_type": "Fixed Asset", "account_category": "Intangible Assets"}, + _("Accumulated Depreciation"): { + "account_type": "Accumulated Depreciation", + "account_category": "Tangible Assets", + }, _("CWIP Account"): { "account_type": "Capital Work in Progress", + "account_category": "Tangible Assets", }, }, - _("Investments"): {"is_group": 1}, - _("Temporary Accounts"): {_("Temporary Opening"): {"account_type": "Temporary"}}, + _("Investments"): {"is_group": 1, "account_category": "Long-term Investments"}, + _("Temporary Accounts"): { + _("Temporary Opening"): { + "account_type": "Temporary", + "account_category": "Other Non-current Assets", + } + }, "root_type": "Asset", }, _("Expenses"): { _("Direct Expenses"): { _("Stock Expenses"): { - _("Cost of Goods Sold"): {"account_type": "Cost of Goods Sold"}, - _("Expenses Included In Asset Valuation"): { - "account_type": "Expenses Included In Asset Valuation" + _("Cost of Goods Sold"): { + "account_type": "Cost of Goods Sold", + "account_category": "Cost of Goods Sold", + }, + _("Expenses Included In Asset Valuation"): { + "account_type": "Expenses Included In Asset Valuation", + "account_category": "Other Direct Costs", + }, + _("Expenses Included In Valuation"): { + "account_type": "Expenses Included In Valuation", + "account_category": "Other Direct Costs", + }, + _("Stock Adjustment"): { + "account_type": "Stock Adjustment", + "account_category": "Other Direct Costs", }, - _("Expenses Included In Valuation"): {"account_type": "Expenses Included In Valuation"}, - _("Stock Adjustment"): {"account_type": "Stock Adjustment"}, }, }, _("Indirect Expenses"): { - _("Administrative Expenses"): {}, - _("Commission on Sales"): {}, - _("Depreciation"): {"account_type": "Depreciation"}, - _("Entertainment Expenses"): {}, - _("Freight and Forwarding Charges"): {"account_type": "Chargeable"}, - _("Legal Expenses"): {}, - _("Marketing Expenses"): {"account_type": "Chargeable"}, - _("Miscellaneous Expenses"): {"account_type": "Chargeable"}, - _("Office Maintenance Expenses"): {}, - _("Office Rent"): {}, - _("Postal Expenses"): {}, - _("Print and Stationery"): {}, - _("Round Off"): {"account_type": "Round Off"}, - _("Salary"): {}, - _("Sales Expenses"): {}, - _("Telephone Expenses"): {}, - _("Travel Expenses"): {}, - _("Utility Expenses"): {}, - _("Write Off"): {}, - _("Exchange Gain/Loss"): {}, - _("Gain/Loss on Asset Disposal"): {}, - _("Impairment"): {}, + _("Administrative Expenses"): {"account_category": "Operating Expenses"}, + _("Commission on Sales"): {"account_category": "Operating Expenses"}, + _("Depreciation"): {"account_type": "Depreciation", "account_category": "Operating Expenses"}, + _("Entertainment Expenses"): {"account_category": "Operating Expenses"}, + _("Freight and Forwarding Charges"): { + "account_type": "Chargeable", + "account_category": "Operating Expenses", + }, + _("Legal Expenses"): {"account_category": "Operating Expenses"}, + _("Marketing Expenses"): { + "account_type": "Chargeable", + "account_category": "Operating Expenses", + }, + _("Miscellaneous Expenses"): { + "account_type": "Chargeable", + "account_category": "Operating Expenses", + }, + _("Office Maintenance Expenses"): {"account_category": "Operating Expenses"}, + _("Office Rent"): {"account_category": "Operating Expenses"}, + _("Postal Expenses"): {"account_category": "Operating Expenses"}, + _("Print and Stationery"): {"account_category": "Operating Expenses"}, + _("Round Off"): {"account_type": "Round Off", "account_category": "Operating Expenses"}, + _("Salary"): {"account_category": "Operating Expenses"}, + _("Sales Expenses"): {"account_category": "Operating Expenses"}, + _("Telephone Expenses"): {"account_category": "Operating Expenses"}, + _("Travel Expenses"): {"account_category": "Operating Expenses"}, + _("Utility Expenses"): {"account_category": "Operating Expenses"}, + _("Write Off"): {"account_category": "Operating Expenses"}, + _("Exchange Gain/Loss"): {"account_category": "Operating Expenses"}, + _("Interest Expense"): {"account_category": "Finance Costs"}, + _("Bank Charges"): {"account_category": "Finance Costs"}, + _("Gain/Loss on Asset Disposal"): {"account_category": "Other Operating Income"}, + _("Impairment"): {"account_category": "Operating Expenses"}, + _("Tax Expense"): {"account_category": "Tax Expense"}, }, "root_type": "Expense", }, _("Income"): { - _("Direct Income"): {_("Sales"): {}, _("Service"): {}}, - _("Indirect Income"): {"is_group": 1}, + _("Direct Income"): { + _("Sales"): {"account_category": "Revenue from Operations"}, + _("Service"): {"account_category": "Revenue from Operations"}, + }, + _("Indirect Income"): { + _("Interest Income"): {"account_category": "Investment Income"}, + _("Interest on Fixed Deposits"): {"account_category": "Investment Income"}, + "is_group": 1, + }, "root_type": "Income", }, _("Source of Funds (Liabilities)"): { _("Current Liabilities"): { _("Accounts Payable"): { - _("Creditors"): {"account_type": "Payable"}, - _("Payroll Payable"): {}, + _("Creditors"): {"account_type": "Payable", "account_category": "Trade Payables"}, + _("Payroll Payable"): {"account_category": "Other Payables"}, }, + _("Accrued Expenses"): {"account_category": "Other Current Liabilities"}, + _("Customer Advances"): {"account_category": "Other Current Liabilities"}, _("Stock Liabilities"): { - _("Stock Received But Not Billed"): {"account_type": "Stock Received But Not Billed"}, - _("Asset Received But Not Billed"): {"account_type": "Asset Received But Not Billed"}, + _("Stock Received But Not Billed"): { + "account_type": "Stock Received But Not Billed", + "account_category": "Trade Payables", + }, + _("Asset Received But Not Billed"): { + "account_type": "Asset Received But Not Billed", + "account_category": "Trade Payables", + }, }, - _("Duties and Taxes"): {"account_type": "Tax", "is_group": 1}, + _("Duties and Taxes"): { + "account_type": "Tax", + "is_group": 1, + "account_category": "Current Tax Liabilities", + }, + _("Short-term Provisions"): {"account_category": "Short-term Provisions"}, _("Loans (Liabilities)"): { - _("Secured Loans"): {}, - _("Unsecured Loans"): {}, - _("Bank Overdraft Account"): {}, + _("Secured Loans"): {"account_category": "Long-term Borrowings"}, + _("Unsecured Loans"): {"account_category": "Long-term Borrowings"}, + _("Bank Overdraft Account"): {"account_category": "Short-term Borrowings"}, }, }, + _("Non-Current Liabilities"): { + _("Long-term Provisions"): {"account_category": "Long-term Provisions"}, + _("Employee Benefits Obligation"): {"account_category": "Other Non-current Liabilities"}, + "is_group": 1, + }, "root_type": "Liability", }, _("Equity"): { - _("Capital Stock"): {"account_type": "Equity"}, - _("Dividends Paid"): {"account_type": "Equity"}, - _("Opening Balance Equity"): {"account_type": "Equity"}, - _("Retained Earnings"): {"account_type": "Equity"}, - _("Revaluation Surplus"): {"account_type": "Equity"}, + _("Capital Stock"): {"account_type": "Equity", "account_category": "Share Capital"}, + _("Dividends Paid"): {"account_type": "Equity", "account_category": "Reserves and Surplus"}, + _("Opening Balance Equity"): { + "account_type": "Equity", + "account_category": "Reserves and Surplus", + }, + _("Retained Earnings"): {"account_type": "Equity", "account_category": "Reserves and Surplus"}, + _("Revaluation Surplus"): {"account_type": "Equity", "account_category": "Reserves and Surplus"}, "root_type": "Equity", }, } diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py index 07962df149e..d175967c628 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py @@ -10,49 +10,128 @@ def get(): _("Application of Funds (Assets)"): { _("Current Assets"): { _("Accounts Receivable"): { - _("Debtors"): {"account_type": "Receivable", "account_number": "1310"}, + _("Debtors"): { + "account_type": "Receivable", + "account_number": "1310", + "account_category": "Trade Receivables", + }, "account_number": "1300", }, - _("Bank Accounts"): {"account_type": "Bank", "is_group": 1, "account_number": "1200"}, + _("Bank Accounts"): { + "account_type": "Bank", + "is_group": 1, + "account_number": "1200", + "account_category": "Cash and Cash Equivalents", + }, _("Cash In Hand"): { - _("Cash"): {"account_type": "Cash", "account_number": "1110"}, + _("Cash"): { + "account_type": "Cash", + "account_number": "1110", + "account_category": "Cash and Cash Equivalents", + }, "account_type": "Cash", "account_number": "1100", + "account_category": "Cash and Cash Equivalents", }, _("Loans and Advances (Assets)"): { - _("Employee Advances"): {"account_number": "1610", "account_type": "Payable"}, + _("Employee Advances"): { + "account_number": "1610", + "account_type": "Payable", + "account_category": "Other Receivables", + }, "account_number": "1600", }, _("Securities and Deposits"): { - _("Earnest Money"): {"account_number": "1651"}, + _("Earnest Money"): { + "account_number": "1651", + "account_category": "Other Current Assets", + }, "account_number": "1650", }, + _("Prepaid Expenses"): { + "account_number": "1660", + "account_category": "Other Current Assets", + }, + _("Short-term Investments"): { + "account_number": "1670", + "account_category": "Short-term Investments", + }, _("Stock Assets"): { - _("Stock In Hand"): {"account_type": "Stock", "account_number": "1410"}, + _("Stock In Hand"): { + "account_type": "Stock", + "account_number": "1410", + "account_category": "Stock Assets", + }, "account_type": "Stock", "account_number": "1400", + "account_category": "Stock Assets", + }, + _("Tax Assets"): { + "is_group": 1, + "account_number": "1500", + "account_category": "Other Current Assets", }, - _("Tax Assets"): {"is_group": 1, "account_number": "1500"}, "account_number": "1100-1600", }, _("Fixed Assets"): { - _("Capital Equipment"): {"account_type": "Fixed Asset", "account_number": "1710"}, - _("Electronic Equipment"): {"account_type": "Fixed Asset", "account_number": "1720"}, - _("Furniture and Fixtures"): {"account_type": "Fixed Asset", "account_number": "1730"}, - _("Office Equipment"): {"account_type": "Fixed Asset", "account_number": "1740"}, - _("Plants and Machineries"): {"account_type": "Fixed Asset", "account_number": "1750"}, - _("Buildings"): {"account_type": "Fixed Asset", "account_number": "1760"}, - _("Software"): {"account_type": "Fixed Asset", "account_number": "1770"}, + _("Capital Equipment"): { + "account_type": "Fixed Asset", + "account_number": "1710", + "account_category": "Tangible Assets", + }, + _("Electronic Equipment"): { + "account_type": "Fixed Asset", + "account_number": "1720", + "account_category": "Tangible Assets", + }, + _("Furniture and Fixtures"): { + "account_type": "Fixed Asset", + "account_number": "1730", + "account_category": "Tangible Assets", + }, + _("Office Equipment"): { + "account_type": "Fixed Asset", + "account_number": "1740", + "account_category": "Tangible Assets", + }, + _("Plants and Machineries"): { + "account_type": "Fixed Asset", + "account_number": "1750", + "account_category": "Tangible Assets", + }, + _("Buildings"): { + "account_type": "Fixed Asset", + "account_number": "1760", + "account_category": "Tangible Assets", + }, + _("Software"): { + "account_type": "Fixed Asset", + "account_number": "1770", + "account_category": "Intangible Assets", + }, _("Accumulated Depreciation"): { "account_type": "Accumulated Depreciation", "account_number": "1780", + "account_category": "Tangible Assets", + }, + _("CWIP Account"): { + "account_type": "Capital Work in Progress", + "account_number": "1790", + "account_category": "Tangible Assets", }, - _("CWIP Account"): {"account_type": "Capital Work in Progress", "account_number": "1790"}, "account_number": "1700", }, - _("Investments"): {"is_group": 1, "account_number": "1800"}, + _("Investments"): { + "is_group": 1, + "account_number": "1800", + "account_category": "Long-term Investments", + }, _("Temporary Accounts"): { - _("Temporary Opening"): {"account_type": "Temporary", "account_number": "1910"}, + _("Temporary Opening"): { + "account_type": "Temporary", + "account_number": "1910", + "account_category": "Other Non-current Assets", + }, "account_number": "1900", }, "root_type": "Asset", @@ -61,97 +140,220 @@ def get(): _("Expenses"): { _("Direct Expenses"): { _("Stock Expenses"): { - _("Cost of Goods Sold"): {"account_type": "Cost of Goods Sold", "account_number": "5111"}, + _("Cost of Goods Sold"): { + "account_type": "Cost of Goods Sold", + "account_number": "5111", + "account_category": "Cost of Goods Sold", + }, _("Expenses Included In Asset Valuation"): { "account_type": "Expenses Included In Asset Valuation", "account_number": "5112", + "account_category": "Other Direct Costs", }, _("Expenses Included In Valuation"): { "account_type": "Expenses Included In Valuation", "account_number": "5118", + "account_category": "Other Direct Costs", + }, + _("Stock Adjustment"): { + "account_type": "Stock Adjustment", + "account_number": "5119", + "account_category": "Other Direct Costs", }, - _("Stock Adjustment"): {"account_type": "Stock Adjustment", "account_number": "5119"}, "account_number": "5110", }, "account_number": "5100", }, _("Indirect Expenses"): { - _("Administrative Expenses"): {"account_number": "5201"}, - _("Commission on Sales"): {"account_number": "5202"}, - _("Depreciation"): {"account_type": "Depreciation", "account_number": "5203"}, - _("Entertainment Expenses"): {"account_number": "5204"}, - _("Freight and Forwarding Charges"): {"account_type": "Chargeable", "account_number": "5205"}, - _("Legal Expenses"): {"account_number": "5206"}, - _("Marketing Expenses"): {"account_type": "Chargeable", "account_number": "5207"}, - _("Office Maintenance Expenses"): {"account_number": "5208"}, - _("Office Rent"): {"account_number": "5209"}, - _("Postal Expenses"): {"account_number": "5210"}, - _("Print and Stationery"): {"account_number": "5211"}, - _("Round Off"): {"account_type": "Round Off", "account_number": "5212"}, - _("Salary"): {"account_number": "5213"}, - _("Sales Expenses"): {"account_number": "5214"}, - _("Telephone Expenses"): {"account_number": "5215"}, - _("Travel Expenses"): {"account_number": "5216"}, - _("Utility Expenses"): {"account_number": "5217"}, - _("Write Off"): {"account_number": "5218"}, - _("Exchange Gain/Loss"): {"account_number": "5219"}, - _("Gain/Loss on Asset Disposal"): {"account_number": "5220"}, - _("Miscellaneous Expenses"): {"account_type": "Chargeable", "account_number": "5221"}, - "account_number": "5200", + _("Administrative Expenses"): { + "account_number": "5201", + "account_category": "Operating Expenses", + }, + _("Commission on Sales"): { + "account_number": "5202", + "account_category": "Operating Expenses", + }, + _("Depreciation"): { + "account_type": "Depreciation", + "account_number": "5203", + "account_category": "Operating Expenses", + }, + _("Entertainment Expenses"): { + "account_number": "5204", + "account_category": "Operating Expenses", + }, + _("Freight and Forwarding Charges"): { + "account_type": "Chargeable", + "account_number": "5205", + "account_category": "Operating Expenses", + }, + _("Legal Expenses"): {"account_number": "5206", "account_category": "Operating Expenses"}, + _("Marketing Expenses"): { + "account_type": "Chargeable", + "account_number": "5207", + "account_category": "Operating Expenses", + }, + _("Office Maintenance Expenses"): { + "account_number": "5208", + "account_category": "Operating Expenses", + }, + _("Office Rent"): {"account_number": "5209", "account_category": "Operating Expenses"}, + _("Postal Expenses"): {"account_number": "5210", "account_category": "Operating Expenses"}, + _("Print and Stationery"): { + "account_number": "5211", + "account_category": "Operating Expenses", + }, + _("Round Off"): { + "account_type": "Round Off", + "account_number": "5212", + "account_category": "Operating Expenses", + }, + _("Salary"): {"account_number": "5213", "account_category": "Operating Expenses"}, + _("Sales Expenses"): {"account_number": "5214", "account_category": "Operating Expenses"}, + _("Telephone Expenses"): {"account_number": "5215", "account_category": "Operating Expenses"}, + _("Travel Expenses"): {"account_number": "5216", "account_category": "Operating Expenses"}, + _("Utility Expenses"): {"account_number": "5217", "account_category": "Operating Expenses"}, + _("Write Off"): {"account_number": "5218", "account_category": "Operating Expenses"}, + _("Exchange Gain/Loss"): {"account_number": "5219", "account_category": "Operating Expenses"}, + _("Interest Expense"): {"account_number": "5220", "account_category": "Finance Costs"}, + _("Bank Charges"): {"account_number": "5221", "account_category": "Finance Costs"}, + _("Gain/Loss on Asset Disposal"): { + "account_number": "5222", + "account_category": "Other Operating Income", + }, + _("Miscellaneous Expenses"): { + "account_type": "Chargeable", + "account_number": "5223", + "account_category": "Operating Expenses", + }, + _("Impairment"): {"account_number": "5224", "account_category": "Operating Expenses"}, + _("Tax Expense"): {"account_number": "5225", "account_category": "Tax Expense"}, }, "root_type": "Expense", "account_number": "5000", }, _("Income"): { _("Direct Income"): { - _("Sales"): {"account_number": "4110"}, - _("Service"): {"account_number": "4120"}, + _("Sales"): {"account_number": "4110", "account_category": "Revenue from Operations"}, + _("Service"): {"account_number": "4120", "account_category": "Revenue from Operations"}, "account_number": "4100", }, - _("Indirect Income"): {"is_group": 1, "account_number": "4200"}, + _("Indirect Income"): { + _("Interest Income"): {"account_number": "4210", "account_category": "Investment Income"}, + _("Interest on Fixed Deposits"): { + "account_number": "4220", + "account_category": "Investment Income", + }, + "is_group": 1, + "account_number": "4200", + }, "root_type": "Income", "account_number": "4000", }, _("Source of Funds (Liabilities)"): { _("Current Liabilities"): { _("Accounts Payable"): { - _("Creditors"): {"account_type": "Payable", "account_number": "2110"}, - _("Payroll Payable"): {"account_number": "2120"}, + _("Creditors"): { + "account_type": "Payable", + "account_number": "2110", + "account_category": "Trade Payables", + }, + _("Payroll Payable"): {"account_number": "2120", "account_category": "Other Payables"}, "account_number": "2100", }, + _("Accrued Expenses"): { + "account_number": "2150", + "account_category": "Other Current Liabilities", + }, + _("Customer Advances"): { + "account_number": "2160", + "account_category": "Other Current Liabilities", + }, _("Stock Liabilities"): { _("Stock Received But Not Billed"): { "account_type": "Stock Received But Not Billed", "account_number": "2210", + "account_category": "Trade Payables", }, _("Asset Received But Not Billed"): { "account_type": "Asset Received But Not Billed", "account_number": "2211", + "account_category": "Trade Payables", }, "account_number": "2200", }, _("Duties and Taxes"): { - _("TDS Payable"): {"account_number": "2310"}, + _("TDS Payable"): { + "account_number": "2310", + "account_category": "Current Tax Liabilities", + }, "account_type": "Tax", "is_group": 1, "account_number": "2300", + "account_category": "Current Tax Liabilities", + }, + _("Short-term Provisions"): { + "account_number": "2350", + "account_category": "Short-term Provisions", }, _("Loans (Liabilities)"): { - _("Secured Loans"): {"account_number": "2410"}, - _("Unsecured Loans"): {"account_number": "2420"}, - _("Bank Overdraft Account"): {"account_number": "2430"}, + _("Secured Loans"): { + "account_number": "2410", + "account_category": "Long-term Borrowings", + }, + _("Unsecured Loans"): { + "account_number": "2420", + "account_category": "Long-term Borrowings", + }, + _("Bank Overdraft Account"): { + "account_number": "2430", + "account_category": "Short-term Borrowings", + }, "account_number": "2400", }, "account_number": "2100-2400", }, + _("Non-Current Liabilities"): { + _("Long-term Provisions"): { + "account_number": "2510", + "account_category": "Long-term Provisions", + }, + _("Employee Benefits Obligation"): { + "account_number": "2520", + "account_category": "Other Non-current Liabilities", + }, + "is_group": 1, + "account_number": "2500", + }, "root_type": "Liability", "account_number": "2000", }, _("Equity"): { - _("Capital Stock"): {"account_type": "Equity", "account_number": "3100"}, - _("Dividends Paid"): {"account_type": "Equity", "account_number": "3200"}, - _("Opening Balance Equity"): {"account_type": "Equity", "account_number": "3300"}, - _("Retained Earnings"): {"account_type": "Equity", "account_number": "3400"}, + _("Capital Stock"): { + "account_type": "Equity", + "account_number": "3100", + "account_category": "Share Capital", + }, + _("Dividends Paid"): { + "account_type": "Equity", + "account_number": "3200", + "account_category": "Reserves and Surplus", + }, + _("Opening Balance Equity"): { + "account_type": "Equity", + "account_number": "3300", + "account_category": "Reserves and Surplus", + }, + _("Retained Earnings"): { + "account_type": "Equity", + "account_number": "3400", + "account_category": "Reserves and Surplus", + }, + _("Revaluation Surplus"): { + "account_type": "Equity", + "account_number": "3500", + "account_category": "Reserves and Surplus", + }, "root_type": "Equity", "account_number": "3000", }, diff --git a/erpnext/accounts/doctype/account_category/__init__.py b/erpnext/accounts/doctype/account_category/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/account_category/account_category.js b/erpnext/accounts/doctype/account_category/account_category.js new file mode 100644 index 00000000000..127eb380e00 --- /dev/null +++ b/erpnext/accounts/doctype/account_category/account_category.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Account Category", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/accounts/doctype/account_category/account_category.json b/erpnext/accounts/doctype/account_category/account_category.json new file mode 100644 index 00000000000..d69d37bd78b --- /dev/null +++ b/erpnext/accounts/doctype/account_category/account_category.json @@ -0,0 +1,71 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:account_category_name", + "creation": "2025-08-02 06:22:31.835063", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "account_category_name", + "description" + ], + "fields": [ + { + "fieldname": "account_category_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Account Category Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-10-15 03:19:47.171349", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Account Category", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "Auditor" + } + ], + "row_format": "Dynamic", + "search_fields": "account_category_name, description", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/account_category/account_category.py b/erpnext/accounts/doctype/account_category/account_category.py new file mode 100644 index 00000000000..8be84d0f8e2 --- /dev/null +++ b/erpnext/accounts/doctype/account_category/account_category.py @@ -0,0 +1,94 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt +import json +import os + +import frappe +from frappe import _ +from frappe.model.document import Document, bulk_insert + +DOCTYPE = "Account Category" + + +class AccountCategory(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + account_category_name: DF.Data + description: DF.SmallText | None + # end: auto-generated types + + def after_rename(self, old_name, new_name, merge): + from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( + FormulaFieldUpdater, + ) + + # get all template rows with this account category being used + row = frappe.qb.DocType("Financial Report Row") + rows = frappe._dict( + frappe.qb.from_(row) + .select(row.name, row.calculation_formula) + .where(row.calculation_formula.like(f"%{old_name}%")) + .run() + ) + + if not rows: + return + + # Update formulas with new name + updater = FormulaFieldUpdater( + field_name="account_category", + value_mapping={old_name: new_name}, + exclude_operators=["like", "not like"], + ) + + updated_formulas = updater.update_in_rows(rows) + + if updated_formulas: + frappe.msgprint( + _("Updated {0} Financial Report Row(s) with new category name").format(len(updated_formulas)) + ) + + +def import_account_categories(template_path: str): + categories_file = os.path.join(template_path, "account_categories.json") + + if not os.path.exists(categories_file): + return + + with open(categories_file) as f: + categories = json.load(f, object_hook=frappe._dict) + + create_account_categories(categories) + + +def create_account_categories(categories: list[dict]): + if not categories: + return + + existing_categories = set(frappe.get_all(DOCTYPE, pluck="name")) + new_categories = [] + + for category_data in categories: + category_name = category_data.get("account_category_name") + if not category_name or category_name in existing_categories: + continue + + doc = frappe.get_doc( + { + **category_data, + "doctype": DOCTYPE, + "name": category_name, + } + ) + + new_categories.append(doc) + existing_categories.add(category_name) + + if new_categories: + bulk_insert(DOCTYPE, new_categories) diff --git a/erpnext/accounts/doctype/account_category/test_account_category.py b/erpnext/accounts/doctype/account_category/test_account_category.py new file mode 100644 index 00000000000..ea3c2c7783c --- /dev/null +++ b/erpnext/accounts/doctype/account_category/test_account_category.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestAccountCategory(IntegrationTestCase): + """ + Integration tests for AccountCategory. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/erpnext/accounts/doctype/financial_report_row/__init__.py b/erpnext/accounts/doctype/financial_report_row/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/financial_report_row/financial_report_row.json b/erpnext/accounts/doctype/financial_report_row/financial_report_row.json new file mode 100644 index 00000000000..0acbfd69a47 --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_row/financial_report_row.json @@ -0,0 +1,187 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-09-06 09:39:46.503678", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_code", + "display_name", + "indentation_level", + "data_source", + "balance_type", + "column_break_hxqu", + "fieldtype", + "color", + "bold_text", + "italic_text", + "hidden_calculation", + "hide_when_empty", + "reverse_sign", + "include_in_charts", + "section_break_ornw", + "column_break_asfe", + "advanced_filtering", + "filters_editor", + "calculation_formula", + "section_break_pvro", + "formula_description" + ], + "fields": [ + { + "columns": 1, + "description": "Code to reference this line in formulas (e.g., REV100, EXP200, ASSET100)", + "fieldname": "reference_code", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Line Reference" + }, + { + "description": "Text displayed on the financial statement (e.g., 'Total Revenue', 'Cash and Cash Equivalents')", + "fieldname": "display_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Display Name" + }, + { + "columns": 1, + "description": "Indentation level: 0 = Main heading, 1 = Sub-category, 2 = Individual accounts, etc.", + "fieldname": "indentation_level", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Indent Level" + }, + { + "description": "How this line gets its data", + "fieldname": "data_source", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Data Source", + "options": "\nAccount Data\nCalculated Amount\nCustom API\nBlank Line\nColumn Break\nSection Break" + }, + { + "depends_on": "eval:doc.data_source == 'Account Data'", + "description": "Opening Balance = Start of period, Closing Balance = End of period, Period Movement = Net change during period", + "fieldname": "balance_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Balance Type", + "mandatory_depends_on": "eval:doc.data_source == 'Account Data'", + "options": "\nOpening Balance\nClosing Balance\nPeriod Movement (Debits - Credits)" + }, + { + "fieldname": "column_break_hxqu", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Bold text for emphasis (totals, major headings)", + "fieldname": "bold_text", + "fieldtype": "Check", + "label": "Bold Text" + }, + { + "default": "0", + "description": "Italic text for subtotals or notes", + "fieldname": "italic_text", + "fieldtype": "Check", + "label": "Italic Text" + }, + { + "default": "0", + "description": "Calculate but don't show on final report", + "fieldname": "hidden_calculation", + "fieldtype": "Check", + "label": "Hidden Line (Internal Use Only)" + }, + { + "default": "0", + "description": "Hide this line if amount is zero", + "fieldname": "hide_when_empty", + "fieldtype": "Check", + "label": "Hide If Zero" + }, + { + "columns": 1, + "default": "0", + "description": "Show negative values as positive (for expenses in P&L)", + "fieldname": "reverse_sign", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Reverse Sign" + }, + { + "fieldname": "section_break_ornw", + "fieldtype": "Section Break" + }, + { + "depends_on": "eval: (doc.data_source === \"Account Data\" && doc.advanced_filtering) || [\"Calculated Amount\", \"Custom API\"].includes(doc.data_source);\n", + "fieldname": "calculation_formula", + "fieldtype": "Code", + "label": "Formula or Account Filter", + "mandatory_depends_on": "eval:doc.data_source != 'Blank Line' && doc.data_source != 'Column Break' && doc.data_source != 'Section Break'" + }, + { + "fieldname": "formula_description", + "fieldtype": "HTML" + }, + { + "default": "0", + "description": "If enabled, this row's values will be displayed on financial charts", + "fieldname": "include_in_charts", + "fieldtype": "Check", + "label": "Include in Charts" + }, + { + "description": "Color to highlight values (e.g., red for exceptions)", + "fieldname": "color", + "fieldtype": "Color", + "label": "Color" + }, + { + "description": "How to format and present values in the financial report (only if different from column fieldtype)", + "fieldname": "fieldtype", + "fieldtype": "Select", + "label": "Value Type", + "options": "\nCurrency\nFloat\nInt\nPercent" + }, + { + "depends_on": "eval: doc.data_source === \"Account Data\" && !doc.advanced_filtering", + "fieldname": "filters_editor", + "fieldtype": "HTML" + }, + { + "depends_on": "eval: ![\"Blank Line\", \"Column Break\", \"Section Break\"].includes(doc.data_source);", + "fieldname": "column_break_asfe", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval: doc.data_source === \"Account Data\"", + "description": "Use Python filters to get Accounts", + "fieldname": "advanced_filtering", + "fieldtype": "Check", + "label": "Advanced Filtering", + "print_hide": 1 + }, + { + "fieldname": "section_break_pvro", + "fieldtype": "Section Break" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-10-14 09:23:27.208072", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Financial Report Row", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/financial_report_row/financial_report_row.py b/erpnext/accounts/doctype/financial_report_row/financial_report_row.py new file mode 100644 index 00000000000..0b2e9d2e4e5 --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_row/financial_report_row.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class FinancialReportRow(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + advanced_filtering: DF.Check + balance_type: DF.Literal[ + "", "Opening Balance", "Closing Balance", "Period Movement (Debits - Credits)" + ] + bold_text: DF.Check + calculation_formula: DF.Code | None + color: DF.Color | None + data_source: DF.Literal[ + "", + "Account Data", + "Calculated Amount", + "Custom API", + "Blank Line", + "Column Break", + "Section Break", + ] + display_name: DF.Data | None + fieldtype: DF.Literal["", "Currency", "Float", "Int", "Percent"] + hidden_calculation: DF.Check + hide_when_empty: DF.Check + include_in_charts: DF.Check + indentation_level: DF.Int + italic_text: DF.Check + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + reference_code: DF.Data | None + reverse_sign: DF.Check + # end: auto-generated types + + pass diff --git a/erpnext/accounts/doctype/financial_report_template/__init__.py b/erpnext/accounts/doctype/financial_report_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py new file mode 100644 index 00000000000..67fcebb7ebf --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py @@ -0,0 +1,1802 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import ast +import json +import math +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from functools import reduce +from typing import Any, Union + +import frappe +from frappe import _ +from frappe.database.operator_map import OPERATOR_MAP +from frappe.query_builder import Case +from frappe.query_builder.functions import Sum +from frappe.utils import cstr, date_diff, flt, getdate +from pypika.terms import LiteralValue + +from erpnext import get_company_currency +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, + get_dimension_with_children, +) +from erpnext.accounts.doctype.financial_report_row.financial_report_row import FinancialReportRow +from erpnext.accounts.doctype.financial_report_template.financial_report_template import ( + FinancialReportTemplate, +) +from erpnext.accounts.doctype.financial_report_template.financial_report_validation import ( + AccountFilterValidator, + CalculationFormulaValidator, + DependencyValidator, +) +from erpnext.accounts.report.financial_statements import ( + get_columns, + get_cost_centers_with_children, + get_period_list, +) +from erpnext.accounts.utils import get_children, get_currency_precision + +# ============================================================================ +# DATA MODELS +# ============================================================================ + + +@dataclass +class PeriodValue: + """Represents financial data for a single period""" + + period_key: str + opening: float = 0.0 + closing: float = 0.0 + movement: float = 0.0 + + def get_value(self, balance_type: str) -> float: + if balance_type == "Opening Balance": + return self.opening + elif balance_type == "Closing Balance": + return self.closing + elif balance_type == "Period Movement (Debits - Credits)": + return self.movement + return 0.0 + + def copy(self): + return PeriodValue( + period_key=self.period_key, opening=self.opening, closing=self.closing, movement=self.movement + ) + + +@dataclass +class AccountData: + """Account data across all periods""" + + account_name: str + period_values: dict[str, PeriodValue] = field(default_factory=dict) + + def add_period(self, period_value: PeriodValue) -> None: + self.period_values[period_value.period_key] = period_value + + def get_period(self, period_key: str) -> PeriodValue | None: + return self.period_values.get(period_key) + + def get_values_by_type(self, balance_type: str) -> list[float]: + return [pv.get_value(balance_type) for pv in self.period_values.values()] + + def get_ordered_values(self, period_keys: list[str], balance_type: str) -> list[float]: + return [ + self.period_values[key].get_value(balance_type) if key in self.period_values else 0.0 + for key in period_keys + ] + + def has_periods(self) -> bool: + return len(self.period_values) > 0 + + def accumulate_values(self) -> None: + for period_value in self.period_values.values(): + period_value.movement += period_value.opening + # closing is accumulated by default + + def unaccumulate_values(self) -> None: + for period_value in self.period_values.values(): + period_value.closing -= period_value.opening + # movement is unaccumulated by default + + def copy(self): + copied = AccountData(account_name=self.account_name) + copied.period_values = {k: v.copy() for k, v in self.period_values.items()} + return copied + + def reverse_values(self) -> None: + for period_value in self.period_values.values(): + period_value.opening = -period_value.opening if period_value.opening else 0.0 + period_value.closing = -period_value.closing if period_value.closing else 0.0 + period_value.movement = -period_value.movement if period_value.movement else 0.0 + + +@dataclass +class RowData: + """Represents a processed template row with calculated values""" + + row: FinancialReportRow + values: list[float] = field(default_factory=list) + account_details: dict[str, AccountData] | None = None + is_detail_row: bool = False + parent_reference: str | None = None + + +@dataclass +class SegmentData: + """Represents a segment with its rows and metadata""" + + rows: list[RowData] = field(default_factory=list) + label: str = "" + index: int = 0 + + @property + def id(self) -> str: + return f"seg_{self.index}" + + +@dataclass +class SectionData: + """Represents a horizontal section containing multiple column segments""" + + segments: list[SegmentData] + label: str = "" + index: int = 0 + + @property + def id(self) -> str: + return f"section_{self.index}" + + +@dataclass +class ReportContext: + """Context object that flows through the pipeline""" + + template: FinancialReportTemplate + filters: dict[str, Any] + period_list: list[dict] = field(default_factory=list) + processed_rows: list[RowData] = field(default_factory=list) + column_segments: list[list[RowData]] = field(default_factory=list) + account_data: dict[str, AccountData] = field(default_factory=dict) + raw_data: dict[str, Any] = field(default_factory=dict) + show_detailed: bool = False + currency: str | None = None + + def get_result(self) -> tuple[list[dict], list[dict]]: + """Get final formatted columns and data""" + return ( + self.raw_data.get("columns", []), + self.raw_data.get("formatted_data", []), + None, + self.raw_data.get("chart", {}), + ) + + +@dataclass +class FormattingRule: + """Rule for applying formatting to rows""" + + condition: callable + format_properties: Union[dict[str, Any], callable] # noqa: UP007 + + def applies_to(self, row_data: RowData) -> bool: + return self.condition(row_data) + + def get_properties(self, row_data: RowData) -> dict[str, Any]: + """Get the format properties, handling both static and dynamic cases""" + if callable(self.format_properties): + return self.format_properties(row_data) + return self.format_properties + + +# ============================================================================ +# REPORT ENGINE +# ============================================================================ + + +class FinancialReportEngine: + def execute(self, filters: dict[str, Any]) -> tuple[list[dict], list[dict]]: + """Execute the complete report generation""" + self._validate_filters(filters) + + # Initialize context + context = self._initialize_context(filters) + + # Execute + self.collect_financial_data(context) + self.process_calculations(context) + self.format_report_data(context) + self.apply_view_transformation(context) + + # Chart + self.generate_chart_data(context) + return context.get_result() + + def _validate_filters(self, filters: dict[str, Any]) -> None: + required_filters = ["report_template", "period_start_date", "period_end_date"] + + for filter_key in required_filters: + if not filters.get(filter_key): + frappe.throw(_("Missing required filter: {0}").format(filter_key)) + + if filters.get("presentation_currency"): + frappe.msgprint(_("Currency filters are currently unsupported in Custom Financial Report.")) + + # Margin view is dependent on first row being an income account. Hence not supported. + # Way to implement this would be using calculated rows with formulas. + supported_views = ("Report", "Growth") + if (view := filters.get("selected_view")) and view not in supported_views: + frappe.msgprint(_("{0} view is currently unsupported in Custom Financial Report.").format(view)) + + def _initialize_context(self, filters: dict[str, Any]) -> ReportContext: + template_name = filters.get("report_template") + template = frappe.get_doc("Financial Report Template", template_name) + + if not template: + frappe.throw(_("Financial Report Template {0} not found").format(template_name)) + + if template.disabled: + frappe.throw(_("Financial Report Template {0} is disabled").format(template_name)) + + # Generate periods + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + + # Support both old and new field names for backward compatibility + show_detailed = filters.get("show_account_details") == "Account Breakdown" + + context = ReportContext( + template=template, + filters=filters, + period_list=period_list, + show_detailed=show_detailed, + # TODO: Enhance this to support report currencies + # after fixing which exchange rate to use for P&L + currency=get_company_currency(filters.company), + ) + # Add period_keys to context + context.raw_data["period_keys"] = [p["key"] for p in period_list] + return context + + def collect_financial_data(self, context: ReportContext) -> ReportContext: + collector = DataCollector(context.filters, context.period_list) + + for row in context.template.rows: + if row.data_source == "Account Data": + collector.add_account_request(row) + + all_data = collector.collect_all_data() + context.account_data = all_data["account_data"] + context.raw_data.update(all_data) + + return context + + def process_calculations(self, context: ReportContext) -> ReportContext: + processor = RowProcessor(context) + context.processed_rows = processor.process_all_rows() + + return context + + def format_report_data(self, context: ReportContext) -> ReportContext: + formatter = DataFormatter(context) + formatted_data, columns = formatter.format_for_display() + + context.raw_data["formatted_data"] = formatted_data + context.raw_data["columns"] = columns + + return context + + def apply_view_transformation(self, context: ReportContext) -> ReportContext: + if context.filters.get("selected_view") == "Growth": + transformer = GrowthViewTransformer(context) + transformer.transform() + + # Default is "Report" view - no transformation needed + + return context + + def generate_chart_data(self, context: ReportContext) -> dict[str, Any]: + generator = ChartDataGenerator(context) + generator.generate() + + return context + + +# ============================================================================ +# DATA COLLECTION +# ============================================================================ + + +class DataCollector: + """Data collector that fetches all data in optimized queries""" + + def __init__(self, filters: dict[str, Any], periods: list[dict]): + self.filters = filters + self.periods = periods + self.company = filters.get("company") + self.account_requests = [] + self.query_builder = FinancialQueryBuilder(filters, periods) + 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, + "balance_type": row.balance_type, + "reference_code": row.reference_code, + "reverse_sign": row.reverse_sign, + } + ) + + def collect_all_data(self) -> dict[str, Any]: + 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"]) + + all_accounts = list(all_accounts) + if not all_accounts: + return {"account_data": {}, "summary": {}, "account_details": {}} + + # Fetch balance data for all accounts + account_data = self.query_builder.fetch_account_balances(all_accounts) + + # Calculate summaries for each request + summary = {} + account_details = {} + period_keys = [p["key"] for p in self.periods] + + for request in self.account_requests: + ref_code = request["reference_code"] + if not ref_code: + continue + + balance_type = request["balance_type"] + accounts = request["accounts"] + + total_values = [0.0] * len(self.periods) + request_account_details = {} + + for account_name in accounts: + if account_name not in account_data: + continue + + account_obj: AccountData = account_data[account_name].copy() + if request["reverse_sign"]: + account_obj.reverse_values() + + account_values = account_obj.get_ordered_values(period_keys, balance_type) + + # Add to totals + for i, value in enumerate(account_values): + total_values[i] += value + + # Store for detailed view + request_account_details[account_name] = account_obj + + summary[ref_code] = total_values + account_details[ref_code] = request_account_details + + return {"account_data": account_data, "summary": summary, "account_details": account_details} + + @staticmethod + def _parse_account_filter(company, report_row) -> list[str]: + """ + Find accounts matching filter criteria. + + Example: + Input: '["account_type", "=", "Cash"]' + Output: ["Cash - COMP", "Petty Cash - COMP", "Bank - COMP"] + """ + filter_parser = FilterExpressionParser() + + account = frappe.qb.DocType("Account") + query = ( + frappe.qb.from_(account) + .select(account.name) + .where(account.disabled == 0) + .where(account.is_group == 0) + ) + + if company: + query = query.where(account.company == company) + + where_condition = filter_parser.build_condition(report_row, account) + if where_condition is None: + return [] + + query = query.where(where_condition) + query = query.orderby(account.name) + result = query.run(as_dict=True) + return [row.name for row in result] + + @staticmethod + def get_filtered_accounts(company: str, account_rows: list) -> list[str]: + filter_parser = FilterExpressionParser() + + account = frappe.qb.DocType("Account") + query = ( + frappe.qb.from_(account) + .select(account.name) + .distinct() + .where(account.disabled == 0) + .where(account.is_group == 0) + .orderby(account.name) + ) + + if company: + query = query.where(account.company == company) + + if conditions := filter_parser.build_conditions(account_rows, account): + query = query.where(conditions) + + return query.run(pluck=True) + + +class FinancialQueryBuilder: + """Centralized query builder for financial data""" + + def __init__(self, filters: dict[str, Any], periods: list[dict]): + self.filters = filters + self.periods = periods + self.company = filters.get("company") + + def fetch_account_balances(self, accounts: list[str]) -> dict[str, AccountData]: + """ + Fetch account balances for all periods with optimization. + Steps: get opening balances → fetch GL entries → calculate running totals + + Returns: + dict: {account: AccountData} + """ + balances_data = self._get_opening_balances(accounts) + gl_data = self._get_gl_movements(accounts) + self._calculate_running_balances(balances_data, gl_data) + self._handle_balance_accumulation(balances_data) + + return balances_data + + def _get_opening_balances(self, accounts: list[str]) -> dict[str, dict[str, dict[str, float]]]: + """ + Return opening balances for *all accounts* defaulting to zero. + """ + if frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance"): + return self._get_opening_balances_from_gl(accounts) + + first_period_start = getdate(self.periods[0]["from_date"]) + last_closing_voucher = frappe.db.get_all( + "Period Closing Voucher", + filters={ + "docstatus": 1, + "company": self.company, + "period_end_date": ("<", first_period_start), + }, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + + if last_closing_voucher: + closing_voucher = last_closing_voucher[0] + closing_data = self._get_closing_balances(accounts, closing_voucher.name) + + if sum(closing_data.values()) != 0.0: + return self._rebase_closing_balances(closing_data, closing_voucher.period_end_date) + + return self._get_opening_balances_from_gl(accounts) + + def _get_closing_balances(self, account_names: list[str], closing_voucher: str) -> dict[str, float]: + closing_balances = {account: 0.0 for account in account_names} + acb_table = frappe.qb.DocType("Account Closing Balance") + + query = ( + frappe.qb.from_(acb_table) + .select( + acb_table.account, + (acb_table.debit - acb_table.credit).as_("balance"), + ) + .where(acb_table.company == self.company) + .where(acb_table.account.isin(account_names)) + .where(acb_table.period_closing_voucher == closing_voucher) + ) + + query = self._apply_standard_filters(query, acb_table) + results = self._execute_with_permissions(query, "Account Closing Balance") + + for row in results: + closing_balances[row["account"]] = row["balance"] + + return closing_balances + + def _rebase_closing_balances( + self, closing_data: dict[str, float], closing_date: str + ) -> dict[str, dict[str, dict[str, float]]]: + balances_data = {} + + first_period_key = self.periods[0]["key"] + report_start = getdate(self.periods[0]["from_date"]) + closing_end = getdate(closing_date) + + has_gap = date_diff(report_start, closing_end) > 1 + + gap_movements = {} + if has_gap: + gap_movements = self._get_gap_movements(list(closing_data.keys()), closing_date, report_start) + + for account, closing_balance in closing_data.items(): + gap_movement = gap_movements.get(account, 0.0) + opening_balance = closing_balance + gap_movement + + account_data = AccountData(account) + account_data.add_period(PeriodValue(first_period_key, opening_balance, 0, 0)) + balances_data[account] = account_data + + return balances_data + + def _get_opening_balances_from_gl(self, accounts: list[str]) -> dict: + # Simulate zero closing balances + zero_closing_balances = {account: 0.0 for account in accounts} + + # Use a very early date + earliest_date = "1900-01-01" + + return self._rebase_closing_balances(zero_closing_balances, earliest_date) + + def _get_gap_movements(self, account_names: list[str], from_date: str, to_date: str) -> dict[str, float]: + gl_table = frappe.qb.DocType("GL Entry") + + query = ( + frappe.qb.from_(gl_table) + .select(gl_table.account, Sum(gl_table.debit - gl_table.credit).as_("movement")) + .where(gl_table.company == self.company) + .where(gl_table.is_cancelled == 0) + .where(gl_table.account.isin(account_names)) + .where(gl_table.posting_date > from_date) + .where(gl_table.posting_date < to_date) + .groupby(gl_table.account) + ) + + query = self._apply_standard_filters(query, gl_table) + results = self._execute_with_permissions(query, "GL Entry") + + return {row["account"]: row["movement"] or 0.0 for row in results} + + def _get_gl_movements(self, account_names: list[str]) -> list[dict]: + gl_table = frappe.qb.DocType("GL Entry") + + query = ( + frappe.qb.from_(gl_table) + .select(gl_table.account) + .where(gl_table.company == self.company) + .where(gl_table.is_cancelled == 0) + .where(gl_table.account.isin(account_names)) + .where(gl_table.posting_date >= self.periods[0]["from_date"]) + .groupby(gl_table.account) + ) + + if not frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting"): + query = query.where(gl_table.is_opening == "No") + + # Add period-specific columns + for period in self.periods: + period_condition = ( + Case() + .when( + (gl_table.posting_date >= period["from_date"]) + & (gl_table.posting_date <= period["to_date"]), + gl_table.debit - gl_table.credit, + ) + .else_(0) + ) + query = query.select(Sum(period_condition).as_(period["key"])) + + query = self._apply_standard_filters(query, gl_table) + return self._execute_with_permissions(query, "GL Entry") + + def _calculate_running_balances(self, balances_data: dict, gl_data: list[dict]) -> dict: + for row in gl_data: + account = row["account"] + if account not in balances_data: + balances_data[account] = AccountData(account) + + account_data: AccountData = balances_data[account] + + if account_data.has_periods(): + first_period = account_data.get_period(self.periods[0]["key"]) + current_balance = first_period.get_value("Opening Balance") if first_period else 0.0 + else: + current_balance = 0.0 + + for period in self.periods: + period_key = period["key"] + movement = row.get(period_key, 0.0) + closing_balance = current_balance + movement + + account_data.add_period(PeriodValue(period_key, current_balance, closing_balance, movement)) + + current_balance = closing_balance + + # Accounts with no movements + for account_data in balances_data.values(): + for period in self.periods: + period_key = period["key"] + if period_key not in account_data.period_values: + account_data.add_period(PeriodValue(period_key, 0.0, 0.0, 0.0)) + + def _handle_balance_accumulation(self, balances_data): + for account_data in balances_data.values(): + account_data: AccountData + + accumulated_values = self.filters.get("accumulated_values") + + if accumulated_values is None: + # respect user setting if not in filters + # closing = accumulated + # movement = unaccumulated + continue + + # for legacy reports + elif accumulated_values: + account_data.accumulate_values() + else: + account_data.unaccumulate_values() + + def _apply_standard_filters(self, query, table): + if self.filters.get("ignore_closing_entries"): + if hasattr(table, "is_period_closing_voucher_entry"): + query = query.where(table.is_period_closing_voucher_entry == 0) + else: + query = query.where(table.voucher_type != "Period Closing Voucher") + + if self.filters.get("project"): + projects = self.filters.get("project") + if isinstance(projects, str): + projects = [projects] + query = query.where(table.project.isin(projects)) + + if self.filters.get("cost_center"): + self.filters.cost_center = get_cost_centers_with_children(self.filters.cost_center) + query = query.where(table.cost_center.isin(self.filters.cost_center)) + + finance_book = self.filters.get("finance_book") + if self.filters.get("include_default_book_entries"): + default_book = frappe.get_cached_value("Company", self.filters.company, "default_finance_book") + + if finance_book and default_book and cstr(finance_book) != cstr(default_book): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + + query = query.where( + (table.finance_book.isin([cstr(finance_book), cstr(default_book), ""])) + | (table.finance_book.isnull()) + ) + else: + query = query.where( + (table.finance_book.isin([cstr(finance_book), ""])) | (table.finance_book.isnull()) + ) + + dimensions = get_accounting_dimensions(as_list=False) + for dimension in dimensions: + if self.filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + self.filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, self.filters.get(dimension.fieldname) + ) + + query = query.where(table[dimension.fieldname].isin(self.filters.get(dimension.fieldname))) + + return query + + def _execute_with_permissions(self, query, doctype): + from frappe.desk.reportview import build_match_conditions + + user_conditions = build_match_conditions(doctype) + + if user_conditions: + query = query.where(LiteralValue(user_conditions)) + + return query.run(as_dict=True) + + +class FilterExpressionParser: + """Direct filter expression to SQL condition builder""" + + def __init__(self): + self.validator = AccountFilterValidator() + + def build_conditions(self, report_rows, table): + conditions = [] + for row in report_rows or []: + condition = self.build_condition(row, table) + if condition is not None: + conditions.append(condition) + + # ensure brackets in or condition + return reduce(lambda a, b: (a) | (b), conditions) + + def build_condition(self, report_row, table): + """ + Build SQL condition directly from filter formula. + + Supports: + 1. Simple condition: ["field", "operator", "value"] + Example: ["account_type", "=", "Income"] + + 2. Complex logical conditions: + {"and": [condition1, condition2, ...]} # All conditions must be true + {"or": [condition1, condition2, ...]} # Any condition can be true + + Example: + { + "and": [ + ["account_type", "=", "Income"], + {"or": [ + ["category", "=", "Direct Income"], + ["category", "=", "Indirect Income"] + ]} + ] + } + + Returns: + SQL condition object or None if invalid + """ + filter_formula = report_row.calculation_formula + if not filter_formula: + return None + + errors = self.validator.validate(report_row) + if not errors.is_valid: + error_messages = [str(issue) for issue in errors.issues] + frappe.log_error(f"Filter validation errors found:\n{'

'.join(error_messages)}") + return None + + try: + parsed = ast.literal_eval(filter_formula) + return self._build_from_parsed(parsed, table) + except (ValueError, SyntaxError) as e: + frappe.log_error(f"Invalid filter formula syntax: {filter_formula} - {e}") + return None + except Exception as e: + frappe.log_error(f"Failed to build condition from formula: {filter_formula} - {e}") + return None + + def _build_from_parsed(self, parsed, table): + if isinstance(parsed, dict): + return self._build_logical_condition(parsed, table) + + if isinstance(parsed, list): + return self._build_simple_condition(parsed, table) + + return None + + def _build_simple_condition(self, condition_list: list[str, str, str | float], table): + field_name, operator, value = condition_list + + if value is None: + return None + + field = getattr(table, field_name, None) + operator_fn = OPERATOR_MAP.get(operator.casefold()) + + if "like" in operator.casefold() and "%" not in value: + value = f"%{value}%" + + return operator_fn(field, value) + + def _build_logical_condition(self, condition_dict: dict, table): + """Build SQL condition from logical {"and/or": [...]} format""" + + logical_op = next(iter(condition_dict.keys())).lower() + sub_conditions = condition_dict.get(logical_op) + + # recursive + built_conditions = [] + for sub_condition in sub_conditions: + condition = self._build_from_parsed(sub_condition, table) + if condition is not None: + built_conditions.append(condition) + + if not built_conditions: + return None + + if len(built_conditions) == 1: + return built_conditions[0] + + # combine + if logical_op == "and": + return reduce(lambda a, b: a & b, built_conditions) + else: # logical_op == "or" + return reduce(lambda a, b: a | b, built_conditions) + + +class FormulaFieldExtractor: + """Extract field values from filter formulas without SQL execution""" + + def __init__(self, field_name: str, exclude_operators: list[str] | None = None): + """ + Initialize field extractor. + + Args: + field_name: The field to extract values for (e.g., "account_category") + exclude_operators: List of operators to exclude (e.g., ["like"]) + """ + self.field_name = field_name + self.exclude_operators = [op.lower() for op in (exclude_operators or [])] + + def extract_from_rows(self, rows: list) -> set: + values = set() + + for row in rows: + if not hasattr(row, "calculation_formula") or not row.calculation_formula: + continue + + try: + parsed = ast.literal_eval(row.calculation_formula) + self._extract_recursive(parsed, values) + except (ValueError, SyntaxError): + continue # Skip rows with invalid formulas + + return values + + def _extract_recursive(self, parsed, values: set): + if isinstance(parsed, list) and len(parsed) == 3: + # Simple condition: ["field", "operator", "value"] + field, operator, value = parsed + + if field == self.field_name and operator.lower() not in self.exclude_operators: + if isinstance(value, str): + values.add(value) + elif isinstance(value, list): + # Handle "in" operator with list of values + values.update(v for v in value if isinstance(v, str)) + + elif isinstance(parsed, dict): + # Logical condition: {"and/or": [...]} + for sub_conditions in parsed.values(): + if isinstance(sub_conditions, list): + for sub_condition in sub_conditions: + self._extract_recursive(sub_condition, values) + + +class FormulaFieldUpdater: + """Update field values in filter formulas""" + + def __init__( + self, field_name: str, value_mapping: dict[str, str], exclude_operators: list[str] | None = None + ): + """ + Initialize field updater. + + Args: + field_name: The field to update values for (e.g., "account_category") + value_mapping: Mapping of old values to new values (e.g., {"Old Name": "New Name"}) + exclude_operators: List of operators to exclude from updates (e.g., ["like", "not like"]) + """ + self.field_name = field_name + self.value_mapping = value_mapping + self.exclude_operators = [op.lower() for op in (exclude_operators or [])] + + def update_in_rows(self, rows: list) -> dict[str, dict[str, str]]: + updated_rows = {} + + for row_name, formula in rows.items(): + if not formula: + continue + + try: + parsed = ast.literal_eval(formula) + updated = self._update_recursive(parsed) + + if updated != parsed: + updated_formula = json.dumps(updated) + updated_rows[row_name] = {"calculation_formula": updated_formula} + + except (ValueError, SyntaxError): + continue # Skip rows with invalid formulas + + if updated_rows: + frappe.db.bulk_update("Financial Report Row", updated_rows, update_modified=False) + + return updated_rows + + def _update_recursive(self, parsed): + if isinstance(parsed, list) and len(parsed) == 3: + # Simple condition: ["field", "operator", "value"] + field, operator, value = parsed + + if field == self.field_name and operator.lower() not in self.exclude_operators: + updated_value = self._update_value(value) + return [field, operator, updated_value] + + return parsed + + elif isinstance(parsed, dict): + # Logical condition: {"and/or": [...]} + updated_dict = {} + for key, sub_conditions in parsed.items(): + updated_conditions = [ + self._update_recursive(sub_condition) for sub_condition in sub_conditions + ] + updated_dict[key] = updated_conditions + + return updated_dict + + return parsed + + def _update_value(self, value): + if isinstance(value, str): + return self.value_mapping.get(value, value) + + elif isinstance(value, list): + # Handle "in" operator with list of values + return [self.value_mapping.get(v, v) if isinstance(v, str) else v for v in value] + + return value + + +@frappe.whitelist() +def get_filtered_accounts(company: str, account_rows: str | list): + frappe.has_permission("Financial Report Template", ptype="read", throw=True) + + if isinstance(account_rows, str): + account_rows = json.loads(account_rows, object_hook=frappe._dict) + + return DataCollector.get_filtered_accounts(company, account_rows) + + +@frappe.whitelist() +def get_children_accounts( + doctype: str, + parent: str, + company: str, + filtered_accounts: list[str] | str | None = None, + missed: bool = False, + is_root: bool = False, + include_disabled: bool = False, +): + """ + Get children accounts based on the provided filters to view in tree. + + Args: + parent: The parent account to get children for. + company: The company to filter accounts by. + account_rows: Template rows with `Data Source` == `Account Data`. + missed: + - If True, only missed by filters accounts will be included. + - If False, only filtered accounts will be included. + is_root: Whether the parent is a root account. + include_disabled: Whether to include disabled accounts. + + Example: + ```python + [ + { + value: "Current Liabilities - WP", + expandable: 1, + root_type: "Liability", + account_currency: "USD", + parent: "Source of Funds (Liabilities) - WP", + }, + { + value: "Non-Current Liabilities - WP", + expandable: 1, + root_type: "Liability", + account_currency: "USD", + parent: "Source of Funds (Liabilities) - WP", + }, + ] + ``` + """ + frappe.has_permission(doctype, ptype="read", throw=True) + + children_accounts = get_children( + doctype, parent, company, is_root=is_root, include_disabled=include_disabled + ) + + if not children_accounts: + return [] + + if isinstance(filtered_accounts, str): + filtered_accounts = frappe.parse_json(filtered_accounts) + + if not filtered_accounts: + return children_accounts if missed else [] + + valid_accounts = [] + + for account in children_accounts: + if account.expandable: + valid_accounts.append(account) + continue + + is_in_filtered = account.value in filtered_accounts + + if (missed and not is_in_filtered) or (not missed and is_in_filtered): + valid_accounts.append(account) + + return valid_accounts + + +# ============================================================================ +# PROCESS CALCULATIONS +# ============================================================================ + + +class RowProcessor: + """ + Processes individual rows of the financial report template. + Handles dependency resolution and calculation order. + """ + + def __init__(self, context: ReportContext): + self.context = context + self.period_list = context.period_list + self.row_values = {} # For formula calculations + self.dependency_resolver = DependencyResolver(context.template) + + def process_all_rows(self) -> list[RowData]: + processing_order = self.dependency_resolver.get_processing_order() + processed_rows = [] + + # Get account data from context + account_summary = self.context.raw_data.get("summary", {}) + account_details = self.context.raw_data.get("account_details", {}) + + for row in processing_order: + row_data = self._process_single_row(row, account_summary, account_details) + processed_rows.append(row_data) + + processed_rows.sort(key=lambda x: getattr(x.row, "idx", 0) or 0) + + return processed_rows + + def _process_single_row(self, row, account_summary: dict, account_details: dict) -> RowData: + if row.data_source == "Account Data": + return self._process_account_row(row, account_summary, account_details) + elif row.data_source == "Custom API": + return self._process_api_row(row) + elif row.data_source == "Calculated Amount": + return self._process_formula_row(row) + elif row.data_source == "Blank Line": + return self._process_blank_row(row) + elif row.data_source == "Column Break": + return self._process_column_break_row(row) + elif row.data_source == "Section Break": + return self._process_section_break_row(row) + else: + return RowData(row=row, values=[0.0] * len(self.period_list)) + + def _process_account_row(self, row, account_summary: dict, account_details: dict) -> RowData: + ref_code = row.reference_code + values = account_summary.get(ref_code, [0.0] * len(self.period_list)) + details = account_details.get(ref_code, {}) + + if ref_code: + self.row_values[ref_code] = values + + return RowData(row=row, values=values, account_details=details) + + def _process_api_row(self, row) -> RowData: + api_path = row.calculation_formula + # TODO + + try: + values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row) + + if row.reverse_sign: + values = [-1 * v for v in values] + + # TODO: add support for server script + # use form_dict to pass input in server script + except Exception as e: + frappe.log_error(f"Custom API Error: {api_path} - {e!s}") + values = [0.0] * len(self.period_list) + + if row.reference_code: + self.row_values[row.reference_code] = values + + return RowData(row=row, values=values) + + def _process_formula_row(self, row) -> RowData: + calculator = FormulaCalculator(self.row_values, self.period_list) + values = calculator.evaluate_formula(row) + + if row.reference_code: + self.row_values[row.reference_code] = values + + return RowData(row=row, values=values) + + def _process_blank_row(self, row) -> RowData: + return RowData(row=row, values=[""] * len(self.period_list)) + + def _process_column_break_row(self, row) -> RowData: + return RowData(row=row, values=[]) + + def _process_section_break_row(self, row) -> RowData: + return RowData(row=row, values=[]) + + +class DependencyResolver: + """Optimized dependency resolver with better circular reference detection""" + + def __init__(self, template): + self.template: FinancialReportTemplate = template + self.rows = template.rows + self.row_map = {row.reference_code: row for row in self.rows if row.reference_code} + self.dependencies = {} + self._validate_dependencies() + + def _validate_dependencies(self): + """Validate dependencies using the new validation framework""" + + validator = DependencyValidator(self.template) + result = validator.validate() + result.notify_user() + + self.dependencies = validator.dependencies + + def get_processing_order(self) -> list: + # rows by type + api_rows = [] + account_rows = [] + formula_rows = [] + other_rows = [] + + for row in self.rows: + if row.data_source == "Custom API": + api_rows.append(row) + elif row.data_source == "Account Data": + account_rows.append(row) + elif row.data_source == "Calculated Amount": + formula_rows.append(row) + else: + other_rows.append(row) + + ordered_rows = api_rows + account_rows + + # sort formula rows + if formula_rows: + ordered_formula_rows = self._topological_sort(formula_rows) + ordered_rows.extend(ordered_formula_rows) + + ordered_rows.extend(other_rows) + + return ordered_rows + + def _topological_sort(self, formula_rows: list) -> list: + formula_row_map = {row.reference_code: row for row in formula_rows if row.reference_code} + + adj_list = {code: [] for code in formula_row_map} + in_degree = {code: 0 for code in formula_row_map} + + # Calculate in-degree + for code in formula_row_map: + deps = self.dependencies.get(code, []) + for dep in deps: + if dep in formula_row_map: # Only consider dependencies within formula rows + adj_list[dep].append(code) + in_degree[code] += 1 + + # Topological sort + queue = [code for code, degree in in_degree.items() if degree == 0] + result = [] + + while queue: + current = queue.pop(0) + result.append(formula_row_map[current]) + + # Reduce in-degree + for neighbor in adj_list[current]: + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + # Add any remaining formula rows + result_set = set(result) + for row in formula_rows: + if row not in result_set: + result.append(row) + + return result + + +class FormulaCalculator: + """Enhanced formula calculator with better error handling""" + + def __init__(self, row_data: dict[str, list[float]], period_list: list[dict]): + self.row_data = row_data + self.period_list = period_list + self.precision = get_currency_precision() + self.validator = CalculationFormulaValidator(set(row_data.keys())) + + self.math_functions = { + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, + "sqrt": math.sqrt, + "pow": math.pow, + "ceil": math.ceil, + "floor": math.floor, + } + + def evaluate_formula(self, report_row: dict[str, Any]) -> list[float]: + validation_result = self.validator.validate(report_row) + formula = report_row.calculation_formula + negation_factor = -1 if report_row.reverse_sign else 1 + + if validation_result.issues: + # TODO: Throw? + messages = "

".join(issue.message for issue in validation_result.issues) + frappe.log_error(f"Formula validation errors found:\n{messages}") + return [0.0] * len(self.period_list) + + results = [] + for i in range(len(self.period_list)): + result = self._evaluate_for_period(formula, i, negation_factor) + results.append(result) + + return results + + def _evaluate_for_period(self, formula: str, period_index: int, negation_factor: int) -> float: + # TODO: consistent error handling + try: + context = self._build_context(period_index) + result = frappe.safe_eval(formula, context) + return flt(result * negation_factor, self.precision) + + except ZeroDivisionError: + frappe.log_error(f"Division by zero in formula: {formula}") + return 0.0 + except Exception as e: + frappe.log_error(f"Formula evaluation error: {formula} - {e!s}") + return 0.0 + + def _build_context(self, period_index: int) -> dict[str, Any]: + context = {} + + # row values + for code, values in self.row_data.items(): + if period_index < len(values): + context[code] = values[period_index] or 0.0 + else: + context[code] = 0.0 + + # math functions + context.update(self.math_functions) + + return context + + +# ============================================================================ +# DATA FORMATTING +# ============================================================================ + + +class DataFormatter: + def __init__(self, context: ReportContext): + self.context = context + self.formatting_engine = FormattingEngine() + + self.organizer = SegmentOrganizer(context.processed_rows) + + if self.organizer.is_single_segment: + self.formatter = SingleSegmentFormatter(context, self.formatting_engine) + else: + self.formatter = MultiSegmentFormatter(context, self.formatting_engine) + + if context.show_detailed: + self._expand_segments_with_details() + + def format_for_display(self) -> tuple[list[dict], list[dict]]: + formatted_data = self._format_rows() + columns = self._generate_columns() + return formatted_data, columns + + def _format_rows(self) -> list[dict]: + formatted_data = [] + + for section in self.organizer.sections: + for row_index in range(self.organizer.max_rows(section)): + formatted_row = self.formatter.format_row(section.segments, row_index) + if formatted_row: # Always include rows that were formatted + # Add metadata + formatted_row["_segment_info"] = { + "total_segments": len(section.segments), + "period_keys": [p["key"] for p in self.context.period_list], # Add period keys + } + formatted_data.append(formatted_row) + + return formatted_data + + def _generate_columns(self) -> list[dict]: + base_columns = get_columns( + self.context.filters.get("periodicity"), + self.context.period_list, + self.context.filters.get("accumulated_values") in (1, None), + self.context.filters.get("company"), + ) + + return self.formatter.get_columns(self.organizer.section_with_max_segments.segments, base_columns) + + def _expand_segments_with_details(self): + for section in self.organizer.sections: + for segment in section.segments: + expanded_rows = [] + + for row_data in segment.rows: + expanded_rows.append(row_data) + + if row_data.account_details: + detail_rows = DetailRowBuilder(self.context.filters, row_data).build() + expanded_rows.extend(detail_rows) + + segment.rows = expanded_rows + + +class FormattingEngine: + """Manages formatting rules and application""" + + def __init__(self): + self.initialize_rules() + + def initialize_rules(self): + self.rules = [ + FormattingRule( + condition=lambda rd: getattr(rd.row, "bold_text", False), format_properties={"bold": True} + ), + FormattingRule( + condition=lambda rd: getattr(rd.row, "italic_text", False), format_properties={"italic": True} + ), + FormattingRule( + condition=lambda rd: rd.is_detail_row, format_properties={"is_detail": True, "prefix": "• "} + ), + FormattingRule( + condition=lambda rd: getattr(rd.row, "warn_if_negative", False), + format_properties={"warn_if_negative": True}, + ), + FormattingRule( + condition=lambda rd: getattr(rd.row, "data_source", "") == "Blank Line", + format_properties={"is_blank_line": True}, + ), + FormattingRule( + condition=lambda rd: getattr(rd.row, "fieldtype", ""), + format_properties=lambda rd: {"fieldtype": getattr(rd.row, "fieldtype", "").strip()}, + ), + FormattingRule( + condition=lambda rd: getattr(rd.row, "color", ""), + format_properties=lambda rd: {"color": getattr(rd.row, "color", "").strip()}, + ), + FormattingRule( + condition=lambda rd: getattr(rd.row, "data_source", "") == "Account Data", + format_properties=lambda rd: { + "account_filters": getattr(rd.row, "calculation_formula", "").strip() + }, + ), + ] + + def get_formatting(self, row_data: RowData) -> dict[str, Any]: + formatting = {} + for rule in self.rules: + if rule.applies_to(row_data): + properties = rule.get_properties(row_data) + formatting.update(properties) + + return formatting + + +class SegmentOrganizer: + """Handles segment organization by `Column Break`, `Section Break` and metadata extraction""" + + def __init__(self, processed_rows: list[RowData]): + self.sections = self._organize_into_sections(processed_rows) + + # ensure same segment length across sections + max_segments = self.max_segments + for section in self.sections: + if len(section.segments) >= max_segments: + continue + + # Pad with empty segments + empty_segments = [SegmentData(index=i) for i in range(len(section.segments), max_segments)] + section.segments.extend(empty_segments) + + def _organize_into_sections(self, rows: list[RowData]) -> list[SectionData]: + sections = [] + current_section_rows = [] + section_index = 0 + section_label = "" + + for row_data in rows: + if not self._should_show_row(row_data): + continue + + if row_data.row.data_source == "Section Break": + # Process current section if we have rows + if current_section_rows: + section_segments = self._organize_into_segments(current_section_rows, section_label) + sections.append( + SectionData(segments=section_segments, label=section_label, index=section_index) + ) + section_index += 1 + current_section_rows = [] + + # Label for the next section + section_label = getattr(row_data.row, "display_name", "") or "" + else: + current_section_rows.append(row_data) + + # Add final section + if current_section_rows or not sections: + section_segments = self._organize_into_segments(current_section_rows, section_label) + sections.append(SectionData(segments=section_segments, label=section_label, index=section_index)) + + return sections + + def _organize_into_segments(self, rows: list[RowData], section_label: str) -> list[SegmentData]: + segments = [] + current_rows = [] + segment_index = 0 + segment_label = "" + + section_header = None + if section_label: + section_header = RowData( + row=frappe._dict( + { + "data_source": "Blank Line", + "display_name": section_label, + "bold_text": True, + } + ) + ) + + for row_data in rows: + if row_data.row.data_source == "Column Break": + # Save current segment + if section_header and current_rows: + current_rows.insert(0, section_header) + section_header = RowData(row=frappe._dict({"data_source": "Blank Line"})) + + if current_rows: + segments.append(SegmentData(rows=current_rows, label=segment_label, index=segment_index)) + segment_index += 1 + current_rows = [] + + # Label for the next segment + segment_label = getattr(row_data.row, "display_name", "") or "" + else: + current_rows.append(row_data) + + # Add final segment + if section_header and current_rows: + current_rows.insert(0, section_header) + + if current_rows or not segments: + segments.append(SegmentData(rows=current_rows, label=segment_label, index=segment_index)) + + return segments + + @property + def is_single_segment(self) -> bool: + return self.max_segments == 1 + + def max_rows(self, section: SectionData) -> int: + return max(len(seg.rows) for seg in section.segments) if section.segments else 0 + + @property + def max_segments(self) -> bool: + return max(len(s.segments) for s in self.sections) + + @property + def section_with_max_segments(self) -> SectionData: + return max(self.sections, key=lambda s: len(s.segments)) + + def _should_show_row(self, row_data: RowData) -> bool: + row = row_data.row + + # Always show blank lines + if row.data_source == "Blank Line": + return True + + if getattr(row, "hidden_calculation", False): + return False + + if getattr(row, "hide_when_empty", False): + significant_values = [ + val for val in row_data.values if isinstance(val, int | float) and abs(flt(val)) > 0.01 + ] + return len(significant_values) > 0 + + return True + + +class RowFormatterBase(ABC): + def __init__(self, context: ReportContext, formatting_engine: FormattingEngine): + self.context = context + self.period_list = context.period_list + self.formatting_engine = formatting_engine + + @abstractmethod + def format_row(self, segments: list[SegmentData], row_index: int) -> dict[str, Any]: + pass + + @abstractmethod + def get_columns(self, segments: list[SegmentData], base_columns: list[dict]) -> list[dict]: + pass + + def _get_values(self, row_data: RowData) -> dict[str, Any]: + # TODO: can be commonify COA? @abdeali + child_accounts = [] + + if row_data.account_details: + child_accounts = list(row_data.account_details.keys()) + + values = { + "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 "", + "total": 0, + } + + for i, period in enumerate(self.period_list): + period_value = self._get_period_value(row_data, i) + values[period["key"]] = period_value + + if self.context.filters.get("accumulated_values") == 0: + values["total"] += flt(period_value) + + # avg for percent + if self.context.filters.get("accumulated_values") == 0 and row_data.row.fieldtype == "Percent": + values["total"] = values["total"] / len(self.period_list) + + return values + + def _get_period_value(self, row_data: RowData, period_index: int) -> Any: + if period_index < len(row_data.values): + return row_data.values[period_index] + + return "" + + +class SingleSegmentFormatter(RowFormatterBase): + def format_row(self, segments: list[SegmentData], row_index: int) -> dict[str, Any]: + if not segments or row_index >= len(segments[0].rows): + return {} + + row_data = segments[0].rows[row_index] + + formatted = self._get_values(row_data) + + formatting = self.formatting_engine.get_formatting(row_data) + formatted.update(formatting) + + return formatted + + def get_columns(self, segments: list[SegmentData], base_columns: list[dict]) -> list[dict]: + for col in base_columns: + if col["fieldname"] == "account": + col["align"] = "left" + + return base_columns + + +class MultiSegmentFormatter(RowFormatterBase): + def format_row(self, segments: list[SegmentData], row_index: int) -> dict[str, Any]: + formatted = {"segment_values": {}} + + for segment in segments: + if row_index < len(segment.rows): + row_data = segment.rows[row_index] + self._add_segment_data(formatted, row_data, segment) + else: + self._add_empty_segment(formatted, segment) + + return formatted + + def get_columns(self, segments: list[SegmentData], base_columns: list[dict]) -> list[dict]: + columns = [] + + # TODO: Refactor + for segment in segments: + for col in base_columns: + new_col = col.copy() + + new_col["fieldname"] = f"{segment.id}_{col['fieldname']}" + + if col["fieldname"] == "account": + new_col["label"] = segment.label or f"Account (Segment {segment.index + 1})" + new_col["align"] = "left" + + if segment.label and col["fieldname"] in [p["key"] for p in self.period_list]: + new_col["label"] = f"{segment.label} - {col['label']}" + + columns.append(new_col) + + return columns + + def _add_segment_data(self, formatted: dict, row_data: RowData, segment: SegmentData): + segment_values = self._get_values(row_data) + + for key, value in segment_values.items(): + formatted[f"{segment.id}_{key}"] = value + + formatting = self.formatting_engine.get_formatting(row_data) + segment_values.update(formatting) + + formatted["segment_values"][segment.id] = segment_values + + def _add_empty_segment(self, formatted: dict, segment: SegmentData): + formatted[f"account_{segment.id}"] = "" + for period in self.period_list: + formatted[f"{segment.id}_{period['key']}"] = "" + + formatted["segment_values"][segment.id] = {"is_blank_line": True} + + +class DetailRowBuilder: + """Builds detail rows for account breakdown""" + + def __init__(self, filters: dict, parent_row_data: RowData): + self.filters = filters + self.parent_row_data = parent_row_data + + def build(self) -> list[RowData]: + if not self.parent_row_data.account_details: + return [] + + 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) + + balance_type = getattr(parent_row, "balance_type", "Closing Balance") + values = account_data.get_values_by_type(balance_type) + + detail_row_data = RowData( + row=detail_row, + values=values, + is_detail_row=True, + parent_reference=parent_row.reference_code, + ) + + detail_rows.append(detail_row_data) + + return detail_rows + + def _create_detail_row_object(self, account_name: str, parent_row): + short_name = account_name.rsplit(" - ", 1)[0].strip() + + return type( + "DetailRow", + (), + { + "display_name": short_name, + "account": account_name, + "account_name": short_name, + "data_source": "Account Detail", + "indentation_level": getattr(parent_row, "indentation_level", 0) + 1, + "fieldtype": getattr(parent_row, "fieldtype", None), + "bold_text": False, + "italic_text": True, + "reverse_sign": getattr(parent_row, "reverse_sign", False), + "warn_if_negative": getattr(parent_row, "warn_if_negative", False), + "hide_when_empty": getattr(parent_row, "hide_when_empty", False), + "hidden_calculation": False, + }, + )() + + +class ChartDataGenerator: + def __init__(self, context: ReportContext): + self.context = context + self.processed_rows = context.processed_rows + self.period_list = context.period_list + self.filters = context.filters + self.currency = context.currency + + def generate(self) -> dict[str, Any]: + chart_rows = [ + row + for row in self.processed_rows + if getattr(row.row, "include_in_charts", False) + and row.row.data_source not in ["Blank Line", "Column Break", "Section Break"] + ] + + if not chart_rows: + return {} + + labels = [p.get("label") for p in self.period_list] + datasets = [] + + for row_data in chart_rows: + display_name = getattr(row_data.row, "display_name", "") + values = [] + for i, _period in enumerate(self.period_list): + if i < len(row_data.values): + value = row_data.values[i] + values.append(flt(value, 2)) + else: + values.append(0.0) + + # only non-zero values + if any(v != 0 for v in values): + datasets.append({"name": display_name, "values": values}) + + if not datasets: + return {} + + # chart config + if not self.filters.get("accumulated_values") or len(labels) <= 1: + chart_type = "bar" + else: + chart_type = "line" + + self.context.raw_data["chart"] = { + "data": {"labels": labels, "datasets": datasets}, + "type": chart_type, + "fieldtype": "Currency", + "options": "currency", + "currency": self.currency, + } + + +class GrowthViewTransformer: + def __init__(self, context: ReportContext): + self.context = context + self.formatted_rows = context.raw_data.get("formatted_data", []) + self.period_list = context.period_list + + def transform(self) -> None: + for row_data in self.formatted_rows: + if row_data.get("is_blank_line"): + continue + + transformed_values = {} + for i in range(len(self.period_list)): + current_period = self.period_list[i]["key"] + + current_value = row_data[current_period] + previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0 + + if i == 0: + transformed_values[current_period] = current_value + else: + growth_percent = self._calculate_growth(previous_value, current_value) + transformed_values[current_period] = growth_percent + + row_data.update(transformed_values) + + def _calculate_growth(self, previous_value: float, current_value: float) -> float | None: + if current_value is None: + return None + + if previous_value == 0 and current_value > 0: + return 100.0 + elif previous_value == 0 and current_value <= 0: + return 0.0 + else: + return flt(((current_value - previous_value) / abs(previous_value)) * 100, 2) diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_template.js b/erpnext/accounts/doctype/financial_report_template/financial_report_template.js new file mode 100644 index 00000000000..739956631fd --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_template.js @@ -0,0 +1,433 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Financial Report Template", { + refresh(frm) { + // add custom button to view missed accounts + frm.add_custom_button(__("View Account Coverage"), function () { + let selected_rows = frm.get_field("rows").grid.get_selected_children(); + const has_selection = selected_rows.length > 0; + if (selected_rows.length === 0) selected_rows = frm.doc.rows; + + show_accounts_tree(selected_rows, has_selection); + }); + + // add custom button to open the financial report + frm.add_custom_button(__("View Report"), function () { + frappe.set_route("query-report", frm.doc.report_type, { + report_template: frm.doc.name, + }); + }); + }, + + validate(frm) { + if (!frm.doc.rows || frm.doc.rows.length === 0) { + frappe.msgprint(__("At least one row is required for a financial report template")); + } + }, +}); + +frappe.ui.form.on("Financial Report Row", { + data_source(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + + update_formula_label(frm, row.data_source); + update_formula_description(frm, row.data_source); + + if (row.data_source !== "Account Data") { + frappe.model.set_value(cdt, cdn, "balance_type", ""); + } + + if (["Blank Line", "Column Break", "Section Break"].includes(row.data_source)) { + frappe.model.set_value(cdt, cdn, "calculation_formula", ""); + } + + set_up_filters_editor(frm, cdt, cdn); + }, + + form_render(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + + update_formula_label(frm, row.data_source); + update_advanced_formula_property(frm, cdt, cdn); + set_up_filters_editor(frm, cdt, cdn); + update_formula_description(frm, row.data_source); + }, + + calculation_formula(frm, cdt, cdn) { + update_advanced_formula_property(frm, cdt, cdn); + }, + + advanced_filtering(frm, cdt, cdn) { + set_up_filters_editor(frm, cdt, cdn); + }, +}); + +// FILTERS EDITOR + +function set_up_filters_editor(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + + if (row.data_source !== "Account Data" || row.advanced_filtering) return; + + const grid_row = frm.fields_dict["rows"].grid.get_row(cdn); + const wrapper = grid_row.get_field("filters_editor").$wrapper; + wrapper.empty(); + + const ACCOUNT = "Account"; + const FIELD_IDX = 1; + const OPERATOR_IDX = 2; + const VALUE_IDX = 3; + + // Parse saved filters + let saved_filters = []; + + if (row.calculation_formula) { + try { + const parsed = JSON.parse(row.calculation_formula); + + if (Array.isArray(parsed)) saved_filters = [parsed]; + else if (parsed.and) saved_filters = parsed.and; + } catch (e) { + frappe.show_alert({ + message: __("Invalid filter formula. Please check the syntax."), + indicator: "red", + }); + } + } + + if (saved_filters.length) + // Ensure every filter starts with "Account" + saved_filters = saved_filters.map((f) => [ACCOUNT, ...f]); + + frappe.model.with_doctype(ACCOUNT, () => { + const filter_group = new frappe.ui.FilterGroup({ + parent: wrapper, + doctype: ACCOUNT, + on_change: () => { + // only need [[field, operator, value]] + const filters = filter_group + .get_filters() + .map((f) => [f[FIELD_IDX], f[OPERATOR_IDX], f[VALUE_IDX]]); + + const current = filters.length > 1 ? { and: filters } : filters[0]; + frappe.model.set_value(cdt, cdn, "calculation_formula", JSON.stringify(current)); + }, + }); + + filter_group.add_filters_to_filter_group(saved_filters); + }); +} + +function update_advanced_formula_property(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + const is_advanced = is_advanced_formula(row); + + frm.set_df_property("rows", "read_only", is_advanced, frm.doc.name, "advanced_filtering", cdn); + + if (is_advanced && !row.advanced_filtering) { + row.advanced_filtering = 1; + frm.refresh_field("rows"); + } +} + +function is_advanced_formula(row) { + if (!row || row.data_source !== "Account Data") return false; + + let parsed = null; + if (row.calculation_formula) { + try { + parsed = JSON.parse(row.calculation_formula); + } catch (e) { + console.warn("Invalid JSON in calculation_formula:", e); + return false; + } + } + + if (Array.isArray(parsed)) return false; + if (parsed?.or) return true; + if (parsed?.and) return parsed.and.some((cond) => !Array.isArray(cond)); + + return false; +} + +// ACCOUNTS TREE VIEW + +function show_accounts_tree(template_rows, has_selection) { + // filtered rows + const account_rows = template_rows.filter((row) => row.data_source === "Account Data"); + + if (account_rows.length === 0) { + frappe.show_alert(__("No Account Data row found")); + return; + } + + const dialog = new frappe.ui.Dialog({ + title: __("Accounts Missing from Report"), + fields: [ + { + fieldname: "company", + fieldtype: "Link", + options: "Company", + label: "Company", + reqd: 1, + default: frappe.defaults.get_user_default("Company"), + onchange: () => { + const company_field = dialog.get_field("company"); + if (!company_field.value || company_field.value === company_field.last_value) return; + refresh_tree_view(dialog, account_rows); + }, + }, + { + fieldname: "view_type", + fieldtype: "Select", + options: ["Missing Accounts", "Filtered Accounts"], + label: "View", + default: has_selection ? "Filtered Accounts" : "Missing Accounts", + reqd: 1, + onchange: () => { + dialog.set_title( + dialog.get_value("view_type") === "Missing Accounts" + ? __("Accounts Missing from Report") + : __("Accounts Included in Report") + ); + + refresh_tree_view(dialog, account_rows); + }, + }, + { + fieldname: "tip", + fieldtype: "HTML", + label: "Tip", + options: ` + + `, + depends_on: has_selection ? "eval: false" : "eval: true", + }, + { + fieldname: "tree_area", + fieldtype: "HTML", + label: "Chart of Accounts", + read_only: 1, + depends_on: "eval: doc.company", + }, + ], + primary_action_label: __("Done"), + primary_action() { + dialog.hide(); + }, + }); + + dialog.show(); + refresh_tree_view(dialog, account_rows); +} + +async function refresh_tree_view(dialog, account_rows) { + const missed = dialog.get_value("view_type") === "Missing Accounts"; + const company = dialog.get_value("company"); + + const wrapper = dialog.get_field("tree_area").$wrapper; + wrapper.empty(); + + // get filtered accounts + const { message: filtered_accounts } = await frappe.call({ + method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_filtered_accounts", + args: { company: company, account_rows: account_rows }, + }); + + // render tree + const tree = new FilteredTree({ + parent: wrapper, + label: company, + root_value: company, + method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts", + args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed }, + toolbar: [], + }); + + tree.load_children(tree.root_node, true); +} + +class FilteredTree extends frappe.ui.Tree { + render_children_of_all_nodes(data_list) { + data_list = this.get_filtered_data_list(data_list); + super.render_children_of_all_nodes(data_list); + } + + get_filtered_data_list(data_list) { + let removed_nodes = new Set(); + + // Filter nodes with no data + data_list = data_list.filter((d) => { + if (d.data.length === 0) { + removed_nodes.add(d.parent); + return false; + } + return true; + }); + + // Remove references to removed nodes and iteratively remove empty parents + while (removed_nodes.size > 0) { + const current_removed = [...removed_nodes]; + removed_nodes.clear(); + + data_list = data_list.filter((d) => { + d.data = d.data.filter((a) => !current_removed.includes(a.value)); + + if (d.data.length === 0) { + removed_nodes.add(d.parent); + return false; + } + return true; + }); + } + + return data_list; + } +} + +function update_formula_label(frm, data_source) { + const grid = frm.fields_dict.rows.grid; + const field = grid.fields_map.calculation_formula; + if (!field) return; + + const labels = { + "Account Data": "Account Filter", + "Custom API": "API Method Path", + }; + + grid.update_docfield_property( + "calculation_formula", + "label", + labels[data_source] || "Calculation Formula" + ); +} + +// FORMULA DESCRIPTION + +function update_formula_description(frm, data_source) { + if (!data_source) return; + + let grid = frm.fields_dict.rows.grid; + let field = grid.fields_map.formula_description; + if (!field) return; + + // Common CSS styles and elements + const container_style = `style="padding: var(--padding-md); border: 1px solid var(--border-color); border-radius: var(--border-radius); margin-top: var(--margin-sm);"`; + const title_style = `style="margin-top: 0; color: var(--text-color);"`; + const subtitle_style = `style="color: var(--text-color); margin-bottom: var(--margin-xs);"`; + const text_style = `style="margin-bottom: var(--margin-sm); color: var(--text-muted);"`; + const list_style = `style="margin-bottom: var(--margin-sm); color: var(--text-muted); font-size: 0.9em;"`; + const note_style = `style="margin-bottom: 0; color: var(--text-muted); font-size: 0.9em;"`; + const tip_style = `style="margin-bottom: 0; color: var(--text-color); font-size: 0.85em;"`; + + let description_html = ""; + + if (data_source === "Account Data") { + description_html = ` +
+
Account Filter Guide
+

Specify which accounts to include in this line.

+ +
Basic Examples:
+ + +
Multiple Conditions (AND/OR):
+ + +

Available operators: =, !=, in, not in, like, not like, is

+

Multi-Company Tip: Use fields like account_type, root_type, and account_category for templates that work across multiple companies.

+
`; + } else if (data_source === "Calculated Amount") { + description_html = ` +
+
Formula Guide
+

Create calculations using reference codes from other lines.

+ +
Basic Examples:
+ + +
Common Functions:
+ + +

Required: Use "Reference Code" from other rows in your formulas.

+
`; + } else if (data_source === "Custom API") { + description_html = ` +
+
Custom API Setup
+

Path to your custom method that returns financial data.

+ +
Format:
+ + +
Return Format:
+

Numbers for each period: [1000.0, 1200.0, 1150.0]

+
`; + } else if (data_source === "Blank Line") { + description_html = ` +
+
Blank Line
+

Adds empty space for better visual separation.

+ +
Use For:
+ + +

Note: No formula needed - creates visual spacing only.

+
`; + } else if (data_source === "Column Break") { + description_html = ` +
+
Column Break
+

Creates a visual break for side-by-side layout.

+ +
Use For:
+ + +

Note: No formula needed - this is for formatting only.

+
`; + } else if (data_source === "Section Break") { + description_html = ` +
+
Section Break
+

Creates a visual break for separating different sections.

+ +
Use For:
+ + +

Note: No formula needed - this is for formatting only.

+
`; + } + + grid.update_docfield_property("formula_description", "options", description_html); +} diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_template.json b/erpnext/accounts/doctype/financial_report_template/financial_report_template.json new file mode 100644 index 00000000000..7383306f332 --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_template.json @@ -0,0 +1,102 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:template_name", + "creation": "2025-08-02 04:44:15.184541", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "template_name", + "report_type", + "module", + "column_break_lvnq", + "disabled", + "section_break_fvlw", + "rows" + ], + "fields": [ + { + "description": "Descriptive name for your template (e.g., 'Standard P&L', 'Detailed Balance Sheet')", + "fieldname": "template_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Template Name", + "reqd": 1, + "unique": 1 + }, + { + "description": "Type of financial statement this template generates", + "fieldname": "report_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Report Type", + "options": "\nProfit and Loss Statement\nBalance Sheet\nCash Flow\nCustom Financial Statement" + }, + { + "depends_on": "eval:frappe.boot.developer_mode", + "fieldname": "module", + "fieldtype": "Link", + "label": "Module (for Export)", + "options": "Module Def" + }, + { + "fieldname": "column_break_lvnq", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_fvlw", + "fieldtype": "Section Break" + }, + { + "allow_bulk_edit": 1, + "fieldname": "rows", + "fieldtype": "Table", + "label": "Report Line Items", + "options": "Financial Report Row" + }, + { + "default": "0", + "description": "Disable template to prevent use in reports", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-11-14 00:11:03.508139", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Financial Report Template", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "Accounts User" + }, + { + "read": 1, + "role": "Auditor" + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "template_name" +} diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_template.py b/erpnext/accounts/doctype/financial_report_template/financial_report_template.py new file mode 100644 index 00000000000..69ee7e4f7dd --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_template.py @@ -0,0 +1,179 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import os +import shutil + +import frappe +from frappe import _ +from frappe.model.document import Document + +from erpnext.accounts.doctype.account_category.account_category import import_account_categories +from erpnext.accounts.doctype.financial_report_template.financial_report_validation import TemplateValidator + + +class FinancialReportTemplate(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.accounts.doctype.financial_report_row.financial_report_row import FinancialReportRow + + disabled: DF.Check + module: DF.Link | None + report_type: DF.Literal[ + "", "Profit and Loss Statement", "Balance Sheet", "Cash Flow", "Custom Financial Statement" + ] + rows: DF.Table[FinancialReportRow] + template_name: DF.Data + # end: auto-generated types + + def validate(self): + validator = TemplateValidator(self) + result = validator.validate() + result.notify_user() + + def on_update(self): + self._export_template() + + def on_trash(self): + self._delete_template() + + def _export_template(self): + from frappe.modules.utils import export_module_json + + if not self.module: + return + + export_module_json(self, True, self.module) + self._export_account_categories() + + def _delete_template(self): + if not self.module or not frappe.conf.developer_mode: + return + + module_path = frappe.get_module_path(self.module) + dir_path = os.path.join(module_path, "financial_report_template", frappe.scrub(self.name)) + + shutil.rmtree(dir_path, ignore_errors=True) + + def _export_account_categories(self): + import json + + from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( + FormulaFieldExtractor, + ) + + if not self.module or not frappe.conf.developer_mode or frappe.flags.in_import: + return + + # Extract category from rows + extractor = FormulaFieldExtractor( + field_name="account_category", exclude_operators=["like", "not like"] + ) + account_data_rows = [row for row in self.rows if row.data_source == "Account Data"] + category_names = extractor.extract_from_rows(account_data_rows) + + if not category_names: + return + + # Get path + module_path = frappe.get_module_path(self.module) + categories_file = os.path.join(module_path, "financial_report_template", "account_categories.json") + + # Load existing categories + existing_categories = {} + if os.path.exists(categories_file): + try: + with open(categories_file) as f: + existing_data = json.load(f) + existing_categories = {cat["account_category_name"]: cat for cat in existing_data} + except (json.JSONDecodeError, KeyError): + pass # Create new file + + # Fetch categories from database + if category_names: + db_categories = frappe.get_all( + "Account Category", + filters={"account_category_name": ["in", list(category_names)]}, + fields=["account_category_name", "description"], + ) + + for cat in db_categories: + existing_categories[cat["account_category_name"]] = cat + + # Sort by category name + sorted_categories = sorted(existing_categories.values(), key=lambda x: x["account_category_name"]) + + # Write to file + os.makedirs(os.path.dirname(categories_file), exist_ok=True) + with open(categories_file, "w") as f: + json.dump(sorted_categories, f, indent=2) + + +def sync_financial_report_templates(chart_of_accounts=None, existing_company=None): + from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import get_chart + + # If COA is being created for an existing company, + # skip syncing templates as they are likely already present + if existing_company: + return + + # Allow regional templates to completely override ERPNext + # templates based on the chart of accounts selected + disable_default_financial_report_template = False + if chart_of_accounts: + coa = get_chart(chart_of_accounts) + if coa.get("disable_default_financial_report_template", False): + disable_default_financial_report_template = True + + installed_apps = frappe.get_installed_apps() + + for app in installed_apps: + if disable_default_financial_report_template and app == "erpnext": + continue + + _sync_templates_for(app) + + +def _sync_templates_for(app_name): + templates = [] + + for module_name in frappe.local.app_modules.get(app_name) or []: + module_path = frappe.get_module_path(module_name) + template_path = os.path.join(module_path, "financial_report_template") + + if not os.path.isdir(template_path): + continue + + import_account_categories(template_path) + + for template_dir in os.listdir(template_path): + json_file = os.path.join(template_path, template_dir, f"{template_dir}.json") + if os.path.isfile(json_file): + templates.append(json_file) + + if not templates: + return + + # ensure files are not exported + frappe.flags.in_import = True + + for template_path in templates: + with open(template_path) as f: + template_data = frappe._dict(frappe.parse_json(f.read())) + + template_name = template_data.get("name") + + if not frappe.db.exists("Financial Report Template", template_name): + doc = frappe.get_doc(template_data) + doc.flags.ignore_mandatory = True + doc.flags.ignore_permissions = True + doc.flags.ignore_validate = True + doc.insert() + + frappe.flags.in_import = False diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_validation.py b/erpnext/accounts/doctype/financial_report_template/financial_report_validation.py new file mode 100644 index 00000000000..306fb562585 --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_validation.py @@ -0,0 +1,545 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import ast +import json +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, ClassVar + +import frappe +from frappe import _ +from frappe.database.operator_map import OPERATOR_MAP +from frappe.database.query import SQLFunctionParser + + +@dataclass +class ValidationIssue: + """Represents a single validation issue""" + + message: str + row_idx: int | None = None + field: str | None = None + details: dict[str, Any] = None + + def __post_init__(self): + if self.details is None: + self.details = {} + + def __str__(self) -> str: + prefix = f"Row {self.row_idx}: " if self.row_idx else "" + field_info = f"[{self.field}] " if self.field else "" + message = f"{prefix}{field_info}{self.message}" + return _(message) + + +@dataclass +class ValidationResult: + issues: list[ValidationIssue] = field(default_factory=list) + warnings: list[ValidationIssue] = field(default_factory=list) + + @property + def is_valid(self) -> bool: + return len(self.issues) == 0 + + @property + def has_warnings(self) -> bool: + return len(self.warnings) > 0 + + @property + def error_count(self) -> int: + return len(self.issues) + + @property + def warning_count(self) -> int: + return len(self.warnings) + + def merge(self, other: "ValidationResult") -> "ValidationResult": + self.issues.extend(other.issues) + self.warnings.extend(other.warnings) + return self + + def add_error(self, issue: ValidationIssue) -> None: + """Add a critical error that prevents functionality""" + self.issues.append(issue) + + def add_warning(self, issue: ValidationIssue) -> None: + """Add a warning for recommendatory validation""" + self.warnings.append(issue) + + def notify_user(self) -> None: + warnings = "

".join(str(w) for w in self.warnings) + errors = "

".join(str(e) for e in self.issues) + + if warnings: + frappe.msgprint(warnings, title=_("Warnings"), indicator="orange") + + if errors: + frappe.throw(errors, title=_("Errors")) + + +class TemplateValidator: + """Main validator that orchestrates all validations""" + + def __init__(self, template): + self.template = template + self.validators = [ + TemplateStructureValidator(), + DependencyValidator(template), + ] + self.formula_validator = FormulaValidator(template) + + def validate(self) -> ValidationResult: + result = ValidationResult([]) + + # Run template-level validators + for validator in self.validators: + result.merge(validator.validate(self.template)) + + # Run row-level validations + account_fields = {field.fieldname for field in frappe.get_meta("Account").fields} + for row in self.template.rows: + result.merge(self.formula_validator.validate(row, account_fields)) + + return result + + +class Validator(ABC): + @abstractmethod + def validate(self, context: Any) -> ValidationResult: + pass + + +class TemplateStructureValidator(Validator): + def validate(self, template) -> ValidationResult: + result = ValidationResult() + + result.merge(self._validate_reference_codes(template)) + result.merge(self._validate_required_fields(template)) + + return result + + def _validate_reference_codes(self, template) -> ValidationResult: + result = ValidationResult() + used_codes = set() + + for row in template.rows: + if not row.reference_code: + continue + + ref_code = row.reference_code.strip() + + # Check format + if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code): + result.add_error( + ValidationIssue( + message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens", + row_idx=row.idx, + ) + ) + + # Check uniqueness + if ref_code in used_codes: + result.add_error( + ValidationIssue( + message=f"Duplicate line reference: '{ref_code}'", + row_idx=row.idx, + ) + ) + used_codes.add(ref_code) + + return result + + def _validate_required_fields(self, template) -> ValidationResult: + result = ValidationResult() + + for row in template.rows: + # Balance type required + if row.data_source == "Account Data" and not row.balance_type: + result.add_error( + ValidationIssue( + message="Balance Type is required for Account Data", + row_idx=row.idx, + ) + ) + + # Calculation formula required + if row.data_source in ["Account Data", "Calculated Amount", "Custom API"]: + if not row.calculation_formula: + result.add_error( + ValidationIssue( + message=f"Formula is required for {row.data_source}", + row_idx=row.idx, + ) + ) + + return result + + +class DependencyValidator(Validator): + def __init__(self, template): + self.template = template + self.dependencies = self._build_dependency_graph() + + def validate(self, context=None) -> ValidationResult: + result = ValidationResult() + + result.merge(self._validate_circular_dependencies()) + result.merge(self._validate_missing_dependencies()) + + return result + + def _build_dependency_graph(self) -> dict[str, list[str]]: + graph = {} + available_codes = {row.reference_code for row in self.template.rows if row.reference_code} + + for row in self.template.rows: + if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula: + deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes)) + if deps: + graph[row.reference_code] = deps + + return graph + + def _validate_circular_dependencies(self) -> ValidationResult: + """ + Efficient cycle detection using DFS (Depth-First Search) with three-color algorithm: + - WHITE (0): unvisited node + - GRAY (1): currently being processed (on recursion stack) + - BLACK (2): fully processed + + Example cycle detection: + A → B → C → A (cycle detected when A is GRAY and visited again) + """ + result = ValidationResult() + WHITE, GRAY, BLACK = 0, 1, 2 + colors = {node: WHITE for node in self.dependencies} + + def dfs(node, path): + if node not in colors: + return # External dependency + + if colors[node] == GRAY: + # Found cycle + cycle_start = path.index(node) + cycle = [*path[cycle_start:], node] + result.add_error( + ValidationIssue( + message=f"Circular dependency detected: {' → '.join(cycle)}", + ) + ) + return + + if colors[node] == BLACK: + return # Already processed + + colors[node] = GRAY + path.append(node) + + for neighbor in self.dependencies.get(node, []): + dfs(neighbor, path.copy()) + + colors[node] = BLACK + + for node in self.dependencies: + if colors[node] == WHITE: + dfs(node, []) + + return result + + def _validate_missing_dependencies(self) -> ValidationResult: + available = {row.reference_code for row in self.template.rows if row.reference_code} + result = ValidationResult() + + for ref_code, deps in self.dependencies.items(): + undefined = [d for d in deps if d not in available] + if undefined: + row_idx = self._get_row_idx(ref_code) + result.add_error( + ValidationIssue( + message=f"Line References undefined in Formula: {', '.join(undefined)}", + row_idx=row_idx, + ) + ) + + return result + + def _get_row_idx(self, reference_code: str) -> int | None: + for row in self.template.rows: + if row.reference_code == reference_code: + return row.idx + return None + + +class CalculationFormulaValidator(Validator): + """Validates calculation formulas used in Calculated Amount rows""" + + def __init__(self, reference_codes: set[str]): + self.reference_codes = reference_codes + + def validate(self, row) -> ValidationResult: + """Validate calculation formula for a single row""" + result = ValidationResult() + + if row.data_source != "Calculated Amount": + return result + + if not row.calculation_formula: + result.add_error( + ValidationIssue( + message="Formula is required for Calculated Amount", + row_idx=row.idx, + field="Formula", + ) + ) + return result + + formula = self._preprocess_formula(row.calculation_formula) + row.calculation_formula = formula + + # Check parentheses + if not self._are_parentheses_balanced(formula): + result.add_error( + ValidationIssue( + message="Formula has unbalanced parentheses", + row_idx=row.idx, + ) + ) + return result + + # Check self-reference + available_codes = list(self.reference_codes) + refs = extract_reference_codes_from_formula(formula, available_codes) + if row.reference_code and row.reference_code in refs: + result.add_error( + ValidationIssue( + message=f"Formula references itself ('{row.reference_code}')", + row_idx=row.idx, + ) + ) + + # Check undefined references + undefined = set(refs) - set(available_codes) + if undefined: + result.add_error( + ValidationIssue( + message=f"Formula references undefined codes: {', '.join(undefined)}", + row_idx=row.idx, + ) + ) + + # Try to evaluate with dummy values + eval_error = self._test_formula_evaluation(formula, available_codes) + if eval_error: + result.add_error( + ValidationIssue( + message=f"Formula evaluation error: {eval_error}", + row_idx=row.idx, + ) + ) + + return result + + def _preprocess_formula(self, formula: str) -> str: + if not formula or not isinstance(formula, str): + return "" + + return formula.strip() + + @staticmethod + def _are_parentheses_balanced(formula: str) -> bool: + return formula.count("(") == formula.count(")") + + def _test_formula_evaluation(self, formula: str, available_codes: list[str]) -> str | None: + try: + context = {code: 1.0 for code in available_codes} + context.update( + { + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, + "sqrt": lambda x: x**0.5, + "pow": pow, + "ceil": lambda x: int(x) + (1 if x % 1 else 0), + "floor": lambda x: int(x), + } + ) + + result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context) + + if not isinstance(result, (int, float)): # noqa: UP038 + return f"Formula must return a numeric value, got {type(result).__name__}" + + return None + except Exception as e: + return str(e) + + +class AccountFilterValidator(Validator): + """Validates account filter expressions used in Account Data rows""" + + def __init__(self, account_fields: set | None = None): + self.account_fields = account_fields or set(frappe.get_meta("Account")._valid_columns) + + def validate(self, row) -> ValidationResult: + result = ValidationResult() + + if row.data_source != "Account Data": + return result + + if not row.calculation_formula: + result.add_error( + ValidationIssue( + message="Account filter is required for Account Data", + row_idx=row.idx, + field="Formula", + ) + ) + return result + + try: + filter_config = json.loads(row.calculation_formula) + error = self._validate_filter_structure(filter_config, self.account_fields) + + if error: + result.add_error( + ValidationIssue( + message=error, + row_idx=row.idx, + field="Account Filter", + ) + ) + + except json.JSONDecodeError as e: + result.add_error( + ValidationIssue( + message=f"Invalid JSON format: {e!s}", + row_idx=row.idx, + field="Account Filter", + ) + ) + + return result + + def _validate_filter_structure(self, filter_config, account_fields: set) -> str | None: + # simple condition: [field, operator, value] + if isinstance(filter_config, list): + if len(filter_config) != 3: + return "Filter must be [field, operator, value]" + + field, operator, value = filter_config + + if not isinstance(field, str) or not isinstance(operator, str): + return "Field and operator must be strings" + + if field not in account_fields: + return f"Field '{field}' is not a valid account field" + + if operator.casefold() not in OPERATOR_MAP: + return f"Invalid operator '{operator}'" + + if operator in ["in", "not in"] and not isinstance(value, list): + return f"Operator '{operator}' requires a list value" + + # logical condition: {"and": [condition1, condition2]} + elif isinstance(filter_config, dict): + if len(filter_config) != 1: + return "Logical condition must have exactly one operator" + + op = next(iter(filter_config.keys())).lower() + if op not in ["and", "or"]: + return "Logical operators must be 'and' or 'or'" + + conditions = filter_config[next(iter(filter_config.keys()))] + if not isinstance(conditions, list) or len(conditions) < 1: + return "Logical conditions need at least 1 sub-condition" + + # recursive + for condition in conditions: + error = self._validate_filter_structure(condition, account_fields) + if error: + return error + else: + return "Filter must be a list or dict" + + return None + + +class FormulaValidator(Validator): + def __init__(self, template): + self.template = template + reference_codes = {row.reference_code for row in template.rows if row.reference_code} + self.calculation_validator = CalculationFormulaValidator(reference_codes) + self.account_filter_validator = AccountFilterValidator() + + def validate(self, row, account_fields: set) -> ValidationResult: + result = ValidationResult() + + if not row.calculation_formula: + return result + + if row.data_source == "Calculated Amount": + return self.calculation_validator.validate(row) + + elif row.data_source == "Account Data": + # Update account fields if provided + if account_fields: + self.account_filter_validator.account_fields = account_fields + return self.account_filter_validator.validate(row) + + elif row.data_source == "Custom API": + result.merge(self._validate_custom_api(row)) + + return result + + def _validate_custom_api(self, row) -> ValidationResult: + result = ValidationResult() + api_path = row.calculation_formula + + if "." not in api_path: + result.add_error( + ValidationIssue( + message="Custom API path should be in format: app.module.method", + row_idx=row.idx, + field="Formula", + ) + ) + return result + + # Method exists? + try: + module_path, method_name = api_path.rsplit(".", 1) + module = frappe.get_module(module_path) + + if not hasattr(module, method_name): + result.add_error( + ValidationIssue( + message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)", + row_idx=row.idx, + field="Formula", + ) + ) + except Exception as e: + result.add_error( + ValidationIssue( + message=f"Could not validate API path: {e!s}", + row_idx=row.idx, + field="Formula", + ) + ) + + return result + + +def extract_reference_codes_from_formula(formula: str, available_codes: list[str]) -> list[str]: + found_codes = [] + for code in available_codes: + # Match complete words only to avoid partial matches + pattern = r"\b" + re.escape(code) + r"\b" + if re.search(pattern, formula): + found_codes.append(code) + return found_codes diff --git a/erpnext/accounts/doctype/financial_report_template/test_financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/test_financial_report_engine.py new file mode 100644 index 00000000000..1a3ffc7ab61 --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_template/test_financial_report_engine.py @@ -0,0 +1,1674 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.utils import flt + +from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( + AccountData, + DataCollector, + DependencyResolver, + FilterExpressionParser, + FinancialQueryBuilder, + FormulaCalculator, + PeriodValue, +) +from erpnext.accounts.doctype.financial_report_template.test_financial_report_template import ( + FinancialReportTemplateTestCase, +) +from erpnext.accounts.utils import get_currency_precision + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestDependencyResolver(FinancialReportTemplateTestCase): + """Test cases for DependencyResolver class""" + + # 1. BASIC FUNCTIONALITY + def test_resolve_basic_processing_order(self): + resolver = DependencyResolver(self.test_template) + order = resolver.get_processing_order() + + # Should process account rows before formula rows + account_indices = [i for i, row in enumerate(order) if row.data_source == "Account Data"] + formula_indices = [i for i, row in enumerate(order) if row.data_source == "Calculated Amount"] + + self.assertTrue(all(ai < fi for ai in account_indices for fi in formula_indices)) + + def test_resolve_simple_dependency(self): + # Create test rows with dependencies + test_rows = [ + { + "reference_code": "A001", + "display_name": "Base Account", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "B001", + "display_name": "Calculated Row", + "data_source": "Calculated Amount", + "calculation_formula": "A001 * 2", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # Check dependencies were correctly identified + self.assertIn("B001", resolver.dependencies) + self.assertEqual(resolver.dependencies["B001"], ["A001"]) + + # Check processing order + order = resolver.get_processing_order() + a001_index = next(i for i, row in enumerate(order) if row.reference_code == "A001") + b001_index = next(i for i, row in enumerate(order) if row.reference_code == "B001") + + self.assertLess(a001_index, b001_index, "A001 should be processed before B001") + + # 2. DEPENDENCY PATTERNS + def test_resolve_multiple_dependencies(self): + test_rows = [ + { + "reference_code": "INC001", + "display_name": "Income", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["root_type", "=", "Income"]', + }, + { + "reference_code": "EXP001", + "display_name": "Expenses", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["root_type", "=", "Expense"]', + }, + { + "reference_code": "GROSS001", + "display_name": "Gross Profit", + "data_source": "Calculated Amount", + "calculation_formula": "INC001 - EXP001", + }, + { + "reference_code": "MARGIN001", + "display_name": "Profit Margin", + "data_source": "Calculated Amount", + "calculation_formula": "GROSS001 / INC001 * 100", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # Check dependencies + self.assertEqual(set(resolver.dependencies["GROSS001"]), {"INC001", "EXP001"}) + self.assertEqual(set(resolver.dependencies["MARGIN001"]), {"GROSS001", "INC001"}) + + # Check processing order + order = resolver.get_processing_order() + positions = {row.reference_code: i for i, row in enumerate(order) if row.reference_code} + + # Account rows should come before formula rows + self.assertLess(positions["INC001"], positions["GROSS001"]) + self.assertLess(positions["EXP001"], positions["GROSS001"]) + + # GROSS001 should come before MARGIN001 (which depends on it) + self.assertLess(positions["GROSS001"], positions["MARGIN001"]) + + def test_resolve_chain_dependencies(self): + """Test dependency resolution with chain of dependencies (A -> B -> C -> D)""" + test_rows = [ + { + "reference_code": "A001", + "display_name": "Base", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "B001", + "display_name": "Level 1", + "data_source": "Calculated Amount", + "calculation_formula": "A001 + 100", + }, + { + "reference_code": "C001", + "display_name": "Level 2", + "data_source": "Calculated Amount", + "calculation_formula": "B001 * 1.2", + }, + { + "reference_code": "D001", + "display_name": "Level 3", + "data_source": "Calculated Amount", + "calculation_formula": "C001 - 50", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + order = resolver.get_processing_order() + positions = {row.reference_code: i for i, row in enumerate(order) if row.reference_code} + + # Verify chain order + self.assertLess(positions["A001"], positions["B001"]) + self.assertLess(positions["B001"], positions["C001"]) + self.assertLess(positions["C001"], positions["D001"]) + + def test_resolve_diamond_dependency_pattern(self): + """Test Diamond Dependency Pattern - A → B, A → C, and both B,C → D""" + test_rows = [ + { + "reference_code": "A001", + "display_name": "Base Data", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "B001", + "display_name": "Branch B", + "data_source": "Calculated Amount", + "calculation_formula": "A001 * 0.6", # B depends on A + }, + { + "reference_code": "C001", + "display_name": "Branch C", + "data_source": "Calculated Amount", + "calculation_formula": "A001 * 0.4", # C depends on A + }, + { + "reference_code": "D001", + "display_name": "Final Result", + "data_source": "Calculated Amount", + "calculation_formula": "B001 + C001", # D depends on both B and C + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + order = resolver.get_processing_order() + positions = {row.reference_code: i for i, row in enumerate(order)} + + # A should be processed first + self.assertLess(positions["A001"], positions["B001"]) + self.assertLess(positions["A001"], positions["C001"]) + self.assertLess(positions["A001"], positions["D001"]) + + # Both B and C should be processed before D + self.assertLess(positions["B001"], positions["D001"]) + self.assertLess(positions["C001"], positions["D001"]) + + # Verify D has correct dependencies + self.assertEqual(set(resolver.dependencies["D001"]), {"B001", "C001"}) + + def test_resolve_independent_formula_row_groups(self): + test_rows = [ + # Chain 1: A → B → C + { + "reference_code": "A001", + "display_name": "Chain 1 Base", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Asset"]', + }, + { + "reference_code": "B001", + "display_name": "Chain 1 Level 2", + "data_source": "Calculated Amount", + "calculation_formula": "A001 * 1.1", + }, + { + "reference_code": "C001", + "display_name": "Chain 1 Final", + "data_source": "Calculated Amount", + "calculation_formula": "B001 + 100", + }, + # Chain 2: X → Y → Z (independent) + { + "reference_code": "X001", + "display_name": "Chain 2 Base", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Liability"]', + }, + { + "reference_code": "Y001", + "display_name": "Chain 2 Level 2", + "data_source": "Calculated Amount", + "calculation_formula": "X001 * 0.9", + }, + { + "reference_code": "Z001", + "display_name": "Chain 2 Final", + "data_source": "Calculated Amount", + "calculation_formula": "Y001 - 50", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + order = resolver.get_processing_order() + positions = {row.reference_code: i for i, row in enumerate(order)} + + # Verify Chain 1 order + self.assertLess(positions["A001"], positions["B001"]) + self.assertLess(positions["B001"], positions["C001"]) + + # Verify Chain 2 order + self.assertLess(positions["X001"], positions["Y001"]) + self.assertLess(positions["Y001"], positions["Z001"]) + + # Verify chains are independent (no cross-dependencies) + chain1_codes = {"A001", "B001", "C001"} + chain2_codes = {"X001", "Y001", "Z001"} + + for code in chain1_codes: + if code in resolver.dependencies: + deps = set(resolver.dependencies[code]) + self.assertFalse(deps.intersection(chain2_codes), f"{code} should not depend on chain 2") + + for code in chain2_codes: + if code in resolver.dependencies: + deps = set(resolver.dependencies[code]) + self.assertFalse(deps.intersection(chain1_codes), f"{code} should not depend on chain 1") + + # 3. DATA SOURCE PROCESSING + def test_resolve_mixed_data_sources(self): + test_rows = [ + { + "reference_code": "CALC001", + "display_name": "Calculated", + "data_source": "Calculated Amount", + "calculation_formula": "ACC001 + 100", + }, + { + "reference_code": None, + "display_name": "Spacing", + "data_source": "Blank Line", + }, + { + "reference_code": "ACC001", + "display_name": "Account", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": None, + "display_name": "Custom", + "data_source": "Custom API", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + order = resolver.get_processing_order() + + # Find positions + positions = {} + for i, row in enumerate(order): + if row.reference_code: + positions[row.reference_code] = i + else: + positions[f"{row.data_source}_{i}"] = i + + # Account data should come before calculated + self.assertLess(positions["ACC001"], positions["CALC001"]) + + # All rows should be present + self.assertEqual(len(order), 4) + + def test_resolve_api_to_formula_dependencies(self): + test_rows = [ + { + "reference_code": "API001", + "display_name": "Custom API Result", + "data_source": "Custom API", + }, + { + "reference_code": "ACC001", + "display_name": "Account Data", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "CALC001", + "display_name": "Calculated Result", + "data_source": "Calculated Amount", + "calculation_formula": "API001 + ACC001", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + order = resolver.get_processing_order() + positions = {row.reference_code: i for i, row in enumerate(order)} + + # API001 should be processed before CALC001 + self.assertLess(positions["API001"], positions["CALC001"]) + # ACC001 should be processed before CALC001 + self.assertLess(positions["ACC001"], positions["CALC001"]) + # API001 should be processed before ACC001 (API rows come first) + self.assertLess(positions["API001"], positions["ACC001"]) + + def test_resolve_cross_datasource_dependencies(self): + test_rows = [ + { + "reference_code": "API001", + "display_name": "API Data", + "data_source": "Custom API", + }, + { + "reference_code": "ACC001", + "display_name": "Account Total", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "MIXED001", + "display_name": "Mixed Calculation", + "data_source": "Calculated Amount", + "calculation_formula": "(API001 + ACC001) * 0.5", + }, + { + "reference_code": "FINAL001", + "display_name": "Final Result", + "data_source": "Calculated Amount", + "calculation_formula": "MIXED001 + API001", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + order = resolver.get_processing_order() + positions = {row.reference_code: i for i, row in enumerate(order)} + + # API rows should be processed first + self.assertLess(positions["API001"], positions["ACC001"]) + self.assertLess(positions["API001"], positions["MIXED001"]) + + # Account data should be processed before formula rows + self.assertLess(positions["ACC001"], positions["MIXED001"]) + + # Mixed calculation should be processed before final result + self.assertLess(positions["MIXED001"], positions["FINAL001"]) + + # Verify dependencies + self.assertEqual(set(resolver.dependencies["MIXED001"]), {"API001", "ACC001"}) + self.assertEqual(set(resolver.dependencies["FINAL001"]), {"MIXED001", "API001"}) + + # 4. FORMULA PARSING + def test_extract_from_complex_formulas(self): + test_rows = [ + { + "reference_code": "INCOME", + "display_name": "Total Income", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["root_type", "=", "Income"]', + }, + { + "reference_code": "EXPENSE", + "display_name": "Total Expense", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["root_type", "=", "Expense"]', + }, + { + "reference_code": "TAX_RATE", + "display_name": "Tax Rate", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_name", "like", "Tax"]', + }, + { + "reference_code": "NET_RESULT", + "display_name": "Net Result", + "data_source": "Calculated Amount", + "calculation_formula": "(INCOME - EXPENSE) * (1 - TAX_RATE / 100)", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # Should correctly identify all three dependencies in complex formula + net_deps = resolver.dependencies.get("NET_RESULT", []) + self.assertEqual(set(net_deps), {"INCOME", "EXPENSE", "TAX_RATE"}) + + def test_extract_references_with_math_functions(self): + test_rows = [ + { + "reference_code": "INCOME", + "display_name": "Total Income", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["root_type", "=", "Income"]', + }, + { + "reference_code": "EXPENSE", + "display_name": "Total Expense", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["root_type", "=", "Expense"]', + }, + { + "reference_code": "TAX", + "display_name": "Tax Amount", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_name", "like", "Tax"]', + }, + { + "reference_code": "MATH_TEST1", + "display_name": "Mathematical Test 1", + "data_source": "Calculated Amount", + "calculation_formula": "max(INCOME, EXPENSE) + min(TAX, 0)", + }, + { + "reference_code": "MATH_TEST2", + "display_name": "Mathematical Test 2", + "data_source": "Calculated Amount", + "calculation_formula": "abs(INCOME - EXPENSE) + round(TAX, 2)", + }, + { + "reference_code": "MATH_TEST3", + "display_name": "Mathematical Test 3", + "data_source": "Calculated Amount", + "calculation_formula": "sqrt(pow(INCOME, 2) + pow(EXPENSE, 2))", + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # MATH_TEST1 should correctly identify dependencies despite max/min functions + self.assertEqual(set(resolver.dependencies["MATH_TEST1"]), {"INCOME", "EXPENSE", "TAX"}) + + # MATH_TEST2 should correctly identify dependencies despite abs/round functions + self.assertEqual(set(resolver.dependencies["MATH_TEST2"]), {"INCOME", "EXPENSE", "TAX"}) + + # MATH_TEST3 should correctly identify dependencies despite sqrt/pow functions + self.assertEqual(set(resolver.dependencies["MATH_TEST3"]), {"INCOME", "EXPENSE"}) + + def test_extract_accurate_reference_matching(self): + test_rows = [ + { + "reference_code": "INC001", + "display_name": "Income Base", + "data_source": "Account Data", + "calculation_formula": '["account_type", "=", "Income"]', + "balance_type": "Closing Balance", + }, + { + "reference_code": "INC002", + "display_name": "Income Secondary", + "data_source": "Account Data", + "calculation_formula": '["account_type", "=", "Income"]', + "balance_type": "Closing Balance", + }, + { + "reference_code": "INC001_2023", # Should not match INC001 + "display_name": "Income 2023", + "data_source": "Account Data", + "calculation_formula": '["account_type", "=", "Income"]', + "balance_type": "Closing Balance", + }, + { + "reference_code": "TEST1", + "display_name": "Test Formula 1", + "data_source": "Calculated Amount", + "calculation_formula": "2 * INC001", # Should correctly extract INC001 + }, + { + "reference_code": "TEST2", + "display_name": "Test Formula 2", + "data_source": "Calculated Amount", + "calculation_formula": "INC001 + INC002", # Word boundaries require separation + }, + { + "reference_code": "TEST3", + "display_name": "Test Formula 3", + "data_source": "Calculated Amount", + "calculation_formula": "INC001_2023 + INC001", # Should match both correctly + }, + { + "reference_code": "TEST4", + "display_name": "Test Formula 4", + "data_source": "Calculated Amount", + "calculation_formula": "INC001_2023*INC001", # No space separation but different tokens + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # TEST1 should only depend on INC001 + self.assertEqual(resolver.dependencies["TEST1"], ["INC001"]) + + # TEST2 should match both INC001 and INC002 (separated by space and +) + self.assertEqual(set(resolver.dependencies["TEST2"]), {"INC001", "INC002"}) + + # TEST3 should depend on both INC001_2023 and INC001 + self.assertEqual(set(resolver.dependencies["TEST3"]), {"INC001_2023", "INC001"}) + + # TEST4 should depend on both INC001_2023 and INC001 (separated by *) + self.assertEqual(set(resolver.dependencies["TEST4"]), {"INC001_2023", "INC001"}) + + def test_prevent_partial_reference_matches(self): + test_rows = [ + { + "reference_code": "INC001", + "display_name": "Income", + "data_source": "Account Data", + "calculation_formula": '["account_type", "=", "Income"]', + "balance_type": "Closing Balance", + }, + { + "reference_code": "INC001_ADJ", # Contains INC001 but shouldn't match + "display_name": "Income Adjustment", + "data_source": "Account Data", + "calculation_formula": '["account_type", "=", "Income"]', + "balance_type": "Closing Balance", + }, + { + "reference_code": "RESULT", + "display_name": "Result", + "data_source": "Calculated Amount", + "calculation_formula": "INC001 + 500", # Should only match INC001, not INC001_ADJ + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # RESULT should only depend on INC001, not INC001_ADJ + self.assertEqual(resolver.dependencies["RESULT"], ["INC001"]) + + # Processing order should work correctly + order = resolver.get_processing_order() + positions = {row.reference_code: i for i, row in enumerate(order)} + + self.assertLess(positions["INC001"], positions["RESULT"]) + # INC001_ADJ can be processed in any order relative to RESULT since there's no dependency + self.assertIn("INC001_ADJ", positions) + + # 5. EDGE CASES + def test_resolve_rows_without_dependencies(self): + test_rows = [ + { + "reference_code": "A001", + "display_name": "Account Row", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "B001", + "display_name": "Static Value", + "data_source": "Calculated Amount", + "calculation_formula": "1000 + 500", # No reference codes + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # B001 should have no dependencies + self.assertEqual(resolver.dependencies.get("B001", []), []) + + # Should still process correctly + order = resolver.get_processing_order() + self.assertEqual(len(order), 2) + + def test_handle_empty_reference_codes(self): + test_rows = [ + { + "reference_code": "VALID001", + "display_name": "Valid Row", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "", # Empty string + "display_name": "Empty Reference", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Asset"]', + }, + { + "reference_code": " ", # Whitespace only + "display_name": "Whitespace Reference", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Liability"]', + }, + { + "reference_code": None, # None value + "display_name": "None Reference", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Expense"]', + }, + { + "reference_code": "CALC001", + "display_name": "Calculated Row", + "data_source": "Calculated Amount", + "calculation_formula": "VALID001 * 2", # Should only depend on VALID001 + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + + # Should not break dependency resolution + order = resolver.get_processing_order() + self.assertEqual(len(order), 5) # All rows should be present + + # CALC001 should only depend on VALID001 + self.assertEqual(resolver.dependencies.get("CALC001", []), ["VALID001"]) + + # Verify processing order + positions = { + row.reference_code: i + for i, row in enumerate(order) + if row.reference_code and row.reference_code.strip() + } + self.assertLess(positions["VALID001"], positions["CALC001"]) + + def test_resolve_include_orphaned_nodes(self): + test_rows = [ + { + "reference_code": "USED001", + "display_name": "Used Row", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + }, + { + "reference_code": "ORPHAN001", + "display_name": "Orphaned Row 1", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Asset"]', + }, + { + "reference_code": "ORPHAN002", + "display_name": "Orphaned Row 2", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Liability"]', + }, + { + "reference_code": "DEPENDENT", + "display_name": "Dependent Row", + "data_source": "Calculated Amount", + "calculation_formula": "USED001 * 2", # Only uses USED001 + }, + ] + + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + order = resolver.get_processing_order() + + # All rows should be included in processing order + self.assertEqual(len(order), 4) + + positions = {row.reference_code: i for i, row in enumerate(order) if row.reference_code} + + # USED001 should be processed before DEPENDENT + self.assertLess(positions["USED001"], positions["DEPENDENT"]) + + # Orphaned rows should be included but have no dependencies + self.assertIn("ORPHAN001", positions) + self.assertIn("ORPHAN002", positions) + + # Orphaned rows should have no dependencies recorded + self.assertEqual(resolver.dependencies.get("ORPHAN001", []), []) + self.assertEqual(resolver.dependencies.get("ORPHAN002", []), []) + + def test_handle_valid_missing_references(self): + test_rows = [ + { + "reference_code": "A001", + "display_name": "Row A", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Asset"]', + }, + { + "reference_code": "B001", + "display_name": "Row B", + "data_source": "Calculated Amount", + "calculation_formula": "A001 * 2", # Valid reference + }, + ] + + # This should work without errors + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + resolver = DependencyResolver(test_template) + # Basic test - ensure it doesn't crash + processing_order = resolver.get_processing_order() + self.assertEqual(len(processing_order), 2) + + # 6. ERROR DETECTION + def test_detect_circular_dependency(self): + """Test detection of circular dependency (A -> B -> C -> A)""" + test_rows = [ + { + "reference_code": "A001", + "display_name": "Row A", + "data_source": "Calculated Amount", + "calculation_formula": "C001 + 100", # A depends on C + }, + { + "reference_code": "B001", + "display_name": "Row B", + "data_source": "Calculated Amount", + "calculation_formula": "A001 + 200", # B depends on A + }, + { + "reference_code": "C001", + "display_name": "Row C", + "data_source": "Calculated Amount", + "calculation_formula": "B001 * 1.5", # C depends on B -> creates cycle + }, + ] + + # Should raise ValidationError for circular dependency + test_template = FinancialReportTemplateTestCase.create_test_template_with_rows(test_rows) + with self.assertRaises(frappe.ValidationError): + DependencyResolver(test_template) + + +class TestFormulaCalculator(FinancialReportTemplateTestCase): + """Test cases for FormulaCalculator class""" + + def _create_mock_report_row(self, formula: str, reference_code: str = "TEST_ROW"): + class MockReportRow: + def __init__(self, formula, ref_code): + self.calculation_formula = formula + self.reference_code = ref_code + self.data_source = "Calculated Amount" + self.idx = 1 + self.reverse_sign = 0 + + return MockReportRow(formula, reference_code) + + # 1. FOUNDATION TESTS + def test_evaluate_basic_operations(self): + # Mock row data with different scenarios + row_data = { + "INC001": [1000.0, 1200.0, 1500.0], + "EXP001": [800.0, 900.0, 1100.0], + "TAX001": [50.0, 60.0, 75.0], + "ZERO_VAL": [0.0, 0.0, 0.0], + "NEG_VAL": [-100.0, -200.0, -150.0], + } + + period_list = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator = FormulaCalculator(row_data, period_list) + + result = calculator.evaluate_formula(self._create_mock_report_row("INC001 - EXP001")) + expected = [200.0, 300.0, 400.0] # [1000-800, 1200-900, 1500-1100] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("INC001 * 2")) + expected = [2000.0, 2400.0, 3000.0] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("INC001 / 10")) + expected = [100.0, 120.0, 150.0] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("(INC001 - EXP001) * 0.8")) + expected = [160.0, 240.0, 320.0] # [(1000-800)*0.8, (1200-900)*0.8, (1500-1100)*0.8] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("abs(NEG_VAL)")) + expected = [100.0, 200.0, 150.0] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("max(INC001, EXP001)")) + expected = [1000.0, 1200.0, 1500.0] # INC001 is always larger + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("min(INC001, EXP001)")) + expected = [800.0, 900.0, 1100.0] # EXP001 is always smaller + self.assertEqual(result, expected) + + def test_handle_division_by_zero(self): + row_data = { + "NUMERATOR": [100.0, 200.0, 300.0], + "ZERO_VAL": [0.0, 0.0, 0.0], + } + + period_list = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator = FormulaCalculator(row_data, period_list) + + result = calculator.evaluate_formula(self._create_mock_report_row("NUMERATOR / ZERO_VAL")) + expected = [0.0, 0.0, 0.0] + self.assertEqual(result, expected) + + # 2. DATA HANDLING TESTS + def test_handle_missing_values(self): + row_data = { + "SHORT_DATA": [100.0, 200.0], # Only 2 periods instead of 3 + "NORMAL_DATA": [50.0, 60.0, 70.0], + } + + period_list = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator = FormulaCalculator(row_data, period_list) + + result = calculator.evaluate_formula(self._create_mock_report_row("SHORT_DATA + NORMAL_DATA")) + + expected = [150.0, 260.0, 70.0] # [100+50, 200+60, 0+70] + self.assertEqual(result, expected) + + # Empty row_data + empty_calculator = FormulaCalculator({}, period_list) + result = empty_calculator.evaluate_formula(self._create_mock_report_row("MISSING_CODE * 2")) + expected = [0.0, 0.0, 0.0] + self.assertEqual(result, expected) + + # None values + row_data_with_none = { + "WITH_NONE": [100.0, None, 300.0], + "NORMAL": [10.0, 20.0, 30.0], + } + none_calculator = FormulaCalculator(row_data_with_none, period_list) + result = none_calculator.evaluate_formula(self._create_mock_report_row("WITH_NONE + NORMAL")) + expected = [110.0, 20.0, 330.0] # [100+10, 0+20, 300+30] + self.assertEqual(result, expected) + + # Zero periods + zero_period_calculator = FormulaCalculator({"TEST": [100.0]}, []) + result = zero_period_calculator.evaluate_formula(self._create_mock_report_row("TEST * 2")) + expected = [] # No periods means no results + self.assertEqual(result, expected) + + def test_handle_invalid_reference_codes(self): + """Test formula calculator handles invalid reference codes""" + row_data = { + "VALID_CODE": [100.0, 200.0, 300.0], + "123_INVALID": [50.0, 60.0, 70.0], # Starts with number - invalid identifier + "VALID-DASH": [25.0, 30.0, 35.0], # Contains dash - invalid identifier + } + + period_list = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator = FormulaCalculator(row_data, period_list) + + # Test with valid reference code + result = calculator.evaluate_formula(self._create_mock_report_row("VALID_CODE * 2")) + expected = [200.0, 400.0, 600.0] + self.assertEqual(result, expected) + + # Test with invalid reference code - should return 0.0 (code won't be in context) + result = calculator.evaluate_formula(self._create_mock_report_row("INVALID_CODE * 2")) + expected = [0.0, 0.0, 0.0] + self.assertEqual(result, expected) + + # Test reference code case sensitivity + result = calculator.evaluate_formula( + self._create_mock_report_row("valid_code * 2") + ) # lowercase version + expected = [0.0, 0.0, 0.0] # Should fail since codes are case-sensitive + self.assertEqual(result, expected) + + def test_handle_mismatched_period_data_lengths(self): + """Test scenarios with mismatched period data""" + # Test when row_data has more values than periods + row_data_extra = { + "EXTRA_DATA": [100.0, 200.0, 300.0, 400.0, 500.0], # 5 values + } + period_list_short = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + ] # Only 2 periods + + calculator_extra = FormulaCalculator(row_data_extra, period_list_short) + result = calculator_extra.evaluate_formula(self._create_mock_report_row("EXTRA_DATA * 2")) + expected = [200.0, 400.0] # Only processes first 2 values + self.assertEqual(result, expected) + + # Test when all row data arrays have different lengths + row_data_mixed = { + "SHORT": [100.0], # 1 value + "MEDIUM": [200.0, 300.0], # 2 values + "LONG": [400.0, 500.0, 600.0], # 3 values + } + period_list_three = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator_mixed = FormulaCalculator(row_data_mixed, period_list_three) + result = calculator_mixed.evaluate_formula(self._create_mock_report_row("SHORT + MEDIUM + LONG")) + # Period 0: 100 + 200 + 400 = 700 + # Period 1: 0 + 300 + 500 = 800 + # Period 2: 0 + 0 + 600 = 600 + expected = [700.0, 800.0, 600.0] + self.assertEqual(result, expected) + + # 3. COMPLEX EXPRESSIONS + def test_evaluate_complex_expressions(self): + row_data = { + "REVENUE": [10000.0, 12000.0, 15000.0], + "COST": [6000.0, 7200.0, 9000.0], + "TAX_RATE": [0.25, 0.25, 0.30], # 25%, 25%, 30% + } + + period_list = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator = FormulaCalculator(row_data, period_list) + + result = calculator.evaluate_formula( + self._create_mock_report_row("(REVENUE - COST) * (1 - TAX_RATE)") + ) + expected = [ + (10000 - 6000) * (1 - 0.25), + (12000 - 7200) * (1 - 0.25), + (15000 - 9000) * (1 - 0.30), + ] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("round(REVENUE / COST, 2)")) + expected = [ + round(10000 / 6000, 2), + round(12000 / 7200, 2), + round(15000 / 9000, 2), + ] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula( + self._create_mock_report_row("REVENUE + COST * TAX_RATE - 100") + ) # Tests PEMDAS order + expected = [ + 10000 + 6000 * 0.25 - 100, + 12000 + 7200 * 0.25 - 100, + 15000 + 9000 * 0.30 - 100, + ] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula( + self._create_mock_report_row("((REVENUE + COST) * (TAX_RATE + 0.1)) / 2") + ) + expected = [ + ((10000 + 6000) * (0.25 + 0.1)) / 2, + ((12000 + 7200) * (0.25 + 0.1)) / 2, + ((15000 + 9000) * (0.30 + 0.1)) / 2, + ] + self.assertEqual(result, expected) + + result = calculator.evaluate_formula(self._create_mock_report_row("REVENUE * 2.5 + 100")) + expected = [ + 10000 * 2.5 + 100, + 12000 * 2.5 + 100, + 15000 * 2.5 + 100, + ] + self.assertEqual(result, expected) + + def test_evaluate_nested_function_combinations(self): + row_data = { + "BASE": [4.0], + "POSITIVE": [16.0], # Use positive number for sqrt + "DECIMAL": [2.7], + } + period_list = [{"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}] + + calculator = FormulaCalculator(row_data, period_list) + + result = calculator.evaluate_formula(self._create_mock_report_row("round(sqrt(POSITIVE), 2)")) + expected = round((16.0**0.5), 2) # round(sqrt(16), 2) = round(4.0, 2) = 4.0 + self.assertEqual(result[0], expected) + + result = calculator.evaluate_formula( + self._create_mock_report_row("max(POSITIVE, min(BASE, DECIMAL))") + ) + expected = max(16.0, min(4.0, 2.7)) # max(16.0, 2.7) = 16.0 + self.assertEqual(result[0], expected) + + result = calculator.evaluate_formula( + self._create_mock_report_row("pow(max(BASE, 2), min(DECIMAL, 3))") + ) + expected = pow(max(4.0, 2), min(2.7, 3)) # pow(4.0, 2.7) + self.assertAlmostEqual(result[0], expected, places=2) + + # 4. FINANCIAL DOMAIN + def test_calculate_financial_use_cases(self): + row_data = { + "REVENUE_Q1": [1000000.0], + "REVENUE_Q2": [1200000.0], + "EXPENSES": [800000.0], + "BUDGET_VARIANCE": [-50000.0], + "ACTUAL_COSTS": [123456.78], + "GROWTH_RATE": [1.15], # 15% growth + "YEARS": [5.0], + } + period_list = [{"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}] + + calculator = FormulaCalculator(row_data, period_list) + + # Best quarterly performance + result = calculator.evaluate_formula(self._create_mock_report_row("max(REVENUE_Q1, REVENUE_Q2)")) + self.assertEqual(result[0], 1200000.0) + + # Absolute variance (remove negative sign for reporting) + result = calculator.evaluate_formula(self._create_mock_report_row("abs(BUDGET_VARIANCE)")) + self.assertEqual(result[0], 50000.0) + + # Rounded reporting figures + result = calculator.evaluate_formula(self._create_mock_report_row("round(ACTUAL_COSTS)")) + self.assertEqual(result[0], 123457.0) # Rounded to nearest whole number + + # Conservative estimates + result = calculator.evaluate_formula(self._create_mock_report_row("floor(ACTUAL_COSTS / 1000)")) + self.assertEqual(result[0], 123.0) # Conservative thousands + + # Compound growth calculations + result = calculator.evaluate_formula(self._create_mock_report_row("pow(GROWTH_RATE, YEARS)")) + expected = flt(1.15**5, get_currency_precision()) + self.assertEqual(result[0], expected) + + # Profit calculation with rounding + result = calculator.evaluate_formula( + self._create_mock_report_row("round((REVENUE_Q1 - EXPENSES) / REVENUE_Q1 * 100)") + ) + self.assertEqual(result[0], 20.0) # 20% profit margin + + def test_calculate_common_financial_patterns(self): + """Test patterns commonly used in financial calculations""" + row_data = { + "ACTUAL": [100000.0], + "BUDGET": [80000.0], + "PREVIOUS_YEAR": [90000.0], + "LOWER_BOUND": [50000.0], + "UPPER_BOUND": [150000.0], + } + period_list = [{"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}] + + calculator = FormulaCalculator(row_data, period_list) + + result = calculator.evaluate_formula( + self._create_mock_report_row("(ACTUAL - BUDGET) / (BUDGET + 0.0001) * 100") + ) + expected = (100000.0 - 80000.0) / (80000.0 + 0.0001) * 100 + self.assertAlmostEqual(result[0], expected, places=2) + + # conditional logic simulation: max(0, ACTUAL - BUDGET) (similar to IF positive) + result = calculator.evaluate_formula(self._create_mock_report_row("max(0, ACTUAL - BUDGET)")) + expected = max(0, 100000.0 - 80000.0) # 20000.0 + self.assertEqual(result[0], expected) + + # clamping patterns: min(max(ACTUAL, LOWER_BOUND), UPPER_BOUND) + result = calculator.evaluate_formula( + self._create_mock_report_row("min(max(ACTUAL, LOWER_BOUND), UPPER_BOUND)") + ) + expected = min(max(100000.0, 50000.0), 150000.0) # min(100000.0, 150000.0) = 100000.0 + self.assertEqual(result[0], expected) + + # year-over-year growth calculation + result = calculator.evaluate_formula( + self._create_mock_report_row("(ACTUAL - PREVIOUS_YEAR) / PREVIOUS_YEAR * 100") + ) + expected = (100000.0 - 90000.0) / 90000.0 * 100 + self.assertAlmostEqual(result[0], expected, places=2) + + # 5. EDGE CASES + def test_handle_error_cases(self): + """Test formula calculator error handling for various edge cases""" + row_data = { + "NORMAL": [100.0, 200.0, 300.0], + } + + period_list = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator = FormulaCalculator(row_data, period_list) + + # Test invalid syntax - should return 0.0 for all periods + result = calculator.evaluate_formula(self._create_mock_report_row("NORMAL + +")) # Invalid syntax + expected = [0.0, 0.0, 0.0] + self.assertEqual(result, expected) + + # Test undefined variable - should return 0.0 for all periods + result = calculator.evaluate_formula(self._create_mock_report_row("UNDEFINED_VAR * 2")) + expected = [0.0, 0.0, 0.0] + self.assertEqual(result, expected) + + # Test empty formula - should return 0.0 for all periods + result = calculator.evaluate_formula(self._create_mock_report_row("")) + expected = [0.0, 0.0, 0.0] + self.assertEqual(result, expected) + + # Test whitespace and formatting tolerance + result = calculator.evaluate_formula( + self._create_mock_report_row(" NORMAL + 100 ") + ) # Extra spaces + expected = [200.0, 300.0, 400.0] + self.assertEqual(result, expected) + + # Test extremely long formulas + long_formula = "NORMAL + " + " + ".join(["10"] * 100) # Very long formula + result = calculator.evaluate_formula(self._create_mock_report_row(long_formula)) + expected = [1100.0, 1200.0, 1300.0] # 100 + (100 * 10) = 1100 added to each value + self.assertEqual(result, expected) + + # Test Unicode characters in formula (should fail gracefully) + result = calculator.evaluate_formula( + self._create_mock_report_row("NORMAL + ∞") + ) # Unicode infinity symbol + expected = [0.0, 0.0, 0.0] + self.assertEqual(result, expected) + + def test_evaluate_math_function_edge_cases(self): + """Test edge cases for mathematical functions""" + row_data = { + "ZERO": [0.0], + "SMALL_DECIMAL": [0.0001], + } + period_list = [{"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}] + + calculator = FormulaCalculator(row_data, period_list) + + # Test sqrt with zero values + result = calculator.evaluate_formula(self._create_mock_report_row("sqrt(ZERO)")) + self.assertEqual(result[0], 0.0) + + # Test very small numbers precision + result = calculator.evaluate_formula(self._create_mock_report_row("SMALL_DECIMAL * SMALL_DECIMAL")) + expected = 0.0001 * 0.0001 + # Depends on currency precision + self.assertTrue(result[0] == 0.0 or abs(result[0] - expected) < 1e-6) + + # 6. OTHER + def test_prevent_security_vulnerabilities(self): + row_data = {"TEST_VAL": [100.0]} + period_list = [{"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}] + + calculator = FormulaCalculator(row_data, period_list) + + # Test that potentially harmful expressions are safely handled + # These should all return 0.0 due to safe evaluation failures + harmful_expressions = [ + "__import__('os').system('ls')", # Import attempts + "eval('1+1')", # Nested eval attempts + "exec('print(1)')", # Exec attempts + "open('/etc/passwd')", # File operations + "globals()", # Global namespace access + "locals()", # Local namespace access + ] + + for expr in harmful_expressions: + with self.subTest(expression=expr): + result = calculator.evaluate_formula(self._create_mock_report_row(expr)) + self.assertEqual(result, [0.0], f"Harmful expression '{expr}' should return [0.0]") + + # Only safe mathematical operations work + safe_expressions = [ + "TEST_VAL + 50", + "abs(TEST_VAL - 200)", + "min(TEST_VAL, 50)", + "max(TEST_VAL, 150)", + "round(TEST_VAL / 3, 2)", + ] + + for expr in safe_expressions: + with self.subTest(expression=expr): + result = calculator.evaluate_formula(self._create_mock_report_row(expr)) + self.assertNotEqual(result, [0.0], f"Safe expression '{expr}' should not return [0.0]") + self.assertIsInstance(result[0], float, f"Safe expression '{expr}' should return a float") + + def test_build_context_validation(self): + row_data = { + "TEST1": [100.0, 200.0, 300.0], + "TEST2": [10.0, 20.0, 30.0], + } + period_list = [ + {"key": "2023_q1", "from_date": "2023-01-01", "to_date": "2023-03-31"}, + {"key": "2023_q2", "from_date": "2023-04-01", "to_date": "2023-06-30"}, + {"key": "2023_q3", "from_date": "2023-07-01", "to_date": "2023-09-30"}, + ] + + calculator = FormulaCalculator(row_data, period_list) + + # Test that context for each period contains the correct values + context_0 = calculator._build_context(0) + self.assertEqual(context_0["TEST1"], 100.0) + self.assertEqual(context_0["TEST2"], 10.0) + + context_1 = calculator._build_context(1) + self.assertEqual(context_1["TEST1"], 200.0) + self.assertEqual(context_1["TEST2"], 20.0) + + context_2 = calculator._build_context(2) + self.assertEqual(context_2["TEST1"], 300.0) + self.assertEqual(context_2["TEST2"], 30.0) + + # Verify all expected math functions are available in context + math_functions = ["abs", "round", "min", "max", "sum", "sqrt", "pow", "ceil", "floor"] + for func_name in math_functions: + self.assertIn(func_name, context_0) + self.assertTrue(callable(context_0[func_name])) + + +class TestFilterExpressionParser(FinancialReportTemplateTestCase): + """Test cases for FilterExpressionParser class""" + + def _create_mock_report_row(self, formula: str, reference_code: str = "TEST_ROW"): + class MockReportRow: + def __init__(self, formula, ref_code): + self.calculation_formula = formula + self.reference_code = ref_code + self.data_source = "Account Data" + self.idx = 1 + self.reverse_sign = 0 + + return MockReportRow(formula, reference_code) + + # 1. BASIC PARSING + def test_parse_simple_equality_condition(self): + parser = FilterExpressionParser() + + # Test simple equality condition + simple_formula = '["account_type", "=", "Income"]' + + # Test with mock table + from frappe.query_builder import DocType + + account_table = DocType("Account") + mock_row = self._create_mock_report_row(simple_formula) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNotNone(condition) + + # Verify the condition contains the expected field and value + condition_str = str(condition) + self.assertIn("account_type", condition_str) + self.assertIn("Income", condition_str) + + def test_parse_logical_and_or_conditions(self): + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Test AND condition + and_formula = """{"and": [["account_type", "=", "Income"], ["is_group", "=", 0]]}""" + mock_row_and = self._create_mock_report_row(and_formula) + condition = parser.build_condition(mock_row_and, account_table) + self.assertIsNotNone(condition) + + condition_str = str(condition) + self.assertIn("account_type", condition_str) + self.assertIn("is_group", condition_str) + self.assertIn("AND", condition_str) + + # Test OR condition + or_formula = """{"or": [["root_type", "=", "Asset"], ["root_type", "=", "Liability"]]}""" + mock_row_or = self._create_mock_report_row(or_formula) + condition = parser.build_condition(mock_row_or, account_table) + self.assertIsNotNone(condition) + + condition_str = str(condition) + self.assertIn("root_type", condition_str) + self.assertIn("Asset", condition_str) + self.assertIn("Liability", condition_str) + self.assertIn("OR", condition_str) + + # 2. OPERATOR SUPPORT + def test_parse_valid_operators(self): + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + test_cases = [ + ('["account_name", "!=", "Cash"]', "!="), + ('["account_number", "like", "1000"]', "like"), + ('["account_type", "in", ["Income", "Expense"]]', "in"), + ('["account_type", "not in", ["Asset", "Liability"]]', "not in"), + ('["account_name", "not like", "Expense"]', "not like"), + ('["account_number", ">=", 1000]', ">="), + ('["account_number", ">", 0]', ">"), + ('["account_number", "<=", 5000]', "<="), + ('["account_number", "<", 100]', "<"), + ('["is_group", "=", 0]', "="), + ] + + for formula, expected_op in test_cases: + mock_row = self._create_mock_report_row(formula) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNotNone(condition, f"Failed to build condition for operator {expected_op}") + + def test_build_logical_condition_with_reduce(self): + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Test AND logic with multiple conditions + and_formula = '{"and": [["account_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]}' + mock_row_and = self._create_mock_report_row(and_formula) + condition = parser.build_condition(mock_row_and, account_table) + self.assertIsNotNone(condition) + condition_str = str(condition) + self.assertEqual(condition_str.count("AND"), 2) + + # Test OR logic with multiple conditions + or_formula = '{"or": [["root_type", "=", "Asset"], ["root_type", "=", "Liability"], ["root_type", "=", "Income"]]}' + mock_row_or = self._create_mock_report_row(or_formula) + condition = parser.build_condition(mock_row_or, account_table) + self.assertIsNotNone(condition) + condition_str = str(condition) + self.assertEqual(condition_str.count("OR"), 2) + + def test_operator_value_compatibility(self): + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Test "in" operator with list value - should work + in_formula = '["account_type", "in", ["Income", "Expense"]]' + mock_row_in = self._create_mock_report_row(in_formula) + condition = parser.build_condition(mock_row_in, account_table) + self.assertIsNotNone(condition) # Should work with list + + # Test numeric operators with proper values + numeric_formulas = [ + '["tax_rate", ">", 10.0]', + '["tax_rate", ">=", 0]', + '["tax_rate", "<", 50.0]', + '["tax_rate", "<=", 100.0]', + ] + + for formula in numeric_formulas: + mock_row = self._create_mock_report_row(formula) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNotNone(condition) + + # 3. COMPLEX STRUCTURES + def test_parse_complex_nested_filters(self): + """Test complex nested filter expressions""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Complex nested condition: ((Income OR Expense) AND NOT Other) AND is_group=0 + complex_formula = """{ + "and": [ + { + "and": [ + { + "or": [ + ["root_type", "=", "Income"], + ["root_type", "=", "Expense"] + ] + }, + ["account_category", "!=", "Other Income"] + ] + }, + ["is_group", "=", 0] + ] + }""" + + mock_row_complex = self._create_mock_report_row(complex_formula) + condition = parser.build_condition(mock_row_complex, account_table) + self.assertIsNotNone(condition) + + condition_str = str(condition) + self.assertIn("root_type", condition_str) + self.assertIn("account_category", condition_str) + self.assertIn("is_group", condition_str) + self.assertIn("AND", condition_str) + self.assertIn("OR", condition_str) + + def test_parse_deeply_nested_conditions(self): + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Triple nesting: AND containing OR containing AND + deep_nested = """{ + "and": [ + { + "or": [ + { + "and": [ + ["account_type", "=", "Income Account"], + ["is_group", "=", 0] + ] + }, + ["root_type", "=", "Asset"] + ] + }, + ["disabled", "=", 0] + ] + }""" + + mock_row_deep = self._create_mock_report_row(deep_nested) + condition = parser.build_condition(mock_row_deep, account_table) + self.assertIsNotNone(condition) + + condition_str = str(condition) + self.assertIn("account_type", condition_str) + self.assertIn("root_type", condition_str) + self.assertIn("disabled", condition_str) + self.assertIn("AND", condition_str) + self.assertIn("OR", condition_str) + + # 4. VALUE TYPES + def test_parse_different_value_types(self): + """Test different value types in conditions""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + test_cases = [ + '["tax_rate", ">=", 10.50]', # Float + '["is_group", "=", 1]', # Integer + '["account_name", "=", ""]', # Empty string + '["account_type", "in", ["Income Account", "Expense Account"]]', # List value + ] + + for formula in test_cases: + mock_row = self._create_mock_report_row(formula) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNotNone(condition, f"Failed to build condition for {formula}") + + # 5. EDGE CASES + def test_parse_special_characters_in_values(self): + """Test special characters in filter values""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + test_cases = [ + ('["account_name", "=", "John\'s Account"]', "apostrophe"), + ('["account_number", "like", "%100%"]', "wildcards"), + ('["account_name", "=", "Test & Development"]', "ampersand"), + ] + + for formula, _case_type in test_cases: + mock_row = self._create_mock_report_row(formula) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNotNone(condition, f"Failed to build condition for {_case_type} case") + + def test_parse_logical_operator_edge_cases(self): + """Test edge cases for logical operators""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Test empty conditions list - should return None + empty_and = '{"and": []}' + mock_row_empty = self._create_mock_report_row(empty_and) + condition = parser.build_condition(mock_row_empty, account_table) + self.assertIsNone(condition) + + # Test single condition in logical operator + single_condition = '{"and": [["account_type", "=", "Bank"]]}' + mock_row_single = self._create_mock_report_row(single_condition) + condition = parser.build_condition(mock_row_single, account_table) + self.assertIsNotNone(condition) + + # Test case sensitivity - should be invalid + wrong_case = '{"AND": [["account_type", "=", "Bank"]]}' + mock_row_wrong = self._create_mock_report_row(wrong_case) + condition = parser.build_condition(mock_row_wrong, account_table) + self.assertIsNone(condition) # Should return None due to invalid logical operator + + def test_build_condition_accepts_document_instance(self): + parser = FilterExpressionParser() + account_table = frappe.qb.DocType("Account") + row_obj = frappe._dict( + { + "doctype": "Financial Report Row", + "reference_code": "DOCROW1", + "display_name": "Doc Row", + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": '["account_type", "=", "Income"]', + } + ) + + # Unsaved child doc is sufficient for validation + row_doc = frappe.get_doc(row_obj) + cond = parser.build_condition(row_doc, account_table) + self.assertIsNotNone(cond) + + # Also accepts plain frappe._dict object + cond = parser.build_condition(row_obj, account_table) + self.assertIsNotNone(cond) + + # 6. ERROR HANDLING + def test_parse_invalid_filter_expressions(self): + """Test handling of invalid filter expressions""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Test malformed expressions - all should return None + invalid_expressions = [ + '["incomplete"]', # Missing operator and value + '{"invalid": "structure"}', # Wrong structure + "not_a_list_or_dict", # Invalid format + '["field", "=", "value", "extra"]', # Too many elements - actually might work due to slicing + '["field"]', # Single element + '["field", "="]', # Missing value - actually gets handled as empty value + '{"AND": [["field", "=", "value"]]}', # Wrong case + '{"and": [["field", "=", "value"]], "or": [["field2", "=", "value2"]]}', # Multiple keys + '{"xor": [["field", "=", "value"]]}', # Invalid logical operator + '{"and": "not_a_list"}', # Non-list value for logical operator + "not even close to valid syntax", # Unparseable string + ] + + for expr in invalid_expressions: + mock_row = self._create_mock_report_row(expr) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNone(condition, f"Expression {expr} should be invalid and return None") + + def test_parse_malformed_logical_conditions(self): + """Test malformed logical conditions""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + malformed_expressions = [ + '{"and": [["field", "=", "value"]], "or": [["field2", "=", "value2"]]}', # Multiple keys + '{"xor": [["field", "=", "value"]]}', # Invalid logical operator + '{"and": "not_a_list"}', # Non-list value for logical operator + ] + + for expr in malformed_expressions: + mock_row = self._create_mock_report_row(expr) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNone(condition, f"Malformed expression {expr} should return None") + + # Test mixed types in conditions - should return None due to validation failure + mixed_types = '{"and": [["account_type", "=", "Bank"], "string", 123]}' + mock_row_mixed = self._create_mock_report_row(mixed_types) + condition = parser.build_condition(mock_row_mixed, account_table) + # Should return None because invalid sub-conditions cause validation to fail + self.assertIsNone(condition) + + def test_handle_exception_robustness(self): + """Test exception handling for various inputs""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + problematic_inputs = [ + "not even close to valid syntax", # Unparseable string + '{"field": "value"}', # JSON-like but not proper format + ] + + for test_input in problematic_inputs: + mock_row = self._create_mock_report_row(test_input) + condition = parser.build_condition(mock_row, account_table) + self.assertIsNone(condition, f"Input {test_input} should result in None") + + # 7. BUILD CONDITIONS + def test_build_condition_field_validation(self): + """Test field validation behavior""" + parser = FilterExpressionParser() + from frappe.query_builder import DocType + + account_table = DocType("Account") + + # Test with existing field - should work + valid_formula = '["account_name", "=", "test"]' + mock_row_valid = self._create_mock_report_row(valid_formula) + condition = parser.build_condition(mock_row_valid, account_table) + self.assertIsNotNone(condition) + + # Test with invalid formula - should return None + invalid_formula = "invalid formula" + mock_row_invalid = self._create_mock_report_row(invalid_formula) + condition = parser.build_condition(mock_row_invalid, account_table) + self.assertIsNone(condition) diff --git a/erpnext/accounts/doctype/financial_report_template/test_financial_report_template.py b/erpnext/accounts/doctype/financial_report_template/test_financial_report_template.py new file mode 100644 index 00000000000..ef5404bd478 --- /dev/null +++ b/erpnext/accounts/doctype/financial_report_template/test_financial_report_template.py @@ -0,0 +1,79 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.tests.utils import make_test_records + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestFinancialReportTemplate(IntegrationTestCase): + pass + + +class FinancialReportTemplateTestCase(IntegrationTestCase): + """Utility class with common setup and helper methods for all test classes""" + + @classmethod + def setUpClass(cls): + """Set up test data""" + make_test_records("Company") + make_test_records("Fiscal Year") + cls.create_test_template() + + @classmethod + def create_test_template(cls): + """Create a test financial report template""" + if not frappe.db.exists("Financial Report Template", "Test P&L Template"): + template = frappe.get_doc( + { + "doctype": "Financial Report Template", + "template_name": "Test P&L Template", + "report_type": "Profit and Loss Statement", + "rows": [ + { + "reference_code": "INC001", + "display_name": "Income", + "indentation_level": 0, + "data_source": "Account Data", + "balance_type": "Closing Balance", + "bold_text": 1, + "calculation_formula": '["root_type", "=", "Income"]', + }, + { + "reference_code": "EXP001", + "display_name": "Expenses", + "indentation_level": 0, + "data_source": "Account Data", + "balance_type": "Closing Balance", + "bold_text": 1, + "calculation_formula": '["root_type", "=", "Expense"]', + }, + { + "reference_code": "NET001", + "display_name": "Net Profit/Loss", + "indentation_level": 0, + "data_source": "Calculated Amount", + "bold_text": 1, + "calculation_formula": "INC001 - EXP001", + }, + ], + } + ) + template.insert() + + cls.test_template = frappe.get_doc("Financial Report Template", "Test P&L Template") + + @staticmethod + def create_test_template_with_rows(rows_data): + """Helper method to create test template with specific rows""" + template_name = f"Test Template {frappe.generate_hash()[:8]}" + template = frappe.get_doc( + {"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data} + ) + return template diff --git a/erpnext/accounts/financial_report_template/__init__.py b/erpnext/accounts/financial_report_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/financial_report_template/account_categories.json b/erpnext/accounts/financial_report_template/account_categories.json new file mode 100644 index 00000000000..f9af2698f10 --- /dev/null +++ b/erpnext/accounts/financial_report_template/account_categories.json @@ -0,0 +1,118 @@ +[ + { + "account_category_name": "Cash and Cash Equivalents", + "description": "Cash on hand, demand deposits, and short-term highly liquid investments readily convertible to cash with original maturities of three months or less. Examples: Cash in hand, bank current accounts, money market funds, treasury bills \u22643 months." + }, + { + "account_category_name": "Cost of Goods Sold", + "description": "Direct costs attributable to cost of goods sold. Examples: Raw materials, stock in trade." + }, + { + "account_category_name": "Current Tax Liabilities", + "description": "Income tax obligations for current and prior periods. Examples: Provision for income tax, advance tax paid, tax deducted at source." + }, + { + "account_category_name": "Finance Costs", + "description": "Interest and financing-related expenses. Examples: Interest on borrowings, bank charges, lease interest, foreign exchange losses." + }, + { + "account_category_name": "Intangible Assets", + "description": "Identifiable non-monetary assets without physical substance. Examples: Software, patents, trademarks, licenses, development costs." + }, + { + "account_category_name": "Investment Income", + "description": "Returns generated from financial investments and cash management. Examples: Interest income, dividend income, rental income, fair value gains." + }, + { + "account_category_name": "Long-term Borrowings", + "description": "Interest-bearing debt obligations with maturity beyond one year. Examples: Term loans, bonds, debentures, mortgages." + }, + { + "account_category_name": "Long-term Investments", + "description": "Investments held for strategic purposes or extended periods. Examples: Equity investments, bonds, associates, joint ventures, deposits." + }, + { + "account_category_name": "Long-term Provisions", + "description": "Present obligations beyond one year with uncertain timing/amount. Examples: Asset retirement obligations, environmental remediation, legal settlements." + }, + { + "account_category_name": "Operating Expenses", + "description": "Costs incurred in ordinary business operations excluding direct costs. Examples: Selling expenses, administrative costs, marketing, utilities, rent." + }, + { + "account_category_name": "Other Current Assets", + "description": "Current assets not classified elsewhere including prepaid expenses and advances. Examples: Prepaid insurance, prepaid rent, advance to suppliers, security deposits recoverable within one year." + }, + { + "account_category_name": "Other Current Liabilities", + "description": "Short-term obligations not classified elsewhere. Examples: Accrued expenses, statutory liabilities, employee payables." + }, + { + "account_category_name": "Other Direct Costs", + "description": "Direct costs excluding cost of goods sold. Examples: Direct labor, manufacturing overhead, freight inward." + }, + { + "account_category_name": "Other Non-current Assets", + "description": "Long-term assets not classified elsewhere. Examples: Security deposits, long-term prepayments, advances for capital goods." + }, + { + "account_category_name": "Other Non-current Liabilities", + "description": "Long-term obligations not classified elsewhere. Examples: Long-term deposits, deferred income, government grants." + }, + { + "account_category_name": "Other Operating Income", + "description": "Incidental income related to business operations but not core revenue. Examples: Scrap sales, government grants, insurance claims, foreign exchange gains." + }, + { + "account_category_name": "Other Payables", + "description": "Non-trade payables and obligations to parties other than suppliers. Examples: Employee payables, accrued expenses, customer advances, security deposits received." + }, + { + "account_category_name": "Other Receivables", + "description": "Non-trade amounts due to the entity excluding financing arrangements. Examples: Employee advances, insurance claims, tax refunds, deposits recoverable." + }, + { + "account_category_name": "Reserves and Surplus", + "description": "Accumulated profits and other reserves created from profits or share premium. Examples: General reserves, retained earnings, statutory reserves, share premium." + }, + { + "account_category_name": "Revenue from Operations", + "description": "Income from primary business activities in ordinary course. Examples: Sales of goods, service revenue, commission income, royalty income." + }, + { + "account_category_name": "Share Capital", + "description": "Nominal value of issued and paid-up equity shares. Examples: Common stock, ordinary shares, preference shares." + }, + { + "account_category_name": "Short-term Borrowings", + "description": "Interest-bearing debt obligations due within one year. Examples: Bank overdrafts, short-term loans, current portion of long-term debt." + }, + { + "account_category_name": "Short-term Investments", + "description": "Financial instruments held for short-term investment purposes, readily convertible to cash. Examples: Marketable securities, fixed deposits >3 months, mutual funds." + }, + { + "account_category_name": "Short-term Provisions", + "description": "Present obligations due within one year with uncertain timing or amount. Examples: Warranty provisions, legal claims, restructuring costs." + }, + { + "account_category_name": "Stock Assets", + "description": "Inventory and stock-related assets including raw materials, work in progress, finished goods, and stock in trade. Examples: Raw materials, finished goods, trading merchandise, consumables." + }, + { + "account_category_name": "Tangible Assets", + "description": "Physical assets used in business operations including property, plant, and equipment. Examples: Land, buildings, machinery, equipment, vehicles, furniture, capital work in progress." + }, + { + "account_category_name": "Tax Expense", + "description": "Current and deferred income tax obligations. Examples: Current tax provision, deferred tax expense, withholding taxes." + }, + { + "account_category_name": "Trade Payables", + "description": "Amounts owed to suppliers. Examples: Supplier invoices, accrued purchases, bills payable." + }, + { + "account_category_name": "Trade Receivables", + "description": "Amounts due from customers for goods sold or services provided in ordinary course of business. Examples: Accounts receivable, notes receivable from customers, unbilled revenue." + } +] \ No newline at end of file diff --git a/erpnext/accounts/financial_report_template/financial_ratios_analysis/__init__.py b/erpnext/accounts/financial_report_template/financial_ratios_analysis/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/financial_report_template/financial_ratios_analysis/financial_ratios_analysis.json b/erpnext/accounts/financial_report_template/financial_ratios_analysis/financial_ratios_analysis.json new file mode 100644 index 00000000000..f924a57e4f2 --- /dev/null +++ b/erpnext/accounts/financial_report_template/financial_ratios_analysis/financial_ratios_analysis.json @@ -0,0 +1,1087 @@ +{ + "creation": "2025-10-15 04:37:23.781661", + "disabled": 0, + "docstatus": 0, + "doctype": "Financial Report Template", + "idx": 0, + "modified": "2025-10-15 04:37:23.781661", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Financial Ratios Analysis", + "owner": "Administrator", + "report_type": "Custom Financial Statement", + "rows": [ + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "FINANCIAL RATIOS ANALYSIS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "RATIO_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Cash and Cash Equivalents\", \"Trade Receivables\", \"Other Receivables\", \"Stock Assets\", \"Short-term Investments\", \"Other Current Assets\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "Current Assets", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_CURRENT_ASSETS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Cash and Cash Equivalents\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Cash and Cash Equivalents", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_CASH", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Receivables\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Trade Receivables", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_RECEIVABLES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Stock Assets\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Inventories", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_INVENTORY", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Tangible Assets\", \"Intangible Assets\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "Fixed Assets", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_FIXED_ASSETS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"=\", \"Asset\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Total Assets", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_TOTAL_ASSETS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Trade Payables\", \"Other Payables\", \"Short-term Borrowings\", \"Current Tax Liabilities\", \"Short-term Provisions\", \"Other Current Liabilities\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "Current Liabilities", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_CURRENT_LIAB", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Payables\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Trade Payables", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_PAYABLES", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Short-term Borrowings\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Short-term Borrowings", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_SHORT_DEBT", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Long-term Borrowings\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Long-term Borrowings", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_LONG_DEBT", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"=\", \"Liability\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Total Liabilities", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_TOTAL_LIAB", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"=\", \"Equity\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Total Equity", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BS_EQUITY", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Revenue from Operations\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Revenue", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_REVENUE", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Cost of Goods Sold\", \"Other Direct Costs\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "Cost of Goods Sold", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_COGS", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_REVENUE + IS_COGS", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Gross Profit", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_GROSS_PROFIT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Operating Expenses\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Operating Expenses", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_OPERATING_EXP", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_GROSS_PROFIT + IS_OPERATING_EXP", + "color": "", + "data_source": "Calculated Amount", + "display_name": "EBIT", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_EBIT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Finance Costs\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Interest Expense", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_INTEREST", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Tax Expense\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Tax Expense", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_TAX", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_EBIT + IS_INTEREST", + "color": "", + "data_source": "Calculated Amount", + "display_name": "EBT", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_EBT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_EBT + IS_TAX", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Net Income", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "IS_NET_INCOME", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "1. LIQUIDITY RATIOS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "LIQUIDITY_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "BS_CURRENT_ASSETS / BS_CURRENT_LIAB if BS_CURRENT_LIAB != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Current Ratio (Target: 1.5 - 2.0)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_CURRENT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(BS_CURRENT_ASSETS - BS_INVENTORY) / BS_CURRENT_LIAB if BS_CURRENT_LIAB != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Quick Ratio / Acid Test (Target: > 1.0)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_QUICK", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "BS_CASH / BS_CURRENT_LIAB if BS_CURRENT_LIAB != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Cash Ratio (Target: > 0.2)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_CASH", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "BS_CURRENT_ASSETS - BS_CURRENT_LIAB", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Net Working Capital (Target: Positive)", + "fieldtype": "Currency", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_NWC", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(BS_CURRENT_ASSETS - BS_CURRENT_LIAB) / BS_TOTAL_ASSETS if BS_TOTAL_ASSETS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Working Capital to Total Assets (Target: 0.1 - 0.3)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_WC_TO_ASSETS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "2. EFFICIENCY/ACTIVITY RATIOS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EFFICIENCY_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_REVENUE / BS_RECEIVABLES if BS_RECEIVABLES != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Receivables Turnover (times)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_REC_TURNOVER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(BS_RECEIVABLES / IS_REVENUE * 365) if IS_REVENUE != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Days Sales Outstanding (Target: < 45 days)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_DSO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_COGS / BS_INVENTORY if BS_INVENTORY != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Inventory Turnover (times)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_INV_TURNOVER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(BS_INVENTORY / IS_COGS * 365) if IS_COGS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Days Inventory Outstanding", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_DIO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_COGS / BS_PAYABLES if BS_PAYABLES != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Payables Turnover (times)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_PAY_TURNOVER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(BS_PAYABLES / IS_COGS * 365) if IS_COGS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Days Payables Outstanding", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_DPO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "RATIO_DSO + RATIO_DIO - RATIO_DPO", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Cash Conversion Cycle (Target: Lower)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_CCC", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_REVENUE / BS_TOTAL_ASSETS if BS_TOTAL_ASSETS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Asset Turnover (Target: > 1.0)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_ASSET_TURNOVER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_REVENUE / BS_FIXED_ASSETS if BS_FIXED_ASSETS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Fixed Asset Turnover", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_FIXED_ASSET_TURNOVER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_REVENUE / RATIO_NWC if RATIO_NWC != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Working Capital Turnover (Target: 3-6x)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_WC_TURNOVER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "3. LEVERAGE/SOLVENCY RATIOS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "LEVERAGE_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(BS_SHORT_DEBT + BS_LONG_DEBT) / BS_EQUITY if BS_EQUITY != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Debt-to-Equity (Target: < 1.0)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_DEBT_TO_EQUITY", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(BS_SHORT_DEBT + BS_LONG_DEBT) / BS_TOTAL_ASSETS if BS_TOTAL_ASSETS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Debt-to-Assets (Target: < 0.5)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_DEBT_TO_ASSETS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "BS_EQUITY / BS_TOTAL_ASSETS if BS_TOTAL_ASSETS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Equity Ratio (Target: > 0.3)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_EQUITY", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "IS_EBIT / IS_INTEREST if IS_INTEREST != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Interest Coverage / Times Interest Earned (Target: > 3.0)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_INTEREST_COVERAGE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "BS_TOTAL_ASSETS / BS_EQUITY if BS_EQUITY != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Equity Multiplier", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_EQUITY_MULT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "4. PROFITABILITY RATIOS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "PROFITABILITY_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(IS_GROSS_PROFIT / IS_REVENUE * 100) if IS_REVENUE != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Gross Profit Margin % (Target: 20-50%)", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_GP_MARGIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(IS_EBIT / IS_REVENUE * 100) if IS_REVENUE != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Operating Profit Margin % (Target: > 10%)", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_OP_MARGIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(IS_NET_INCOME / IS_REVENUE * 100) if IS_REVENUE != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Net Profit Margin % (Target: > 5%)", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_NP_MARGIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(IS_NET_INCOME / BS_TOTAL_ASSETS * 100) if BS_TOTAL_ASSETS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Return on Assets (ROA) % (Target: > 5%)", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_ROA", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(IS_NET_INCOME / BS_EQUITY * 100) if BS_EQUITY != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Return on Equity (ROE) % (Target: 15-20%)", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_ROE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(IS_EBIT / (BS_TOTAL_ASSETS - BS_CURRENT_LIAB) * 100) if (BS_TOTAL_ASSETS - BS_CURRENT_LIAB) != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Return on Capital Employed (ROCE) % (Target: > 15%)", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_ROCE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(IS_EBIT / BS_TOTAL_ASSETS * 100) if BS_TOTAL_ASSETS != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Basic Earning Power % (Target: > 10%)", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "RATIO_BEP", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + } + ], + "template_name": "Financial Ratios Analysis" +} diff --git a/erpnext/accounts/financial_report_template/horizontal_balance_sheet_(columnar)/__init__.py b/erpnext/accounts/financial_report_template/horizontal_balance_sheet_(columnar)/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/financial_report_template/horizontal_balance_sheet_(columnar)/horizontal_balance_sheet_(columnar).json b/erpnext/accounts/financial_report_template/horizontal_balance_sheet_(columnar)/horizontal_balance_sheet_(columnar).json new file mode 100644 index 00000000000..e27fe5bb0a7 --- /dev/null +++ b/erpnext/accounts/financial_report_template/horizontal_balance_sheet_(columnar)/horizontal_balance_sheet_(columnar).json @@ -0,0 +1,993 @@ +{ + "creation": "2025-09-07 07:24:40.762641", + "disabled": 0, + "docstatus": 0, + "doctype": "Financial Report Template", + "idx": 0, + "modified": "2025-10-15 03:12:19.165699", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Horizontal Balance Sheet (Columnar)", + "owner": "Administrator", + "report_type": "Balance Sheet", + "rows": [ + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Section Break", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Column Break", + "display_name": "Equity & Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "Capital & Reserves", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CAPITAL_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Share Capital\"]", + "data_source": "Account Data", + "display_name": "Share Capital", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_SHARE_CAPITAL", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "{\"and\":[[\"account_category\",\"=\",\"Reserves and Surplus\"],[\"account_name\",\"not like\",\"%Retained Earnings%\"]]}", + "data_source": "Account Data", + "display_name": "Reserves & Surplus", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_RESERVES", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "{\"and\":[[\"account_name\",\"like\",\"%Retained Earnings%\"],[\"account_category\",\"=\",\"Reserves and Surplus\"]]}", + "data_source": "Account Data", + "display_name": "Retained Earnings", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_RETAINED_EARNINGS", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "L_SHARE_CAPITAL + L_RESERVES + L_RETAINED_EARNINGS", + "data_source": "Calculated Amount", + "display_name": "Total Capital & Reserves", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "L_TOTAL_EQUITY", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "Non-Current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "NCL_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Long-term Borrowings\"], [\"account_name\", \"not like\", \"Unsecured\"]]}", + "data_source": "Account Data", + "display_name": "Secured Loans", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_SECURED_LOANS", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Long-term Borrowings\"], [\"account_name\", \"like\", \"Unsecured\"]]}", + "data_source": "Account Data", + "display_name": "Unsecured Loans", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_UNSECURED_LOANS", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Long-term Provisions\"]", + "data_source": "Account Data", + "display_name": "Long-term Provisions", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_LT_PROVISIONS", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Non-current Liabilities\"]", + "data_source": "Account Data", + "display_name": "Other Non-current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_OTHER_NCL", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "L_SECURED_LOANS + L_UNSECURED_LOANS + L_LT_PROVISIONS + L_OTHER_NCL", + "data_source": "Calculated Amount", + "display_name": "Total Non-current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "L_TOTAL_NCL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "Current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CL_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Short-term Borrowings\"]", + "data_source": "Account Data", + "display_name": "Short-term Borrowings", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_ST_BORROWINGS", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Payables\"]", + "data_source": "Account Data", + "display_name": "Trade Payables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_TRADE_PAYABLES", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Payables\"]", + "data_source": "Account Data", + "display_name": "Other Payables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_OTHER_PAYABLES", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Current Tax Liabilities\"]", + "data_source": "Account Data", + "display_name": "Current Tax Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_TAX_LIABILITIES", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Short-term Provisions\"]", + "data_source": "Account Data", + "display_name": "Short-term Provisions", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_PROVISIONS", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Current Liabilities\"]", + "data_source": "Account Data", + "display_name": "Other Current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_OTHER_CL", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "L_ST_BORROWINGS + L_TRADE_PAYABLES + L_OTHER_PAYABLES + L_TAX_LIABILITIES + L_PROVISIONS + L_OTHER_CL", + "data_source": "Calculated Amount", + "display_name": "Total Current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "L_TOTAL_CL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "Current Year Earnings", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "PL_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 1, + "calculation_formula": "[\"root_type\", \"in\", [\"Income\", \"Expense\"]]", + "color": "#28a745", + "data_source": "Account Data", + "display_name": "Net Profit/(Loss) for the Year", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "L_CURRENT_PL", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "display_name": "Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "Non-Current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "NCA_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Tangible Assets\"], [\"account_type\", \"!=\", \"Accumulated Depreciation\"]]}", + "data_source": "Account Data", + "display_name": "Property, Plant & Equipment", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_TANGIBLE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_type\", \"like\", \"Accumulated Depreciation\"]", + "data_source": "Account Data", + "display_name": "Less: Accumulated Depreciation", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 2, + "italic_text": 1, + "reference_code": "A_ACC_DEPRECIATION", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "A_TANGIBLE - A_ACC_DEPRECIATION", + "data_source": "Calculated Amount", + "display_name": "Net Tangible Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_NET_TANGIBLE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Intangible Assets\"]", + "data_source": "Account Data", + "display_name": "Intangible Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_INTANGIBLE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Long-term Investments\"]", + "data_source": "Account Data", + "display_name": "Long-term Investments", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_LT_INVESTMENTS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Non-current Assets\"]", + "data_source": "Account Data", + "display_name": "Other Non-current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_OTHER_NCA", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "(A_NET_TANGIBLE if A_ACC_DEPRECIATION else A_TANGIBLE) + A_INTANGIBLE + A_LT_INVESTMENTS + A_OTHER_NCA", + "data_source": "Calculated Amount", + "display_name": "Total Non-current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "A_TOTAL_NCA", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "Current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CA_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Stock Assets\"]", + "data_source": "Account Data", + "display_name": "Inventories", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_STOCK", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Receivables\"]", + "data_source": "Account Data", + "display_name": "Trade Receivables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_TRADE_RECEIVABLES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Receivables\"]", + "data_source": "Account Data", + "display_name": "Other Receivables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_OTHER_RECEIVABLES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Short-term Investments\"]", + "data_source": "Account Data", + "display_name": "Short-term Investments", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_ST_INVESTMENTS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Cash and Cash Equivalents\"]", + "data_source": "Account Data", + "display_name": "Cash & Bank Balances", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_CASH_BANK", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Current Assets\"]", + "data_source": "Account Data", + "display_name": "Other Current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "A_OTHER_CA", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "A_STOCK + A_TRADE_RECEIVABLES + A_OTHER_RECEIVABLES + A_ST_INVESTMENTS + A_CASH_BANK + A_OTHER_CA", + "data_source": "Calculated Amount", + "display_name": "Total Current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "A_TOTAL_CA", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Section Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "L_TOTAL_EQUITY + L_TOTAL_NCL + L_TOTAL_CL + L_CURRENT_PL", + "data_source": "Calculated Amount", + "display_name": "TOTAL", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "L_GRAND_TOTAL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "A_TOTAL_NCA + A_TOTAL_CA", + "data_source": "Calculated Amount", + "display_name": "TOTAL", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "A_GRAND_TOTAL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Section Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "L_GRAND_TOTAL - A_GRAND_TOTAL", + "color": "#EC864B", + "data_source": "Calculated Amount", + "display_name": "Balance Check (should be zero)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "BALANCE_CHECK", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "A_TOTAL_NCA + A_TOTAL_CA", + "data_source": "Calculated Amount", + "display_name": "Total Assets", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "TOTAL_ASSETS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "L_TOTAL_NCL + L_TOTAL_CL", + "data_source": "Calculated Amount", + "display_name": "Total Liabilities", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "TOTAL_LIABILITIES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "L_TOTAL_EQUITY + L_CURRENT_PL", + "data_source": "Calculated Amount", + "display_name": "Total Equity", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "TOTAL_EQUITY", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "KEY FINANCIAL RATIOS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "METRICS_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(A_TOTAL_CA / L_TOTAL_CL) if L_TOTAL_CL != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Current Ratio", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "CURRENT_RATIO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "((A_TRADE_RECEIVABLES + A_ST_INVESTMENTS + A_CASH_BANK) / L_TOTAL_CL) if L_TOTAL_CL != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Quick Ratio (Acid Test)", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "QUICK_RATIO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "((L_SECURED_LOANS + L_UNSECURED_LOANS + L_ST_BORROWINGS) / TOTAL_EQUITY) if TOTAL_EQUITY != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Debt to Equity Ratio", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "DEBT_EQUITY_RATIO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "A_TOTAL_CA - L_TOTAL_CL", + "data_source": "Calculated Amount", + "display_name": "Net Working Capital", + "fieldtype": "Currency", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "WORKING_CAPITAL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(TOTAL_EQUITY / TOTAL_ASSETS * 100) if TOTAL_ASSETS != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Equity Ratio %", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "EQUITY_RATIO", + "reverse_sign": 0 + } + ], + "template_name": "Horizontal Balance Sheet (Columnar)" +} diff --git a/erpnext/accounts/financial_report_template/horizontal_profit_and_loss_(columnar)/__init__.py b/erpnext/accounts/financial_report_template/horizontal_profit_and_loss_(columnar)/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/financial_report_template/horizontal_profit_and_loss_(columnar)/horizontal_profit_and_loss_(columnar).json b/erpnext/accounts/financial_report_template/horizontal_profit_and_loss_(columnar)/horizontal_profit_and_loss_(columnar).json new file mode 100644 index 00000000000..b1891491ced --- /dev/null +++ b/erpnext/accounts/financial_report_template/horizontal_profit_and_loss_(columnar)/horizontal_profit_and_loss_(columnar).json @@ -0,0 +1,1008 @@ +{ + "creation": "2025-09-07 07:08:46.031035", + "disabled": 0, + "docstatus": 0, + "doctype": "Financial Report Template", + "idx": 0, + "modified": "2025-10-14 03:35:49.918519", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Horizontal Profit and Loss (Columnar)", + "owner": "Administrator", + "report_type": "Profit and Loss Statement", + "rows": [ + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Section Break", + "display_name": "TRADING ACCOUNT", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "display_name": "Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Opening Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Stock Assets\"]", + "data_source": "Account Data", + "display_name": "Opening Stock", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_OPENING_STOCK", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Cost of Goods Sold\"]", + "data_source": "Account Data", + "display_name": "Cost of Goods Sold", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_COGS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "INC_CLOSING_STOCK + EXP_COGS - EXP_OPENING_STOCK", + "data_source": "Calculated Amount", + "display_name": "Purchases", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_PURCHASES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Direct Costs\"]", + "data_source": "Account Data", + "display_name": "Direct Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_DIRECT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "EXP_OPENING_STOCK + EXP_PURCHASES + EXP_DIRECT", + "data_source": "Calculated Amount", + "display_name": "Subtotal", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "LEFT_SUBTOTAL_1", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "max(0, RIGHT_SUBTOTAL_1 - LEFT_SUBTOTAL_1)", + "color": "#28a745", + "data_source": "Calculated Amount", + "display_name": "Gross Profit c/d", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "GP_CD_LEFT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "display_name": "Income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\":[[\"account_category\",\"=\",\"Revenue from Operations\"],[\"account_name\",\"not like\",\"%Service%\"]]}", + "data_source": "Account Data", + "display_name": "Sales", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "INC_SALES", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Stock Assets\"]", + "data_source": "Account Data", + "display_name": "Closing Stock", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "INC_CLOSING_STOCK", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Revenue from Operations\"], [\"account_name\", \"like\", \"Service\"]]}", + "data_source": "Account Data", + "display_name": "Service Income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "INC_SERVICE", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "INC_SALES + INC_CLOSING_STOCK + INC_SERVICE", + "data_source": "Calculated Amount", + "display_name": "Subtotal", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "RIGHT_SUBTOTAL_1", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "max(0, LEFT_SUBTOTAL_1 - RIGHT_SUBTOTAL_1)", + "color": "#CB2929", + "data_source": "Calculated Amount", + "display_name": "Gross Loss c/d", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "GL_CD_RIGHT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Section Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "LEFT_SUBTOTAL_1 + GP_CD_LEFT", + "data_source": "Calculated Amount", + "display_name": "Total", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "LEFT_TOTAL_1", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "RIGHT_SUBTOTAL_1 + GL_CD_RIGHT", + "data_source": "Calculated Amount", + "display_name": "Total", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "RIGHT_TOTAL_1", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "LEFT_TOTAL_1 - RIGHT_TOTAL_1", + "color": "#EC864B", + "data_source": "Calculated Amount", + "display_name": "Trading Balance Check (should be zero)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "TRADING_VALIDATION", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Section Break", + "display_name": "PROFIT & LOSS ACCOUNT", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "display_name": "P&L Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "GL_CD_RIGHT", + "color": "#CB2929", + "data_source": "Calculated Amount", + "display_name": "Gross Loss b/d", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "GL_BD_LEFT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Operating Expenses\"], [\"account_name\", \"like\", \"Administrative\"]]}", + "data_source": "Account Data", + "display_name": "Administrative Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_ADMIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Operating Expenses\"], [\"account_name\", \"like\", \"Sales\"]]}", + "data_source": "Account Data", + "display_name": "Selling & Distribution Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_SELLING", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Finance Costs\"]", + "data_source": "Account Data", + "display_name": "Finance Costs", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_FINANCE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_type\", \"like\", \"Depreciation\"]", + "data_source": "Account Data", + "display_name": "Depreciation", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_DEPRECIATION", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Operating Expenses\"], [\"account_name\", \"not like\", \"Administrative\"], [\"account_name\", \"not like\", \"Sales\"], [\"account_type\", \"not like\", \"Depreciation\"]]}", + "data_source": "Account Data", + "display_name": "Other Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_OTHER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Tax Expense\"]", + "data_source": "Account Data", + "display_name": "Provision for Tax", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EXP_TAX", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "GL_BD_LEFT + EXP_ADMIN + EXP_SELLING + EXP_FINANCE + EXP_DEPRECIATION + EXP_OTHER + EXP_TAX", + "data_source": "Calculated Amount", + "display_name": "Subtotal", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "LEFT_SUBTOTAL_2", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "max(0, RIGHT_SUBTOTAL_2 - LEFT_SUBTOTAL_2)", + "color": "#28a745", + "data_source": "Calculated Amount", + "display_name": "Net Profit", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "NET_PROFIT_LEFT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "display_name": "P&L Income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "GP_CD_LEFT", + "color": "#28a745", + "data_source": "Calculated Amount", + "display_name": "Gross Profit b/d", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "GP_BD_RIGHT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Operating Income\"]", + "data_source": "Account Data", + "display_name": "Other Income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "INC_OTHER", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Investment Income\"]", + "data_source": "Account Data", + "display_name": "Investment Income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "INC_INVESTMENT", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "GP_BD_RIGHT + INC_OTHER + INC_INVESTMENT", + "data_source": "Calculated Amount", + "display_name": "Subtotal", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "RIGHT_SUBTOTAL_2", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "max(0, LEFT_SUBTOTAL_2 - RIGHT_SUBTOTAL_2)", + "color": "#CB2929", + "data_source": "Calculated Amount", + "display_name": "Net Loss", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "NET_LOSS_RIGHT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Section Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "LEFT_SUBTOTAL_2 + NET_PROFIT_LEFT", + "data_source": "Calculated Amount", + "display_name": "Total", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "LEFT_TOTAL_2", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Column Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "RIGHT_SUBTOTAL_2 + NET_LOSS_RIGHT", + "data_source": "Calculated Amount", + "display_name": "Total", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "RIGHT_TOTAL_2", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Section Break", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "LEFT_TOTAL_2 - RIGHT_TOTAL_2", + "color": "#EC864B", + "data_source": "Calculated Amount", + "display_name": "P&L Balance Check (should be zero)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "PL_VALIDATION", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"=\", \"Income\"]", + "data_source": "Account Data", + "display_name": "Total Income", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "ACT_TOTAL_INCOME", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"=\", \"Expense\"]", + "data_source": "Account Data", + "display_name": "Total Expenses", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "ACT_TOTAL_EXPENSES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "ACT_TOTAL_INCOME - ACT_TOTAL_EXPENSES", + "data_source": "Calculated Amount", + "display_name": "Net Profit", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "ACT_NET_PROFIT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "NET_PROFIT_LEFT - NET_LOSS_RIGHT", + "data_source": "Calculated Amount", + "display_name": "Calculated Net Profit", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CALC_NET_PROFIT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CALC_NET_PROFIT - ACT_NET_PROFIT", + "color": "#CB2929", + "data_source": "Calculated Amount", + "display_name": "VARIANCE (Calculated vs Actual - should be zero)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "VARIANCE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "KEY PERFORMANCE METRICS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "METRICS_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "((GP_CD_LEFT - GL_CD_RIGHT) / INC_SALES * 100) if INC_SALES != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Gross Profit %", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "GP_PERCENT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(CALC_NET_PROFIT / INC_SALES * 100) if INC_SALES != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Net Profit %", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "NP_PERCENT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "((EXP_ADMIN + EXP_SELLING + EXP_OTHER) / INC_SALES * 100) if INC_SALES != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Operating Expense Ratio %", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "EXPENSE_RATIO", + "reverse_sign": 0 + } + ], + "template_name": "Horizontal Profit and Loss (Columnar)" +} diff --git a/erpnext/accounts/financial_report_template/standard_balance_sheet_(ifrs)/__init__.py b/erpnext/accounts/financial_report_template/standard_balance_sheet_(ifrs)/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/financial_report_template/standard_balance_sheet_(ifrs)/standard_balance_sheet_(ifrs).json b/erpnext/accounts/financial_report_template/standard_balance_sheet_(ifrs)/standard_balance_sheet_(ifrs).json new file mode 100644 index 00000000000..17ea6570e14 --- /dev/null +++ b/erpnext/accounts/financial_report_template/standard_balance_sheet_(ifrs)/standard_balance_sheet_(ifrs).json @@ -0,0 +1,824 @@ +{ + "creation": "2025-09-06 21:47:56.970556", + "disabled": 0, + "docstatus": 0, + "doctype": "Financial Report Template", + "idx": 0, + "modified": "2025-10-15 03:13:38.485684", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Standard Balance Sheet (IFRS)", + "owner": "Administrator", + "report_type": "Balance Sheet", + "rows": [ + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "ASSETS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "ASSETS_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "CURRENT ASSETS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CA_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Cash and Cash Equivalents\"]", + "data_source": "Account Data", + "display_name": "Cash and Cash Equivalents", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CA100", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Receivables\"]", + "data_source": "Account Data", + "display_name": "Trade Receivables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CA200", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Receivables\"]", + "data_source": "Account Data", + "display_name": "Other Receivables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CA300", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Stock Assets\"]", + "data_source": "Account Data", + "display_name": "Inventories", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CA400", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Short-term Investments\"]", + "data_source": "Account Data", + "display_name": "Short-term Investments", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CA500", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Current Assets\"]", + "data_source": "Account Data", + "display_name": "Other Current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CA600", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CA100 + CA200 + CA300 + CA400 + CA500 + CA600", + "data_source": "Calculated Amount", + "display_name": "Total Current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "CA_TOTAL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "NON-CURRENT ASSETS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "NCA_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Tangible Assets\"]", + "data_source": "Account Data", + "display_name": "Property, Plant and Equipment", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "NCA100", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Intangible Assets\"]", + "data_source": "Account Data", + "display_name": "Intangible Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "NCA200", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Long-term Investments\"]", + "data_source": "Account Data", + "display_name": "Long-term Investments", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "NCA300", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Non-current Assets\"]", + "data_source": "Account Data", + "display_name": "Other Non-current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "NCA400", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "NCA100 + NCA200 + NCA300 + NCA400", + "data_source": "Calculated Amount", + "display_name": "Total Non-current Assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "NCA_TOTAL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CA_TOTAL + NCA_TOTAL", + "data_source": "Calculated Amount", + "display_name": "TOTAL ASSETS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "TOTAL_ASSETS", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "LIABILITIES AND EQUITY", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "LIAB_EQUITY_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "CURRENT LIABILITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CL_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Payables\"]", + "data_source": "Account Data", + "display_name": "Trade Payables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CL100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Payables\"]", + "data_source": "Account Data", + "display_name": "Other Payables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CL200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Short-term Borrowings\"]", + "data_source": "Account Data", + "display_name": "Short-term Borrowings", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CL300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Current Tax Liabilities\"]", + "data_source": "Account Data", + "display_name": "Current Tax Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CL400", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Short-term Provisions\"]", + "data_source": "Account Data", + "display_name": "Short-term Provisions", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CL500", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Current Liabilities\"]", + "data_source": "Account Data", + "display_name": "Other Current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CL600", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CL100 + CL200 + CL300 + CL400 + CL500 + CL600", + "data_source": "Calculated Amount", + "display_name": "Total Current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "CL_TOTAL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "NON-CURRENT LIABILITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "NCL_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Long-term Borrowings\"]", + "data_source": "Account Data", + "display_name": "Long-term Borrowings", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "NCL100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Long-term Provisions\"]", + "data_source": "Account Data", + "display_name": "Long-term Provisions", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "NCL200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Non-current Liabilities\"]", + "data_source": "Account Data", + "display_name": "Other Non-current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "NCL300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "NCL100 + NCL200 + NCL300", + "data_source": "Calculated Amount", + "display_name": "Total Non-current Liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "NCL_TOTAL", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CL_TOTAL + NCL_TOTAL", + "data_source": "Calculated Amount", + "display_name": "TOTAL LIABILITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "TOTAL_LIABILITIES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "data_source": "Blank Line", + "display_name": "EQUITY", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EQ_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Share Capital\"]", + "data_source": "Account Data", + "display_name": "Share Capital", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "EQ100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Reserves and Surplus\"]", + "data_source": "Account Data", + "display_name": "Reserves and Surplus", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "EQ200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"in\", [\"Income\", \"Expense\"]]", + "data_source": "Account Data", + "display_name": "Provisional Profit and Loss", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "EQ300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "EQ100 + EQ200 + EQ300", + "data_source": "Calculated Amount", + "display_name": "Total Equity", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "TOTAL_EQUITY", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "TOTAL_EQUITY", + "data_source": "Calculated Amount", + "display_name": "TOTAL EQUITY", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "TOTAL_LIABILITIES + TOTAL_EQUITY", + "data_source": "Calculated Amount", + "display_name": "TOTAL LIABILITIES AND EQUITY", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "TOTAL_LIAB_EQUITY", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "TOTAL_ASSETS - TOTAL_LIAB_EQUITY", + "color": "#CB2929", + "data_source": "Calculated Amount", + "display_name": "Balance Check (should be zero)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "BALANCE_CHECK", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "CA_TOTAL / CL_TOTAL if CL_TOTAL != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Current Ratio", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "CURRENT_RATIO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(CL300 + NCL100) / TOTAL_EQUITY if TOTAL_EQUITY != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Debt to Equity Ratio", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "DEBT_EQUITY_RATIO", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "CA_TOTAL - CL_TOTAL", + "data_source": "Calculated Amount", + "display_name": "Working Capital", + "fieldtype": "Currency", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "WORKING_CAPITAL", + "reverse_sign": 0 + } + ], + "template_name": "Standard Balance Sheet (IFRS)" +} diff --git a/erpnext/accounts/financial_report_template/standard_cash_flow_statement_(ifrs)/__init__.py b/erpnext/accounts/financial_report_template/standard_cash_flow_statement_(ifrs)/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/financial_report_template/standard_cash_flow_statement_(ifrs)/standard_cash_flow_statement_(ifrs).json b/erpnext/accounts/financial_report_template/standard_cash_flow_statement_(ifrs)/standard_cash_flow_statement_(ifrs).json new file mode 100644 index 00000000000..38c25841443 --- /dev/null +++ b/erpnext/accounts/financial_report_template/standard_cash_flow_statement_(ifrs)/standard_cash_flow_statement_(ifrs).json @@ -0,0 +1,832 @@ +{ + "creation": "2025-09-07 22:45:05.754628", + "disabled": 0, + "docstatus": 0, + "doctype": "Financial Report Template", + "idx": 0, + "modified": "2025-10-27 08:25:12.870928", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Standard Cash Flow Statement (IFRS)", + "owner": "Administrator", + "report_type": "Cash Flow", + "rows": [ + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "CASH FLOWS FROM OPERATING ACTIVITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_OP_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"root_type\", \"in\", [\"Income\", \"Expense\"]], [\"account_category\", \"!=\", \"Tax Expense\"]]}", + "color": "", + "data_source": "Account Data", + "display_name": "Profit before tax", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_OP100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "Adjustments for:", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "CF_ADJ_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_type\", \"=\", \"Depreciation\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Depreciation and amortization", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_ADJ100", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Finance Costs\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Finance costs", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_ADJ200", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Investment Income\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Investment income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_ADJ300", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CF_OP100 + CF_ADJ100 + CF_ADJ200 + CF_ADJ300", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Operating profit before working capital changes", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "CF_OP_BEFORE_WC", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "Working capital changes:", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "CF_WC_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Receivables\"]", + "color": "", + "data_source": "Account Data", + "display_name": "(Increase)/decrease in trade receivables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_WC100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Stock Assets\"]", + "color": "", + "data_source": "Account Data", + "display_name": "(Increase)/decrease in inventories", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_WC200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Other Receivables\", \"Other Current Assets\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "(Increase)/decrease in other current assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_WC300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Trade Payables\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Increase/(decrease) in trade payables", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_WC400", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Other Payables\", \"Current Tax Liabilities\", \"Short-term Borrowings\", \"Short-term Provisions\", \"Other Current Liabilities\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "Increase/(decrease) in other current liabilities", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 0, + "reference_code": "CF_WC500", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CF_OP_BEFORE_WC + CF_WC100 + CF_WC200 + CF_WC300 + CF_WC400 + CF_WC500", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Cash generated from operations", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_CASH_GEN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Finance Costs\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Interest paid", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_OP200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Tax Expense\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Income taxes paid", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_OP300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CF_CASH_GEN + CF_OP200 + CF_OP300", + "color": "", + "data_source": "Calculated Amount", + "display_name": "NET CASH FROM OPERATING ACTIVITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_OP_NET", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "CASH FLOWS FROM INVESTING ACTIVITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Tangible Assets\"], [\"account_type\", \"!=\", \"Accumulated Depreciation\"]]}", + "color": "", + "data_source": "Account Data", + "display_name": "Purchase / Sale of property, plant and equipment", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_type\", \"=\", \"Accumulated Depreciation\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Accumulated Depreciation", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV101", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "max(0, CF_ADJ100 - CF_INV101)", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Depreciation and amortization", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 1, + "italic_text": 1, + "reference_code": "CF_INV102", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Intangible Assets\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Purchase of intangible assets", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Long-term Investments\", \"Short-term Investments\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "Purchase of investments", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Investment Income\"], [\"account_name\", \"not like\", \"dividend\"]]}", + "color": "", + "data_source": "Account Data", + "display_name": "Interest received", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV400", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Investment Income\"], [\"account_name\", \"like\", \"dividend\"]]}", + "color": "", + "data_source": "Account Data", + "display_name": "Dividends received", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV500", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CF_INV100 + CF_INV102 + CF_INV200 + CF_INV300 + CF_INV400 + CF_INV500", + "color": "", + "data_source": "Calculated Amount", + "display_name": "NET CASH FROM INVESTING ACTIVITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_INV_NET", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "CASH FLOWS FROM FINANCING ACTIVITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_FIN_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"in\", [\"Share Capital\", \"Reserves and Surplus\"]], [\"account_name\", \"not like\", \"Dividends Paid\"]]}", + "color": "", + "data_source": "Account Data", + "display_name": "Proceeds from issue of share capital", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_FIN100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"in\", [\"Long-term Borrowings\", \"Short-term Borrowings\"]]", + "color": "", + "data_source": "Account Data", + "display_name": "Proceeds from / Repayment of borrowings", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_FIN200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"in\", [\"Share Capital\", \"Reserves and Surplus\"]], [\"account_name\", \"like\", \"Dividends Paid\"]]}", + "color": "", + "data_source": "Account Data", + "display_name": "Dividends paid", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_FIN300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CF_FIN100 + CF_FIN200 + CF_FIN300", + "color": "", + "data_source": "Calculated Amount", + "display_name": "NET CASH FROM FINANCING ACTIVITIES", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_FIN_NET", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CF_OP_NET + CF_INV_NET + CF_FIN_NET", + "color": "", + "data_source": "Calculated Amount", + "display_name": "NET INCREASE/(DECREASE) IN CASH AND CASH EQUIVALENTS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_NET_INCREASE", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Opening Balance", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Cash and Cash Equivalents\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Cash and cash equivalents at beginning of period", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_CASH_BEGIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_type\", \"like\", \"Exchange\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Effect of exchange rate changes", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_FX_EFFECT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Closing Balance", + "bold_text": 1, + "calculation_formula": "[\"account_category\", \"=\", \"Cash and Cash Equivalents\"]", + "color": "", + "data_source": "Account Data", + "display_name": "Cash and cash equivalents at end of period", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_CASH_END", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "CF_CASH_END - (CF_CASH_BEGIN + CF_NET_INCREASE + CF_FX_EFFECT)", + "color": "#CB2929", + "data_source": "Calculated Amount", + "display_name": "Validation Check (should be zero)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "CF_VALIDATION", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "", + "color": "", + "data_source": "Blank Line", + "display_name": "CASH FLOW METRICS", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "CF_METRICS_HEADER", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "CF_OP_NET + CF_INV100", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Free Cash Flow", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "CF_FREE_CASH", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(CF_OP_NET / (CF_FIN300 + CF_OP200)) if (CF_FIN300 + CF_OP200) != 0 else 0", + "color": "", + "data_source": "Calculated Amount", + "display_name": "Cash Flow Coverage Ratio", + "fieldtype": "Float", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "CF_COVERAGE", + "reverse_sign": 0 + } + ], + "template_name": "Standard Cash Flow Statement (IFRS)" +} diff --git a/erpnext/accounts/financial_report_template/standard_profit_and_loss_(ifrs)/__init__.py b/erpnext/accounts/financial_report_template/standard_profit_and_loss_(ifrs)/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/financial_report_template/standard_profit_and_loss_(ifrs)/standard_profit_and_loss_(ifrs).json b/erpnext/accounts/financial_report_template/standard_profit_and_loss_(ifrs)/standard_profit_and_loss_(ifrs).json new file mode 100644 index 00000000000..8bb62a8be2b --- /dev/null +++ b/erpnext/accounts/financial_report_template/standard_profit_and_loss_(ifrs)/standard_profit_and_loss_(ifrs).json @@ -0,0 +1,418 @@ +{ + "creation": "2025-09-06 10:23:05.259864", + "disabled": 0, + "docstatus": 0, + "doctype": "Financial Report Template", + "idx": 0, + "modified": "2025-09-15 15:02:15.911105", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Standard Profit and Loss (IFRS)", + "owner": "Administrator", + "report_type": "Profit and Loss Statement", + "rows": [ + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\":[[\"account_category\",\"=\",\"Revenue from Operations\"]]}", + "data_source": "Account Data", + "display_name": "Sales Revenue", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "REV100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Cost of Goods Sold\"]", + "data_source": "Account Data", + "display_name": "Cost of Goods Sold", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "COGS100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Direct Costs\"]", + "data_source": "Account Data", + "display_name": "Other Direct Costs", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "COGS200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "REV100 + COGS100 + COGS200", + "data_source": "Calculated Amount", + "display_name": "GROSS PROFIT (GP)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "GROSS_PROFIT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Other Operating Income\"]", + "data_source": "Account Data", + "display_name": "Other Operating Income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "OPIN100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Operating Expenses\"], [\"account_name\", \"like\", \"Sales\"]]}", + "data_source": "Account Data", + "display_name": "Selling & Distribution Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "OPEX100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Operating Expenses\"], [\"account_name\", \"like\", \"Administrative\"]]}", + "data_source": "Account Data", + "display_name": "Administrative Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "OPEX200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "{\"and\": [[\"account_category\", \"=\", \"Operating Expenses\"], [\"account_name\", \"not like\", \"Sales\"], [\"account_name\", \"not like\", \"Administrative\"]]}", + "data_source": "Account Data", + "display_name": "Other Operating Expenses", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "OPEX300", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "GROSS_PROFIT + OPIN100 + OPEX100 + OPEX200 + OPEX300", + "data_source": "Calculated Amount", + "display_name": "OPERATING PROFIT (EBIT)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "EBIT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Investment Income\"]", + "data_source": "Account Data", + "display_name": "Investment Income", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "FIN100", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Finance Costs\"]", + "data_source": "Account Data", + "display_name": "Finance Costs", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "FIN200", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "EBIT + FIN100 + FIN200", + "data_source": "Calculated Amount", + "display_name": "PROFIT BEFORE TAX (EBT)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "PBT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"account_category\", \"=\", \"Tax Expense\"]", + "data_source": "Account Data", + "display_name": "Tax Expense", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "TAX", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "PBT + TAX", + "data_source": "Calculated Amount", + "display_name": "NET PROFIT (NP)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "NET_PROFIT", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "NET_PROFIT - ACT_NET_PROFIT", + "color": "#CB2929", + "data_source": "Calculated Amount", + "display_name": "VARIANCE (Calculated vs Actual)", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 1, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "VAL_DIFF", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "data_source": "Blank Line", + "fieldtype": "", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 0, + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(GROSS_PROFIT / REV100) * 100 if REV100 != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Gross Profit Margin %", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "GP_MARGIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(EBIT / REV100) * 100 if REV100 != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Operating Profit Margin %", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "OP_MARGIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 0, + "calculation_formula": "(NET_PROFIT / REV100) * 100 if REV100 != 0 else 0", + "data_source": "Calculated Amount", + "display_name": "Net Profit Margin %", + "fieldtype": "Percent", + "hidden_calculation": 0, + "hide_when_empty": 0, + "include_in_charts": 0, + "indentation_level": 0, + "italic_text": 1, + "reference_code": "NP_MARGIN", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"=\", \"Income\"]", + "data_source": "Account Data", + "display_name": "Total Income", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "ACT_TOTAL_INCOME", + "reverse_sign": 1 + }, + { + "advanced_filtering": 0, + "balance_type": "Period Movement (Debits - Credits)", + "bold_text": 0, + "calculation_formula": "[\"root_type\", \"=\", \"Expense\"]", + "data_source": "Account Data", + "display_name": "Total Expenses", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "ACT_TOTAL_EXPENSES", + "reverse_sign": 0 + }, + { + "advanced_filtering": 0, + "balance_type": "", + "bold_text": 1, + "calculation_formula": "ACT_TOTAL_INCOME - ACT_TOTAL_EXPENSES", + "data_source": "Calculated Amount", + "display_name": "Net Profit", + "fieldtype": "", + "hidden_calculation": 1, + "hide_when_empty": 0, + "include_in_charts": 1, + "indentation_level": 0, + "italic_text": 0, + "reference_code": "ACT_NET_PROFIT", + "reverse_sign": 0 + } + ], + "template_name": "Standard Profit and Loss (IFRS)" +} diff --git a/erpnext/accounts/report/account_balance/test_account_balance.py b/erpnext/accounts/report/account_balance/test_account_balance.py index dd265ce94bc..37d6f292fb6 100644 --- a/erpnext/accounts/report/account_balance/test_account_balance.py +++ b/erpnext/accounts/report/account_balance/test_account_balance.py @@ -39,6 +39,16 @@ class TestAccountBalance(IntegrationTestCase): "currency": "EUR", "balance": 0.0, }, + { + "account": "Interest Income - _TC2", + "currency": "EUR", + "balance": 0.0, + }, + { + "account": "Interest on Fixed Deposits - _TC2", + "currency": "EUR", + "balance": 0.0, + }, { "account": "Sales - _TC2", "currency": "EUR", diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.js b/erpnext/accounts/report/balance_sheet/balance_sheet.js index 0273ee7277e..17d31cd1683 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.js +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.js @@ -1,11 +1,28 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -frappe.query_reports["Balance Sheet"] = $.extend({}, erpnext.financial_statements); +const BS_REPORT_NAME = "Balance Sheet"; -erpnext.utils.add_dimensions("Balance Sheet", 10); +frappe.query_reports[BS_REPORT_NAME] = $.extend({}, erpnext.financial_statements); -frappe.query_reports["Balance Sheet"]["filters"].push( +erpnext.utils.add_dimensions(BS_REPORT_NAME, 10); + +frappe.query_reports[BS_REPORT_NAME]["filters"].push( + { + fieldname: "report_template", + label: __("Report Template"), + fieldtype: "Link", + options: "Financial Report Template", + get_query: { filters: { report_type: BS_REPORT_NAME, disabled: 0 } }, + }, + { + fieldname: "show_account_details", + label: __("Account Detail Level"), + fieldtype: "Select", + options: ["Summary", "Account Breakdown"], + default: "Summary", + depends_on: "eval:doc.report_template", + }, { fieldname: "selected_view", label: __("Select View"), @@ -33,7 +50,8 @@ frappe.query_reports["Balance Sheet"]["filters"].push( fieldname: "show_zero_values", label: __("Show zero values"), fieldtype: "Check", + depends_on: "eval:!doc.report_template", } ); -frappe.query_reports["Balance Sheet"]["export_hidden_cols"] = true; +frappe.query_reports[BS_REPORT_NAME]["export_hidden_cols"] = true; diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index fc19c40f8f9..3606de08ca3 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -6,6 +6,9 @@ import frappe from frappe import _ from frappe.utils import cint, flt +from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( + FinancialReportEngine, +) from erpnext.accounts.report.financial_statements import ( compute_growth_view_data, get_columns, @@ -16,6 +19,9 @@ from erpnext.accounts.report.financial_statements import ( def execute(filters=None): + if filters and filters.report_template: + return FinancialReportEngine().execute(filters) + period_list = get_period_list( filters.from_fiscal_year, filters.to_fiscal_year, diff --git a/erpnext/accounts/report/cash_flow/cash_flow.js b/erpnext/accounts/report/cash_flow/cash_flow.js index 6c44c07a508..bf3f636bf7c 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.js +++ b/erpnext/accounts/report/cash_flow/cash_flow.js @@ -1,20 +1,37 @@ // Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.query_reports["Cash Flow"] = $.extend(erpnext.financial_statements, { +const CF_REPORT_NAME = "Cash Flow"; + +frappe.query_reports[CF_REPORT_NAME] = $.extend(erpnext.financial_statements, { name_field: "section", parent_field: "parent_section", }); -erpnext.utils.add_dimensions("Cash Flow", 10); +erpnext.utils.add_dimensions(CF_REPORT_NAME, 10); // The last item in the array is the definition for Presentation Currency // filter. It won't be used in cash flow for now so we pop it. Please take // of this if you are working here. -frappe.query_reports["Cash Flow"]["filters"].splice(8, 1); +frappe.query_reports[CF_REPORT_NAME]["filters"].splice(8, 1); -frappe.query_reports["Cash Flow"]["filters"].push( +frappe.query_reports[CF_REPORT_NAME]["filters"].push( + { + fieldname: "report_template", + label: __("Report Template"), + fieldtype: "Link", + options: "Financial Report Template", + get_query: { filters: { report_type: CF_REPORT_NAME, disabled: 0 } }, + }, + { + fieldname: "show_account_details", + label: __("Account Detail Level"), + fieldtype: "Select", + options: ["Summary", "Account Breakdown"], + default: "Summary", + depends_on: "eval:doc.report_template", + }, { fieldname: "include_default_book_entries", label: __("Include Default FB Entries"), diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index 1d53ac6b680..02a1f87a8d9 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -10,6 +10,9 @@ from frappe.query_builder import DocType from frappe.utils import cstr, flt from pypika import Order +from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( + FinancialReportEngine, +) from erpnext.accounts.report.financial_statements import ( get_columns, get_cost_centers_with_children, @@ -25,6 +28,9 @@ from erpnext.accounts.utils import get_fiscal_year def execute(filters=None): + if filters and filters.report_template: + return FinancialReportEngine().execute(filters) + period_list = get_period_list( filters.from_fiscal_year, filters.to_fiscal_year, diff --git a/erpnext/accounts/report/custom_financial_statement/__init__.py b/erpnext/accounts/report/custom_financial_statement/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.js b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.js new file mode 100644 index 00000000000..e5329bdd301 --- /dev/null +++ b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.js @@ -0,0 +1,35 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +const CFS_REPORT_NAME = "Custom Financial Statement"; + +frappe.query_reports[CFS_REPORT_NAME] = $.extend({}, erpnext.financial_statements); + +erpnext.utils.add_dimensions(CFS_REPORT_NAME, 10); + +frappe.query_reports[CFS_REPORT_NAME]["filters"].push( + { + fieldname: "report_template", + label: __("Report Template"), + fieldtype: "Link", + options: "Financial Report Template", + get_query: { filters: { disabled: 0 } }, + reqd: 1, + }, + { + fieldname: "show_account_details", + label: __("Account Detail Level"), + fieldtype: "Select", + options: ["Summary", "Account Breakdown"], + default: "Summary", + depends_on: "eval:doc.report_template", + }, + { + fieldname: "include_default_book_entries", + label: __("Include Default FB Entries"), + fieldtype: "Check", + default: 1, + } +); + +frappe.query_reports[CFS_REPORT_NAME]["export_hidden_cols"] = true; diff --git a/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.json b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.json new file mode 100644 index 00000000000..c51407b6a28 --- /dev/null +++ b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.json @@ -0,0 +1,34 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2025-10-03 01:21:24.043064", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2025-10-03 01:21:24.043064", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Custom Financial Statement", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "Custom Financial Statement", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + } + ], + "timeout": 0 +} diff --git a/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py new file mode 100644 index 00000000000..dc506071f01 --- /dev/null +++ b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py @@ -0,0 +1,11 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( + FinancialReportEngine, +) + + +def execute(filters: dict | None = None): + if filters and filters.report_template: + return FinancialReportEngine().execute(filters) diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js index 666f41394b6..e679f1f5728 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js @@ -1,11 +1,28 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -frappe.query_reports["Profit and Loss Statement"] = $.extend({}, erpnext.financial_statements); +const PL_REPORT_NAME = "Profit and Loss Statement"; -erpnext.utils.add_dimensions("Profit and Loss Statement", 10); +frappe.query_reports[PL_REPORT_NAME] = $.extend({}, erpnext.financial_statements); -frappe.query_reports["Profit and Loss Statement"]["filters"].push( +erpnext.utils.add_dimensions(PL_REPORT_NAME, 10); + +frappe.query_reports[PL_REPORT_NAME]["filters"].push( + { + fieldname: "report_template", + label: __("Report Template"), + fieldtype: "Link", + options: "Financial Report Template", + get_query: { filters: { report_type: PL_REPORT_NAME, disabled: 0 } }, + }, + { + fieldname: "show_account_details", + label: __("Account Detail Level"), + fieldtype: "Select", + options: ["Summary", "Account Breakdown"], + default: "Summary", + depends_on: "eval:doc.report_template", + }, { fieldname: "selected_view", label: __("Select View"), @@ -34,7 +51,8 @@ frappe.query_reports["Profit and Loss Statement"]["filters"].push( fieldname: "show_zero_values", label: __("Show zero values"), fieldtype: "Check", + depends_on: "eval:!doc.report_template", } ); -frappe.query_reports["Profit and Loss Statement"]["export_hidden_cols"] = true; +frappe.query_reports[PL_REPORT_NAME]["export_hidden_cols"] = true; diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index ccb4d26f77b..b4280ca3067 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -6,6 +6,9 @@ import frappe from frappe import _ from frappe.utils import flt +from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( + FinancialReportEngine, +) from erpnext.accounts.report.financial_statements import ( compute_growth_view_data, compute_margin_view_data, @@ -17,6 +20,9 @@ from erpnext.accounts.report.financial_statements import ( def execute(filters=None): + if filters and filters.report_template: + return FinancialReportEngine().execute(filters) + period_list = get_period_list( filters.from_fiscal_year, filters.to_fiscal_year, diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5fa0f56e8e2..a1edea61b40 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -440,6 +440,7 @@ erpnext.patches.v16_0.make_workstation_operating_components #1 erpnext.patches.v16_0.set_reporting_currency erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes erpnext.patches.v16_0.update_serial_no_reference_name +erpnext.patches.v16_0.update_account_categories_for_existing_accounts erpnext.patches.v16_0.rename_subcontracted_quantity erpnext.patches.v16_0.add_new_stock_entry_types erpnext.patches.v15_0.set_asset_status_if_not_already_set diff --git a/erpnext/patches/v16_0/update_account_categories_for_existing_accounts.py b/erpnext/patches/v16_0/update_account_categories_for_existing_accounts.py new file mode 100644 index 00000000000..0290ef44c7d --- /dev/null +++ b/erpnext/patches/v16_0/update_account_categories_for_existing_accounts.py @@ -0,0 +1,69 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + +from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import get_chart_metadata_fields +from erpnext.accounts.doctype.account.chart_of_accounts.verified import standard_chart_of_accounts +from erpnext.accounts.doctype.financial_report_template.financial_report_template import ( + sync_financial_report_templates, +) + + +def execute(): + """ + Patch to create default account categories and update existing accounts + with appropriate account categories based on standard chart of accounts mapping + """ + sync_financial_report_templates() + update_account_categories() + + +def update_account_categories(): + account_mapping = get_standard_account_category_mapping() + companies = frappe.get_all("Company", pluck="name") + + mapped_account_categories = {} + + for company in companies: + map_account_categories_for_company(company, account_mapping, mapped_account_categories) + + if not mapped_account_categories: + return + + frappe.db.bulk_update("Account", mapped_account_categories) + + +def get_standard_account_category_mapping(): + account_mapping = {} + + def _extract_account_mapping(chart_data, prefix=""): + for account_name, account_details in chart_data.items(): + if account_name in get_chart_metadata_fields(): + continue + + if isinstance(account_details, dict) and account_details.get("account_category"): + account_mapping[account_name] = account_details["account_category"] + + if isinstance(account_details, dict): + _extract_account_mapping(account_details, prefix) + + standard_chart = standard_chart_of_accounts.get() + _extract_account_mapping(standard_chart) + + return account_mapping + + +def map_account_categories_for_company(company, account_mapping, mapped_account_categories): + accounts = frappe.get_all( + "Account", + filters={"company": company, "account_category": ["is", "not set"]}, + fields=["name", "account_name"], + ) + + for account in accounts: + account_category = account_mapping.get(account.account_name) + + if account_category: + mapped_account_categories[account.name] = {"account_category": account_category} diff --git a/erpnext/public/js/financial_statements.js b/erpnext/public/js/financial_statements.js index 2783055613a..8a3ac40d212 100644 --- a/erpnext/public/js/financial_statements.js +++ b/erpnext/public/js/financial_statements.js @@ -4,45 +4,160 @@ erpnext.financial_statements = { filters: get_filters(), baseData: null, formatter: function (value, row, column, data, default_formatter, filter) { - if ( - frappe.query_report.get_filter_value("selected_view") == "Growth" && - data && - column.colIndex >= 3 - ) { - const growthPercent = data[column.fieldname]; + // Growth/Margin + if (this._is_special_view(column, data)) + return this._format_special_view(value, row, column, data, default_formatter); - if (growthPercent == undefined) return "NA"; //making this not applicable for undefined/null values + if (frappe.query_report.get_filter_value("report_template")) + return this._format_custom_report(value, row, column, data, default_formatter, filter); + else return this._format_standard_report(value, row, column, data, default_formatter, filter); + }, + + _is_special_view: function (column, data) { + if (!data) return false; + const view = frappe.query_report.get_filter_value("selected_view"); + return (view === "Growth" && column.colIndex >= 3) || (view === "Margin" && column.colIndex >= 2); + }, + + _format_custom_report: function (value, row, column, data, default_formatter, filter) { + const columnInfo = this._parse_column_info(column.fieldname, data); + const formatting = this._get_formatting_for_column(data, columnInfo); + + if (columnInfo.isAccount) { + return this._format_custom_account_column( + value, + data, + formatting, + column, + default_formatter, + row + ); + } else { + return this._format_custom_value_column(value, data, formatting, column, default_formatter, row); + } + }, + + _parse_column_info: function (fieldname, data) { + const valueMatch = fieldname.match(/^(?:seg_(\d+)_)?(.+)$/); + + const periodKeys = data._segment_info?.period_keys || []; + const baseName = valueMatch ? valueMatch[2] : fieldname; + const isPeriodColumn = periodKeys.includes(baseName); + + return { + isAccount: baseName === "account", + isPeriod: isPeriodColumn, + segmentIndex: valueMatch && valueMatch[1] ? parseInt(valueMatch[1]) : null, + fieldname: baseName, + }; + }, + + _get_formatting_for_column: function (data, columnInfo) { + let formatting = {}; + + if (columnInfo.segmentIndex !== null && data.segment_values) + formatting = data.segment_values[`seg_${columnInfo.segmentIndex}`] || {}; + else formatting = data; + + return formatting; + }, + + _format_custom_account_column: function (value, data, formatting, column, default_formatter, row) { + if (!value) return ""; + + // Link to open ledger + const should_link_to_ledger = + formatting.is_detail || (formatting.account_filters && formatting.child_accounts); + + if (should_link_to_ledger) { + const glData = { + account: formatting.account_name || formatting.child_accounts || value, + from_date: formatting.from_date || formatting.period_start_date, + to_date: formatting.to_date || formatting.period_end_date, + account_type: formatting.account_type, + company: frappe.query_report.get_filter_value("company"), + }; + + column.link_onclick = + "erpnext.financial_statements.open_general_ledger(" + JSON.stringify(glData) + ")"; + + value = default_formatter(value, row, column, data); + } + + let formattedValue = String(value); + + // Prefix + if (formatting.is_detail || formatting.prefix) + formattedValue = (formatting.prefix || "• ") + formattedValue; + + // Indent + if (data._segment_info && data._segment_info.total_segments === 1) { + column.is_tree = true; + } else if (formatting.indent && formatting.indent > 0) { + const indent = " ".repeat(formatting.indent * 4); + formattedValue = indent + formattedValue; + } + + // Style + return this._style_custom_value(formattedValue, formatting, null); + }, + + _format_custom_value_column: function (value, data, formatting, column, default_formatter, row) { + if (formatting.is_blank_line) return ""; + + const col = { ...column }; + col.fieldtype = formatting.fieldtype || col.fieldtype; + // Avoid formatting as currency + if (col.fieldtype === "Float") col.options = null; + + let formattedValue = default_formatter(value, row, col, data); + return this._style_custom_value(formattedValue, formatting, value); + }, + + _style_custom_value(formattedValue, formatting, value) { + let $element = $(`${formattedValue}`); + + 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); + + return $element.wrap("

").parent().html(); + }, + + _format_special_view: function (value, row, column, data, default_formatter) { + const selectedView = frappe.query_report.get_filter_value("selected_view"); + + if (selectedView === "Growth") { + const growthPercent = data[column.fieldname]; + if (growthPercent === undefined) return "NA"; + if (growthPercent === "") return ""; if (column.fieldname === "total") { value = $(`${growthPercent}`); } else { value = $(`${(growthPercent >= 0 ? "+" : "") + growthPercent + "%"}`); - if (growthPercent < 0) { value = $(value).addClass("text-danger"); } else { value = $(value).addClass("text-success"); } } - value = $(value).wrap("

").parent().html(); + return $(value).wrap("

").parent().html(); + } else { + const marginPercent = data[column.fieldname]; + if (marginPercent === undefined) return "NA"; - return value; - } else if (frappe.query_report.get_filter_value("selected_view") == "Margin" && data) { - if (column.colIndex >= 2) { - const marginPercent = data[column.fieldname]; - - if (marginPercent == undefined) return "NA"; //making this not applicable for undefined/null values - - value = $(`${marginPercent + "%"}`); - if (marginPercent < 0) value = $(value).addClass("text-danger"); - else value = $(value).addClass("text-success"); - value = $(value).wrap("

").parent().html(); - return value; - } + value = $(`${marginPercent + "%"}`); + if (marginPercent < 0) value = $(value).addClass("text-danger"); + else value = $(value).addClass("text-success"); + return $(value).wrap("

").parent().html(); } + }, + _format_standard_report: function (value, row, column, data, default_formatter, filter) { if (data && column.fieldname == this.name_field) { - // first column value = data.section_name || data.account_name || value; if (filter && filter?.text && filter?.type == "contains") { diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 7d8a9002d30..9eebf21b1d0 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -15,6 +15,9 @@ from frappe.utils import add_months, cint, formatdate, get_first_day, get_link_t from frappe.utils.nestedset import NestedSet, rebuild_tree from erpnext.accounts.doctype.account.account import get_account_currency +from erpnext.accounts.doctype.financial_report_template.financial_report_template import ( + sync_financial_report_templates, +) from erpnext.setup.setup_wizard.operations.taxes_setup import setup_taxes_and_charges @@ -294,6 +297,7 @@ class Company(NestedSet): ): if not frappe.local.flags.ignore_chart_of_accounts: frappe.flags.country_change = True + sync_financial_report_templates(self.chart_of_accounts, self.existing_company) self.create_default_accounts() self.create_default_warehouses()