From 181ad0bdcd0453008f58a2f8660885abd3e74f53 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 24 Sep 2025 18:28:13 +0530 Subject: [PATCH] feat: consolidated trial balance report --- .../consolidated_trial_balance/__init__.py | 0 .../consolidated_trial_balance.html | 1 + .../consolidated_trial_balance.js | 101 ++++ .../consolidated_trial_balance.json | 34 ++ .../consolidated_trial_balance.py | 469 ++++++++++++++++++ 5 files changed, 605 insertions(+) create mode 100644 erpnext/accounts/report/consolidated_trial_balance/__init__.py create mode 100644 erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.html create mode 100644 erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.js create mode 100644 erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.json create mode 100644 erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.py diff --git a/erpnext/accounts/report/consolidated_trial_balance/__init__.py b/erpnext/accounts/report/consolidated_trial_balance/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.html b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.html new file mode 100644 index 00000000000..d4ae54d4f38 --- /dev/null +++ b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.html @@ -0,0 +1 @@ +{% include "accounts/report/financial_statements.html" %} diff --git a/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.js b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.js new file mode 100644 index 00000000000..65f2e3b905a --- /dev/null +++ b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.js @@ -0,0 +1,101 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Consolidated Trial Balance"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "MultiSelectList", + options: "Company", + get_data: function (txt) { + return frappe.db.get_link_options("Company", txt); + }, + reqd: 1, + }, + { + fieldname: "fiscal_year", + label: __("Fiscal Year"), + fieldtype: "Link", + options: "Fiscal Year", + default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today()), + reqd: 1, + on_change: function (query_report) { + var fiscal_year = query_report.get_values().fiscal_year; + if (!fiscal_year) { + return; + } + frappe.model.with_doc("Fiscal Year", fiscal_year, function (r) { + var fy = frappe.model.get_doc("Fiscal Year", fiscal_year); + frappe.query_report.set_filter_value({ + from_date: fy.year_start_date, + to_date: fy.year_end_date, + }); + }); + }, + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1], + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[2], + }, + { + fieldname: "finance_book", + label: __("Finance Book"), + fieldtype: "Link", + options: "Finance Book", + }, + { + fieldname: "presentation_currency", + label: __("Currency"), + fieldtype: "Select", + options: erpnext.get_presentation_currency_list(), + }, + { + fieldname: "with_period_closing_entry_for_opening", + label: __("With Period Closing Entry For Opening Balances"), + fieldtype: "Check", + default: 1, + }, + { + fieldname: "with_period_closing_entry_for_current_period", + label: __("Period Closing Entry For Current Period"), + fieldtype: "Check", + default: 1, + }, + { + fieldname: "show_zero_values", + label: __("Show zero values"), + fieldtype: "Check", + }, + { + fieldname: "show_unclosed_fy_pl_balances", + label: __("Show unclosed fiscal year's P&L balances"), + fieldtype: "Check", + }, + { + fieldname: "include_default_book_entries", + label: __("Include Default FB Entries"), + fieldtype: "Check", + default: 1, + }, + { + fieldname: "show_group_accounts", + label: __("Show Group Accounts"), + fieldtype: "Check", + default: 1, + }, + ], + formatter: erpnext.financial_statements.formatter, + tree: true, + name_field: "account", + parent_field: "parent_account", + initial_depth: 3, +}; diff --git a/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.json b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.json new file mode 100644 index 00000000000..e200fc0040f --- /dev/null +++ b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.json @@ -0,0 +1,34 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2025-09-03 00:53:22.230646", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2025-09-03 00:53:22.230646", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Consolidated Trial Balance", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "Consolidated Trial Balance", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + } + ], + "timeout": 0 +} diff --git a/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.py b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.py new file mode 100644 index 00000000000..81a9c426f44 --- /dev/null +++ b/erpnext/accounts/report/consolidated_trial_balance/consolidated_trial_balance.py @@ -0,0 +1,469 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.utils import flt, getdate, now_datetime, nowdate + +import erpnext +from erpnext.accounts.doctype.account.account import get_root_company +from erpnext.accounts.report.financial_statements import ( + filter_accounts, + filter_out_zero_value_rows, + set_gl_entries_by_account, +) +from erpnext.accounts.report.trial_balance.trial_balance import ( + accumulate_values_into_parents, + calculate_values, + get_opening_balances, + hide_group_accounts, + prepare_opening_closing, + value_fields, +) +from erpnext.accounts.report.trial_balance.trial_balance import ( + validate_filters as tb_validate_filters, +) +from erpnext.accounts.report.utils import get_rate_as_at +from erpnext.accounts.utils import get_zero_cutoff +from erpnext.setup.utils import get_exchange_rate + + +def execute(filters: dict | None = None): + """Return columns and data for the report. + + This is the main entry point for the report. It accepts the filters as a + dictionary and should return columns and data. It is called by the framework + every time the report is refreshed or a filter is updated. + """ + validate_filters(filters=filters) + columns = get_columns() + data = get_data(filters) + + return columns, data + + +def validate_filters(filters): + validate_companies(filters) + filters.show_net_values = True + tb_validate_filters(filters) + + +def validate_companies(filters): + if not filters.company: + return + + root_company = get_root_company(filters.company[0]) + root_company = root_company[0] if root_company else filters.company[0] + + lft, rgt = frappe.db.get_value("Company", root_company, fieldname=["lft", "rgt"]) + + company_subtree = frappe.db.get_all( + "Company", + {"lft": [">=", lft], "rgt": ["<=", rgt]}, + "name", + order_by="lft", + pluck="name", + ) + + for company in filters.company: + if company not in company_subtree: + frappe.throw( + _("Consolidated Trial Balance can be generated for Companies having same root Company.") + ) + + sort_companies(filters) + + +def sort_companies(filters): + companies = frappe.db.get_all( + "Company", {"name": ["in", filters.company]}, "name", order_by="lft", pluck="name" + ) + filters.company = companies + + +def get_data(filters) -> list[list]: + """Return data for the report. + + The report data is a list of rows, with each row being a list of cell values. + """ + data = [] + if filters.company: + reporting_currency, ignore_reporting_currency = get_reporting_currency(filters) + else: + return data + + for company in filters.company: + company_filter = frappe._dict(filters) + company_filter.company = company + + tb_data = get_company_wise_tb_data(company_filter, reporting_currency, ignore_reporting_currency) + consolidate_trial_balance_data(data, tb_data) + + for d in data: + prepare_opening_closing(d) + + total_row = calculate_total_row(data, reporting_currency) + + data.extend([{}, total_row]) + + if not filters.get("show_group_accounts"): + data = hide_group_accounts(data) + + if filters.get("presentation_currency"): + update_to_presentation_currency( + data, + reporting_currency, + filters.get("presentation_currency"), + filters.get("to_date"), + ignore_reporting_currency, + ) + + return data + + +def get_company_wise_tb_data(filters, reporting_currency, ignore_reporting_currency): + accounts = frappe.db.sql( + """select name, account_number, parent_account, account_name, root_type, report_type, account_type, is_group, lft, rgt + + from `tabAccount` where company=%s order by lft""", + filters.company, + as_dict=True, + ) + + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + default_currency = erpnext.get_company_currency(filters.company) + + opening_exchange_rate = get_exchange_rate( + default_currency, + reporting_currency, + filters.get("from_date"), + ) + current_date = ( + filters.get("to_date") if getdate(filters.get("to_date")) <= now_datetime().date() else nowdate() + ) + closing_exchange_rate = get_exchange_rate( + default_currency, + reporting_currency, + current_date, + ) + + if not (opening_exchange_rate and closing_exchange_rate): + frappe.throw( + _( + "Consolidated Trial balance could not be generated as Exchange Rate from {0} to {1} is not available for {2}.", + ).format(default_currency, reporting_currency, current_date) + ) + + if not accounts: + return [] + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + + gl_entries_by_account = {} + + opening_balances = get_opening_balances( + filters, + ignore_is_opening, + exchange_rate=opening_exchange_rate, + ignore_reporting_currency=ignore_reporting_currency, + ) + + set_gl_entries_by_account( + filters.company, + filters.from_date, + filters.to_date, + filters, + gl_entries_by_account, + root_lft=None, + root_rgt=None, + ignore_closing_entries=not flt(filters.with_period_closing_entry_for_current_period), + ignore_opening_entries=True, + group_by_account=True, + ignore_reporting_currency=ignore_reporting_currency, + ) + + calculate_values( + accounts, + gl_entries_by_account, + opening_balances, + filters.get("show_net_values"), + ignore_is_opening=ignore_is_opening, + exchange_rate=closing_exchange_rate, + ignore_reporting_currency=ignore_reporting_currency, + ) + + accumulate_values_into_parents(accounts, accounts_by_name) + + data = prepare_companywise_tb_data(accounts, filters, parent_children_map, reporting_currency) + data = filter_out_zero_value_rows( + data, parent_children_map, show_zero_values=filters.get("show_zero_values") + ) + + return data + + +def prepare_companywise_tb_data(accounts, filters, parent_children_map, reporting_currency): + data = [] + + for d in accounts: + # Prepare opening closing for group account + if parent_children_map.get(d.account) and filters.get("show_net_values"): + prepare_opening_closing(d) + + has_value = False + row = { + "account": d.name, + "parent_account": d.parent_account, + "indent": d.indent, + "from_date": filters.from_date, + "to_date": filters.to_date, + "currency": reporting_currency, + "is_group_account": d.is_group, + "acc_name": d.account_name, + "acc_number": d.account_number, + "account_name": ( + f"{d.account_number} - {d.account_name}" if d.account_number else d.account_name + ), + "root_type": d.root_type, + "account_type": d.account_type, + } + + for key in value_fields: + row[key] = flt(d.get(key, 0.0), 3) + + if abs(row[key]) >= get_zero_cutoff(reporting_currency): + # ignore zero values + has_value = True + + row["has_value"] = has_value + data.append(row) + + return data + + +def calculate_total_row(data, reporting_currency): + total_row = { + "account": "'" + _("Total") + "'", + "account_name": "'" + _("Total") + "'", + "warn_if_negative": True, + "opening_debit": 0.0, + "opening_credit": 0.0, + "debit": 0.0, + "credit": 0.0, + "closing_debit": 0.0, + "closing_credit": 0.0, + "parent_account": None, + "indent": 0, + "has_value": True, + "currency": reporting_currency, + } + + for d in data: + if not d.get("parent_account"): + for field in value_fields: + total_row[field] += d[field] + + calculate_foreign_currency_translation_reserve(total_row, data) + + return total_row + + +def calculate_foreign_currency_translation_reserve(total_row, data): + opening_dr_cr_diff = total_row["opening_debit"] - total_row["opening_credit"] + dr_cr_diff = total_row["debit"] - total_row["credit"] + + idx = get_fctr_root_row_index(data) + + fctr_row = { + "account": _("Foreign Currency Translation Reserve"), + "account_name": _("Foreign Currency Translation Reserve"), + "warn_if_negative": True, + "opening_debit": abs(opening_dr_cr_diff) if opening_dr_cr_diff < 0 else 0.0, + "opening_credit": abs(opening_dr_cr_diff) if opening_dr_cr_diff > 0 else 0.0, + "debit": abs(dr_cr_diff) if dr_cr_diff < 0 else 0.0, + "credit": abs(dr_cr_diff) if dr_cr_diff > 0 else 0.0, + "closing_debit": 0.0, + "closing_credit": 0.0, + "root_type": data[idx].get("root_type"), + "account_type": "Equity", + "parent_account": data[idx].get("account"), + "indent": data[idx].get("indent") + 1, + "has_value": True, + "currency": total_row.get("currency"), + } + + fctr_row["closing_debit"] = fctr_row["opening_debit"] + fctr_row["debit"] + fctr_row["closing_credit"] = fctr_row["opening_credit"] + fctr_row["credit"] + + prepare_opening_closing(fctr_row) + + data.insert(idx + 1, fctr_row) + + for field in value_fields: + total_row[field] += fctr_row[field] + + +def get_fctr_root_row_index(data): + """ + Returns: index, root_type, parent_account + """ + liabilities_idx, equity_idx, tmp_idx = -1, -1, 0 + for d in data: + if liabilities_idx == -1 and d.get("root_type") == "Liability": + liabilities_idx = tmp_idx + + if equity_idx == -1 and d.get("root_type") == "Equity": + equity_idx = tmp_idx + + tmp_idx += 1 + + if equity_idx == -1: + return liabilities_idx + + return equity_idx + + +def consolidate_trial_balance_data(data, tb_data): + if not data: + data.extend(list(tb_data)) + return + + for entry in tb_data: + if entry: + consolidate_gle_data(data, entry, tb_data) + + +def get_reporting_currency(filters): + reporting_currency = frappe.get_cached_value("Company", filters.company[0], "reporting_currency") + default_currency = None + for company in filters.company: + company_default_currency = erpnext.get_company_currency(company) + if not default_currency: + default_currency = company_default_currency + + if company_default_currency != default_currency: + return (reporting_currency, False) + + return (default_currency, True) + + +def consolidate_gle_data(data, entry, tb_data): + entry_gle_exists = False + for gle in data: + if gle and gle["account_name"] == entry["account_name"]: + entry_gle_exists = True + gle["closing_credit"] += entry["closing_credit"] + gle["closing_debit"] += entry["closing_debit"] + gle["credit"] += entry["credit"] + gle["debit"] += entry["debit"] + gle["opening_credit"] += entry["opening_credit"] + gle["opening_debit"] += entry["opening_debit"] + gle["has_value"] = 1 + + if not entry_gle_exists: + entry_parent_account = next( + (d for d in tb_data if d.get("account") == entry.get("parent_account")), None + ) + parent_account_in_data = None + if entry_parent_account: + parent_account_in_data = next( + (d for d in data if d and d.get("account_name") == entry_parent_account.get("account_name")), + None, + ) + if parent_account_in_data: + entry["parent_account"] = parent_account_in_data.get("account") + entry["indent"] = (parent_account_in_data.get("indent") or 0) + 1 + data.insert(data.index(parent_account_in_data) + 1, entry) + else: + entry["parent_account"] = None + entry["indent"] = 0 + data.append(entry) + + +def update_to_presentation_currency(data, from_currency, to_currency, date, ignore_reporting_currency): + if from_currency == to_currency: + return + + exchange_rate = get_rate_as_at(date, from_currency, to_currency) + + for d in data: + if not ignore_reporting_currency: + for field in value_fields: + if d.get(field): + d[field] = d[field] * flt(exchange_rate) + d.update(currency=to_currency) + + +def get_columns(): + return [ + { + "fieldname": "account_name", + "label": _("Account"), + "fieldtype": "Data", + "width": 300, + }, + { + "fieldname": "acc_name", + "label": _("Account Name"), + "fieldtype": "Data", + "hidden": 1, + "width": 250, + }, + { + "fieldname": "acc_number", + "label": _("Account Number"), + "fieldtype": "Data", + "hidden": 1, + "width": 120, + }, + { + "fieldname": "currency", + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "hidden": 1, + }, + { + "fieldname": "opening_debit", + "label": _("Opening (Dr)"), + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "fieldname": "opening_credit", + "label": _("Opening (Cr)"), + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "fieldname": "debit", + "label": _("Debit"), + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "fieldname": "credit", + "label": _("Credit"), + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "fieldname": "closing_debit", + "label": _("Closing (Dr)"), + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "fieldname": "closing_credit", + "label": _("Closing (Cr)"), + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + ]