From 16e63783882fd617ce60f9b3c85b2891898aeaf2 Mon Sep 17 00:00:00 2001 From: bobrador <bobrador@apsl.net> Date: Thu, 7 Nov 2024 15:18:37 +0100 Subject: [PATCH] [ADD] account_analytic_report: New module account_analytic_report --- account_analytic_report/README.rst | 129 +++ account_analytic_report/__init__.py | 2 + account_analytic_report/__manifest__.py | 30 + account_analytic_report/i18n/es.po | 410 +++++++++ account_analytic_report/menuitems.xml | 15 + account_analytic_report/pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 3 + account_analytic_report/readme/DESCRIPTION.md | 1 + account_analytic_report/readme/USAGE.md | 25 + account_analytic_report/report/__init__.py | 2 + .../templates/trial_balance_analytic.xml | 492 ++++++++++ .../report/trial_balance_analytic.py | 837 ++++++++++++++++++ .../report/trial_balance_analytic_xlsx.py | 643 ++++++++++++++ account_analytic_report/reports.xml | 47 + .../security/ir.model.access.csv | 2 + .../static/description/icon.png | Bin 0 -> 36663 bytes .../static/description/index.html | 483 ++++++++++ account_analytic_report/tests/__init__.py | 1 + .../tests/test_trial_analytic_balance.py | 374 ++++++++ .../views/account_analytic_line.xml | 14 + .../views/report_trial_balance_analytic.xml | 11 + account_analytic_report/wizard/__init__.py | 1 + .../trial_balance_analytic_wizard_view.py | 170 ++++ .../trial_balance_analytic_wizard_view.xml | 94 ++ 24 files changed, 3789 insertions(+) create mode 100644 account_analytic_report/README.rst create mode 100644 account_analytic_report/__init__.py create mode 100644 account_analytic_report/__manifest__.py create mode 100644 account_analytic_report/i18n/es.po create mode 100644 account_analytic_report/menuitems.xml create mode 100644 account_analytic_report/pyproject.toml create mode 100644 account_analytic_report/readme/CONTRIBUTORS.md create mode 100644 account_analytic_report/readme/DESCRIPTION.md create mode 100644 account_analytic_report/readme/USAGE.md create mode 100644 account_analytic_report/report/__init__.py create mode 100644 account_analytic_report/report/templates/trial_balance_analytic.xml create mode 100644 account_analytic_report/report/trial_balance_analytic.py create mode 100644 account_analytic_report/report/trial_balance_analytic_xlsx.py create mode 100644 account_analytic_report/reports.xml create mode 100644 account_analytic_report/security/ir.model.access.csv create mode 100644 account_analytic_report/static/description/icon.png create mode 100644 account_analytic_report/static/description/index.html create mode 100644 account_analytic_report/tests/__init__.py create mode 100644 account_analytic_report/tests/test_trial_analytic_balance.py create mode 100644 account_analytic_report/views/account_analytic_line.xml create mode 100644 account_analytic_report/views/report_trial_balance_analytic.xml create mode 100644 account_analytic_report/wizard/__init__.py create mode 100644 account_analytic_report/wizard/trial_balance_analytic_wizard_view.py create mode 100644 account_analytic_report/wizard/trial_balance_analytic_wizard_view.xml diff --git a/account_analytic_report/README.rst b/account_analytic_report/README.rst new file mode 100644 index 00000000000..23508571912 --- /dev/null +++ b/account_analytic_report/README.rst @@ -0,0 +1,129 @@ +======================== +Account Analytic Reports +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e3b2f8d263dd282038c6d240451ddf65612a4d8dfbf754af136900aa97285230 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--financial--reporting-lightgray.png?logo=github + :target: https://github.com/OCA/account-financial-reporting/tree/17.0/account_analytic_report + :alt: OCA/account-financial-reporting +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-financial-reporting-17-0/account-financial-reporting-17-0-account_analytic_report + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/account-financial-reporting&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module introduces an analytic report that provides an intuitive way +to view and analyze analytic balances. It simplifies the process, +offering enhanced insights and making it easier to leverage this +information effectively. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Using this module is straightforward. Follow these steps: + +- | **Navigate to the Report**: + | Go to **Invoicing** -> **Reporting** -> **Analytic Trial Balance**. + +- | **Customize the Report with Filters**: + | Adjust the report using the available options: + + - | **Group by Analytic Account**: + | Groups the results by analytic accounts instead of financial + accounts. + + - | **Show Hierarchy and Limit Hierarchy Level**: + | Displays the amounts split by the hierarchy levels of financial + accounts. + + - | **Filter Accounts**: + | When used independently (without grouping by analytic accounts + or showing hierarchy), the results will be split by both + financial accounts. + | **Example**: Filtering by accounts *Test 1* and *Test 2*: + + .. code:: text + + | Initial Balance | Test 1 | Test 2 | Ending Balance + 400000 | 0 | $3600 | $2400 | $6000 + + - | **Show Months** (Excel export only): + | Enabled when filtering accounts without grouping by analytic + accounts or showing hierarchy. It generates a separate sheet in + the Excel file for each filtered account, detailing the amounts + by month within the selected date range. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues <https://github.com/OCA/account-financial-reporting/issues>`_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback <https://github.com/OCA/account-financial-reporting/issues/new?body=module:%20account_analytic_report%0Aversion:%2017.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* APSL-Nagarro + +Contributors +------------ + +- `APSL-Nagarro <https://apsl.tech>`__: + + - Bernat Obrador + - Miquel Alzanillas + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-BernatObrador| image:: https://github.com/BernatObrador.png?size=40px + :target: https://github.com/BernatObrador + :alt: BernatObrador +.. |maintainer-miquelalzanillas| image:: https://github.com/miquelalzanillas.png?size=40px + :target: https://github.com/miquelalzanillas + :alt: miquelalzanillas + +Current `maintainers <https://odoo-community.org/page/maintainer-role>`__: + +|maintainer-BernatObrador| |maintainer-miquelalzanillas| + +This module is part of the `OCA/account-financial-reporting <https://github.com/OCA/account-financial-reporting/tree/17.0/account_analytic_report>`_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_analytic_report/__init__.py b/account_analytic_report/__init__.py new file mode 100644 index 00000000000..c4e388b714a --- /dev/null +++ b/account_analytic_report/__init__.py @@ -0,0 +1,2 @@ +from . import report +from . import wizard diff --git a/account_analytic_report/__manifest__.py b/account_analytic_report/__manifest__.py new file mode 100644 index 00000000000..a0b96320fad --- /dev/null +++ b/account_analytic_report/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Account Analytic Reports", + "version": "17.0.1.0.0", + "summary": "OCA Analytic Reports", + "author": "APSL-Nagarro, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-financial-reporting", + "category": "Account", + "depends": ["analytic", "account_financial_report"], + "maintainers": ["BernatObrador", "miquelalzanillas"], + "data": [ + "security/ir.model.access.csv", + "wizard/trial_balance_analytic_wizard_view.xml", + "menuitems.xml", + "reports.xml", + "report/templates/trial_balance_analytic.xml", + "views/report_trial_balance_analytic.xml", + "views/account_analytic_line.xml", + ], + "assets": { + "web.assets_backend": [ + "account_analytic_report/static/src/js/*", + ], + }, + "application": False, + "installable": True, + "auto_install": False, + "license": "AGPL-3", +} diff --git a/account_analytic_report/i18n/es.po b/account_analytic_report/i18n/es.po new file mode 100644 index 00000000000..e46cb22cdb4 --- /dev/null +++ b/account_analytic_report/i18n/es.po @@ -0,0 +1,410 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_analytic_report +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-11-14 07:15+0000\n" +"PO-Revision-Date: 2024-11-14 07:15+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_analytic_report +#: model:ir.model.fields,help:account_analytic_report.field_ac_trial_balance_report_wizard__show_months +msgid "" +"\n" +" This option works only when exporting to Excel. It will create a separate sheet\n" +" for each selected analytic account, displaying all financial accounts with a\n" +" balance.\n" +" For each account, it shows the monthly balance within the selected date range.\n" +" " +msgstr "" +"Esta opción funciona solo al exportar a Excel. Creará una hoja separada para cada cuenta analítica seleccionada, mostrando todas las cuentas financieras con un saldo.\n" +"Para cada cuenta, muestra el saldo mensual dentro del rango de fechas seleccionado." + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_lines_header +#, python-format +msgid "Account" +msgstr "Cuenta" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "Account at 0 filter" +msgstr "Filtro de cuentas a 0" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +msgid "All accounts" +msgstr "Todas las cuentas" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model:ir.actions.act_window,name:account_analytic_report.action_analytic_trial_balance_wizard +#: model:ir.ui.menu,name:account_analytic_report.menu_analytic_trial_balance +#, python-format +msgid "Analytic Trial Balance" +msgstr "Balance Analítico" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_base +msgid "Analytic Trial Balance -" +msgstr "Balance Analítico -" + +#. module: account_analytic_report +#: model:ir.model,name:account_analytic_report.model_ac_trial_balance_report_wizard +msgid "Analytic Trial Balance Report Wizard" +msgstr "Asistente de balance analítico" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.analytic_trial_balance_wizard +msgid "Cancel" +msgstr "Cancelar" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_lines_header +#, python-format +msgid "Code" +msgstr "Código" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__company_id +msgid "Company" +msgstr "Compañía" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__date_from +msgid "Date From" +msgstr "Desde" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__date_to +msgid "Date To" +msgstr "Hasta" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__date_range_id +msgid "Date range" +msgstr "Periodo" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "Date range filter" +msgstr "Filtro de fechas" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_lines_header +#, python-format +msgid "Ending balance" +msgstr "Saldo Final" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.analytic_trial_balance_wizard +msgid "Export PDF" +msgstr "Exportar PDF" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.analytic_trial_balance_wizard +msgid "Export XLSX" +msgstr "Exportar XLSX" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__account_ids +msgid "Filter accounts" +msgstr "Filtrar cuentas" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +msgid "From:" +msgstr "Desde:" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#, python-format +msgid "From: %(date_from)s To: %(date_to)s" +msgstr "" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__fy_start_date +msgid "Fy Start Date" +msgstr "Fecha inicio ejercicio fiscal" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__group_by_analytic_account +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +msgid "Group by Analytic Account" +msgstr "Agrupar por cuenta analítica" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#, python-format +msgid "Grouped by analytic account" +msgstr "" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "Hide" +msgstr "Ocultar" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__hide_account_at_0 +msgid "Hide accounts at 0" +msgstr "Ocultar cuentas a 0" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__hierarchy_level +msgid "Hierarchy Level" +msgstr "Nivel de Jerarquía\n" + +#. module: account_analytic_report +#: model:ir.model.fields,help:account_analytic_report.field_ac_trial_balance_report_wizard__hierarchy_level +msgid "Hierarchy levels to show" +msgstr "Nivel de Jerarquía a mostrar\n" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__id +msgid "ID" +msgstr "" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_lines_header +#, python-format +msgid "Initial balance" +msgstr "Saldo Inicial" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +msgid "Level" +msgstr "Nivel" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#, python-format +msgid "Level %s" +msgstr "" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__limit_hierarchy_level +msgid "Limit Hierarchy Level" +msgstr "Limitar niveles de jerarquía" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "Limit hierarchy levels" +msgstr "Limitar niveles de jerarquía" + +#. module: account_analytic_report +#: model:ir.model.fields,help:account_analytic_report.field_ac_trial_balance_report_wizard__limit_hierarchy_level +msgid "Limits hierarchy level" +msgstr "Límites de niveles de jerarquía" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "No" +msgstr "" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "No limit" +msgstr "Sin Nivel" + +#. module: account_analytic_report +#: model:ir.ui.menu,name:account_analytic_report.menu_oca_analytic_reports +msgid "OCA Analytic reports" +msgstr "Reportes Analíticos OCA" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_lines_header +#, python-format +msgid "Period balance" +msgstr "Saldo Periodo" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__plan_id +msgid "Plan" +msgstr "Plan Contable" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#, python-format +msgid "Selected Plan" +msgstr "" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "Show" +msgstr "Mostrar" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__show_hierarchy +msgid "Show Hierarchy" +msgstr "Mostrar jerarquía" + +#. module: account_analytic_report +#: model:ir.model.fields,field_description:account_analytic_report.field_ac_trial_balance_report_wizard__show_months +msgid "Show Months" +msgstr "Mostrar meses" + +#. module: account_analytic_report +#: model:ir.model.fields,help:account_analytic_report.field_ac_trial_balance_report_wizard__show_hierarchy +msgid "Shows hierarchy of the financial accounts" +msgstr "Mostrar jerarquía de cuentas financieras" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +msgid "Target Plan" +msgstr "Plan analítico" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +msgid "Target accounts filter" +msgstr "Filtro de cuentas" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/wizard/trial_balance_analytic_wizard_view.py:0 +#, python-format +msgid "" +"The Company in the Trial Balance Report Wizard and in Date Range must be the" +" same." +msgstr "" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/wizard/trial_balance_analytic_wizard_view.py:0 +#, python-format +msgid "The hierarchy level to filter on must be greater than 0." +msgstr "" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +msgid "To" +msgstr "Hasta:" + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#, python-format +msgid "Total" +msgstr "" + +#. module: account_analytic_report +#: model:ir.actions.report,name:account_analytic_report.action_report_analytic_trial_balance_html +#: model:ir.actions.report,name:account_analytic_report.action_report_analytic_trial_balance_qweb +msgid "Trial Analytic Balance" +msgstr "Balance Analítico" + +#. module: account_analytic_report +#: model:ir.model,name:account_analytic_report.model_report_account_analytic_report_trial_balance_analytic +msgid "Trial Balance Analytic Report" +msgstr "Informe de balance analítico" + +#. module: account_analytic_report +#: model:ir.actions.report,name:account_analytic_report.action_report_analytic_trial_balance_xlsx +msgid "Trial Balance XLSX" +msgstr "Balance Analítico XLSX" + +#. module: account_analytic_report +#: model:ir.model,name:account_analytic_report.model_report_a_f_r_report_trial_balance_analytic_xlsx +msgid "Trial Balance XLSX Report" +msgstr "Informe XLSX de balance analítico" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.analytic_trial_balance_wizard +msgid "View" +msgstr "Ver" + +#. module: account_analytic_report +#: model:ir.model.fields,help:account_analytic_report.field_ac_trial_balance_report_wizard__hide_account_at_0 +msgid "" +"When this option is enabled, the trial balance will not display accounts " +"that have initial balance = debit = credit = end balance = 0" +msgstr "" +"Cuando esta opción está habilitada, el balance de comprobación no mostrará " +"cuentas cuyo saldo inicial = débito = crédito = saldo final = 0." + +#. module: account_analytic_report +#. odoo-python +#: code:addons/account_analytic_report/report/trial_balance_analytic_xlsx.py:0 +#: model_terms:ir.ui.view,arch_db:account_analytic_report.report_trial_balance_filters +#, python-format +msgid "Yes" +msgstr "Si" + +#. module: account_analytic_report +#: model_terms:ir.ui.view,arch_db:account_analytic_report.analytic_trial_balance_wizard +msgid "or" +msgstr "o" diff --git a/account_analytic_report/menuitems.xml b/account_analytic_report/menuitems.xml new file mode 100644 index 00000000000..64f8e6a2f93 --- /dev/null +++ b/account_analytic_report/menuitems.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <menuitem + parent="account.menu_finance_reports" + id="menu_oca_analytic_reports" + name="OCA Analytic reports" + groups="analytic.group_analytic_accounting" + /> + <menuitem + parent="menu_oca_analytic_reports" + action="action_analytic_trial_balance_wizard" + id="menu_analytic_trial_balance" + sequence="10" + /> +</odoo> diff --git a/account_analytic_report/pyproject.toml b/account_analytic_report/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/account_analytic_report/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_analytic_report/readme/CONTRIBUTORS.md b/account_analytic_report/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..4ad78d6af5f --- /dev/null +++ b/account_analytic_report/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [APSL-Nagarro](https://apsl.tech): + - Bernat Obrador + - Miquel Alzanillas diff --git a/account_analytic_report/readme/DESCRIPTION.md b/account_analytic_report/readme/DESCRIPTION.md new file mode 100644 index 00000000000..7f76bb60ec1 --- /dev/null +++ b/account_analytic_report/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module introduces an analytic report that provides an intuitive way to view and analyze analytic balances. It simplifies the process, offering enhanced insights and making it easier to leverage this information effectively. \ No newline at end of file diff --git a/account_analytic_report/readme/USAGE.md b/account_analytic_report/readme/USAGE.md new file mode 100644 index 00000000000..36d21cd620b --- /dev/null +++ b/account_analytic_report/readme/USAGE.md @@ -0,0 +1,25 @@ +Using this module is straightforward. Follow these steps: + +* **Navigate to the Report**: + Go to **Invoicing** -> **Reporting** -> **Analytic Trial Balance**. + +* **Customize the Report with Filters**: + Adjust the report using the available options: + + * **Group by Analytic Account**: + Groups the results by analytic accounts instead of financial accounts. + + * **Show Hierarchy and Limit Hierarchy Level**: + Displays the amounts split by the hierarchy levels of financial accounts. + + * **Filter Accounts**: + When used independently (without grouping by analytic accounts or showing hierarchy), the results will be split by both financial accounts. + **Example**: Filtering by accounts *Test 1* and *Test 2*: + + ```text + | Initial Balance | Test 1 | Test 2 | Ending Balance + 400000 | 0 | $3600 | $2400 | $6000 + ``` + + * **Show Months** (Excel export only): + Enabled when filtering accounts without grouping by analytic accounts or showing hierarchy. It generates a separate sheet in the Excel file for each filtered account, detailing the amounts by month within the selected date range. diff --git a/account_analytic_report/report/__init__.py b/account_analytic_report/report/__init__.py new file mode 100644 index 00000000000..db771ac683a --- /dev/null +++ b/account_analytic_report/report/__init__.py @@ -0,0 +1,2 @@ +from . import trial_balance_analytic +from . import trial_balance_analytic_xlsx diff --git a/account_analytic_report/report/templates/trial_balance_analytic.xml b/account_analytic_report/report/templates/trial_balance_analytic.xml new file mode 100644 index 00000000000..d83721f155e --- /dev/null +++ b/account_analytic_report/report/templates/trial_balance_analytic.xml @@ -0,0 +1,492 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <template id="trial_balance_analytic"> + <t t-call="account_financial_report.html_container"> + <t t-foreach="docs" t-as="o"> + <t t-call="account_financial_report.internal_layout"> + <t t-call="account_analytic_report.report_trial_balance_base" /> + </t> + </t> + </t> + </template> + <template id="report_trial_balance_base"> + <!-- Saved flag fields into variables, used to define columns display --> + <t t-set="foreign_currency" t-value="foreign_currency" /> + <t t-set="hierarchy_level" t-value="hierarchy_level" /> + <t t-set="limit_hierarchy_level" t-value="limit_hierarchy_level" /> + <t t-set="account_codes" t-value="account_codes" /> + <t t-set="account_code_list" t-value="account_code_list" /> + <t t-set="show_hierarchy" t-value="show_hierarchy" /> + + <!-- Defines global variables used by internal layout --> + <t t-set="title"> + Analytic Trial Balance - + <t t-out="company_name" /> + - + <t t-out="currency_name" /> + </t> + <t t-set="company_name" t-value="Company_Name" /> + <!-- <t t-set="res_company" t-value="company_id"/> --> + <t class="page"> + <div class="row"> + <h4 + class="mt0" + t-out="title or 'Odoo Report'" + style="text-align: center;" + /> + </div> + <!-- Display filters --> + <t t-call="account_analytic_report.report_trial_balance_filters" /> + <div class="table" style="overflow-x: auto; width: 100%;"> + <div + class="act_as_table list_table" + style="margin-top: 10px; width: max-content;" + /> + <!-- Display account lines --> + <div class="act_as_table data_table" style="width: 100%;"> + <!-- Display account header --> + <t t-call="account_analytic_report.report_trial_balance_lines_header" /> + <!-- Display each lines --> + <t t-foreach="trial_balance" t-as="balance"> + <!-- Adapt --> + <t t-set="style" t-value="'font-size:12px;'" /> + <!-- Different style for account group --> + <t t-if="show_hierarchy"> + <t t-if="balance['type'] == 'group_type'"> + <t + t-set="style" + t-value="style + 'font-weight: bold; color: blue; vertical-align: middle;'" + /> + </t> + </t> + <t t-if="limit_hierarchy_level and hierarchy_level"> + <t t-if="hierarchy_level > balance['level']"> + <t + t-call="account_analytic_report.report_trial_balance_line" + /> + </t> + </t> + <t t-else=""> + <t t-call="account_analytic_report.report_trial_balance_line" /> + </t> + </t> + <!-- Show total amounts by account type --> + <t + t-call="account_analytic_report.report_trial_analytic_balance_total_by_acc_type" + /> + + <!-- Show total amounts --> + <t + t-call="account_analytic_report.report_trial_analytic_balance_totals" + /> + </div> + </div> + </t> + </template> + <template id="report_trial_balance_filters"> + <div class="act_as_table data_table" style="width: 100%;"> + <div class="act_as_row labels"> + <div class="act_as_cell">Date range filter</div> + <div class="act_as_cell">Target accounts filter</div> + <div class="act_as_cell">Limit hierarchy levels</div> + <div class="act_as_cell">Target Plan</div> + <div class="act_as_cell">Group by Analytic Account</div> + </div> + <div class="act_as_row"> + <div class="act_as_cell"> + From: + <span t-out="date_from" t-options="{'widget': 'date'}" /> + To + <span t-out="date_to" t-options="{'widget': 'date'}" /> + </div> + <div class="act_as_cell"> + <t t-if="account_codes"> + <span t-out="account_codes" /> + </t> + <t t-else=""> + All accounts + </t> + </div> + <div class="act_as_cell"> + <t t-if="limit_hierarchy_level"> + Level + <span t-out="hierarchy_level" /> + </t> + <t t-if="not limit_hierarchy_level">No limit</t> + </div> + <div class="act_as_cell"> + <span t-out="plan_name" /> + </div> + <div class="act_as_cell"> + <t t-if="group_by_analytic_account"> Yes </t> + <t t-if="not group_by_analytic_account"> No </t> + </div> + </div> + </div> + </template> + <template id="report_trial_balance_lines_header"> + <!-- Display table headers for lines --> + <div class="act_as_thead"> + <div class="act_as_row labels"> + <!--## Code--> + <div class="act_as_cell" style="width: 8%;">Code</div> + <!--## Account--> + <div class="act_as_cell" style="width: 25%;">Account</div> + <!--## Initial balance--> + <div class="act_as_cell" style="width: 9%;"> + Initial balance + </div> + <t + t-if="not account_ids or group_by_analytic_account or show_hierarchy" + > + <!--## Period balance--> + <div class="act_as_cell" style="width: 9%;">Period balance</div> + </t> + <t t-else=""> + <t t-foreach="account_code_list" t-as="code"> + <div + class="act_as_cell" + style="min-width: 15%; flex-shrink: 0;" + > + <span t-out="code" /> + </div> + </t> + </t> + <!--## Ending balance--> + <div class="act_as_cell" style="width: 9%;">Ending balance</div> + </div> + </div> + </template> + <template id="report_trial_balance_line"> + + <t t-if="not group_by_analytic_account"> + <t t-set="account_id_field" t-value="'general_account_id'" /> + <t t-set="res_model" t-value="'account.account'" /> + </t> + <t t-else=""> + <t t-set="res_model" t-value="'account.analytic.account'" /> + <t t-set="account_id_field" t-value="plan_field" /> + </t> + + <t + t-set="base_domain" + t-value="[(plan_field, '!=', False), + ('company_id', '=', res_company.id), + (plan_field, 'not in', archived_accounts)]" + /> + <!-- # line --> + <div class="act_as_row lines"> + <!--## Code--> + <t t-if="balance['type'] == 'account_type'"> + <div class="act_as_cell left" t-att-style="style"> + <span + t-att-res-id="balance['id']" + t-att-res-model="res_model" + view-type="form" + > + <t t-out="balance['code']" /> + </span> + </div> + <!-- ## Account --> + <div class="act_as_cell left" t-att-style="style"> + <span + t-att-res-id="balance['id']" + t-att-res-model="res_model" + view-type="form" + > + <t t-out="balance['name']" /> + </span> + </div> + </t> + <t t-if="balance['type'] == 'group_type'"> + <div class="act_as_cell left" t-att-style="style"> + <t t-set="res_model" t-value="'account.group'" /> + <span + t-att-res-id="balance['id']" + res-model="account.group" + view-type="form" + > + <t t-out="balance['code']" /> + </span> + </div> + <div class="act_as_cell left" t-att-style="style"> + <t t-set="res_model" t-value="'account.group'" /> + <span + t-att-res-id="balance['id']" + res-model="account.group" + view-type="form" + > + <t t-out="balance['name']" /> + </span> + </div> + </t> + <!--## Initial balance--> + <div class="act_as_cell amount" t-att-style="style"> + <t t-if="balance['type'] == 'account_type'"> + <t + t-set="domain" + t-value="[ + (account_id_field, '=', balance['id']), + ('date', '<', date_from), + ]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + view-type="tree" + id="initial-balance" + > + <t + t-out="balance['initial_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + <t t-if="balance['type'] == 'group_type'"> + <t + t-set="domain" + t-value="[(account_id_field, 'in', balance['account_ids']), + ('date', '<', date_from)]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + id="initial-balance-group" + > + <t + t-out="balance['initial_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + </div> + <t t-if="not account_ids or group_by_analytic_account or show_hierarchy"> + <!-- <!–## Period balance–>--> + <div class="act_as_cell amount" t-att-style="style"> + <t t-if="balance['type'] == 'account_type'"> + <t + t-set="domain" + t-value="[(account_id_field, '=', balance['id']), + ('date', '>=', date_from), + ('date', '<=', date_to), + ('amount', '<>', 0)]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + id="period-balance" + > + <t + t-out="balance['balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + <t t-if="balance['type'] == 'group_type'"> + <t + t-set="domain" + t-value="[(account_id_field, 'in', balance['account_ids']), + ('date', '>=', date_from), + ('date', '<=', date_to)]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + id="period-balance-group" + > + <t + t-out="balance['balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + </div> + </t> + <t t-else=""> + <!-- Displays period balance splited by accounts --> + <t t-foreach="balance['accounts'].items()" t-as="account"> + <div class="act_as_cell amount" t-att-style="style"> + <t t-if="balance['type'] == 'account_type'"> + <t + t-set="domain" + t-value="[(plan_field, '=', account[0]), + ('general_account_id', '=', balance['id']), + ('date', '>=', date_from), + ('date', '<=', date_to), + ('amount', '<>', 0)]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + id="period-balance" + > + <t + t-out="account[1]" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + <t t-if="balance['type'] == 'group_type'"> + <t + t-set="domain" + t-value="[(account_id_field, 'in', balance['account_ids']), + ('date', '>=', date_from), + ('date', '<=', date_to)]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + id="period-balance-group" + > + <t + t-out="balance['balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + </div> + </t> + </t> + <!-- <!–## Ending balance–>--> + <div class="act_as_cell amount" t-att-style="style"> + <t t-if="balance['type'] == 'account_type'"> + <t + t-set="domain" + t-value="[(account_id_field, '=', balance['id']), + ('date', '<=', date_to)]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + id="ending-balance" + > + <t + t-out="balance['ending_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + <t t-if="balance['type'] == 'group_type'"> + <t + t-set="domain" + t-value="[(account_id_field, 'in', balance['account_ids'])]" + /> + <span + t-att-domain="domain+base_domain" + res-model="account.analytic.line" + id="ending-balance-group" + > + <t + t-out="balance['ending_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </span> + </t> + </div> + </div> + </template> + + <template id="report_trial_analytic_balance_total_by_acc_type"> + <t t-foreach="totals_by_acc_type.items()" t-as="total"> + <div + class="act_as_row total_by_account_type" + style="background-color: #f5f5f5; border-top: 2px solid #ccc; margin-top: 10px; padding: 5px;" + > + <div class="act_as_cell" /> + <div class="act_as_cell left" style="font-weight: bold; color: #333;"> + <t t-out="total[0]" /> + </div> + <div class="act_as_cell amount" style="font-weight: bold; color: #555;"> + <t + t-out="total[1]['total_initial_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + <t + t-if="not account_ids or group_by_analytic_account or show_hierarchy" + > + <div + class="act_as_cell amount" + style="font-weight: bold; color: #555;" + > + <t + t-out="total[1]['total_period_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + </t> + <t t-else=""> + <t + t-foreach="total[1]['total_period_balance'].items()" + t-as="total_by_acc" + > + <div + class="act_as_cell amount" + style="font-weight: bold; color: #555;" + > + <t + t-out="total_by_acc[1]" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + </t> + </t> + <div class="act_as_cell amount" style="font-weight: bold; color: #555;"> + <t + t-out="total[1]['total_ending_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + </div> + </t> + </template> + + <template id="report_trial_analytic_balance_totals"> + <div + class="act_as_row total" + style="background-color: #e6e6e6; border-top: 3px solid #333; margin-top: 15px; padding: 10px;" + > + <div class="act_as_cell" /> + <div + class="act_as_cell left" + style="font-weight: bold; color: #000;" + >Total</div> + <div class="act_as_cell amount" style="font-weight: bold; color: #444;"> + <t + t-out="total_amounts['total_initial_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + <t t-if="not account_ids or group_by_analytic_account or show_hierarchy"> + <div class="act_as_cell amount" style="font-weight: bold; color: #444;"> + <t + t-out="total_amounts['total_period_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + </t> + <t t-else=""> + <t + t-foreach="total_amounts['total_period_balance'].items()" + t-as="amount" + > + <div + class="act_as_cell amount" + style="font-weight: bold; color: #444;" + > + <t + t-out="amount[1]" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + </t> + </t> + <div class="act_as_cell amount" style="font-weight: bold; color: #444;"> + <t + t-out="total_amounts['total_ending_balance']" + t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}" + /> + </div> + </div> + </template> +</odoo> diff --git a/account_analytic_report/report/trial_balance_analytic.py b/account_analytic_report/report/trial_balance_analytic.py new file mode 100644 index 00000000000..fa14f131f7c --- /dev/null +++ b/account_analytic_report/report/trial_balance_analytic.py @@ -0,0 +1,837 @@ +# Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import api, models +from odoo.tools.float_utils import float_is_zero + + +class TrialBalanceAnalyticReport(models.AbstractModel): + _name = "report.account_analytic_report.trial_balance_analytic" + _description = "Trial Balance Analytic Report" + _inherit = "report.account_financial_report.abstract_report" + + def _get_accounts_data(self, accounts_ids, group_by_field): + if group_by_field == "general_account_id": + accounts = self.env["account.account"].search([("id", "in", accounts_ids)]) + else: + accounts = self.env["account.analytic.account"].search( + [("id", "in", accounts_ids)] + ) + accounts_data = {} + for account in accounts: + accounts_data.update( + { + account.id: { + "id": account.id, + "name": account.name, + "code": account.code if account.code else account.name, + } + } + ) + return accounts_data + + def _get_base_domain( + self, account_ids, company_id, account_id_field, plan_id, group_by_field + ): + accounts_domain = [ + ("company_id", "=", company_id), + ("root_plan_id", "=", plan_id), + ] + if account_ids: + accounts_domain += [("id", "in", account_ids)] + accounts = self.env["account.analytic.account"].search(accounts_domain) + + domain = [ + (account_id_field, "in", accounts.ids), + (account_id_field, "!=", False), + (group_by_field, "!=", False), + ] + if company_id: + domain += [("company_id", "=", company_id)] + return domain + + def _get_initial_balances_bs_ml_domain(self, domain, date_from, fy_start_date): + bs_ml_domain = domain + [ + ("date", "<", date_from), + ("date", ">=", fy_start_date), + ] + return bs_ml_domain + + @api.model + def _get_period_ml_domain( + self, + domain, + date_to, + date_from, + ): + ml_domain = domain + [ + ("date", ">=", date_from), + ("date", "<=", date_to), + ] + return ml_domain + + @api.model + def _compute_account_amount( + self, + total_amount, + tb_initial_acc, + tb_period_acc, + group_by_field, + account_id_field=None, + account_ids=None, + ): + """ + Prepares the total amount dict with inital balance, period balance and + ending balance. + If account_ids is not null and we are not grouping by analytic account + it will split the ammount in the analytic account and financial account + """ + for tb in tb_period_acc: + if tb[group_by_field]: + self._prepare_amounts( + tb, group_by_field, total_amount, account_id_field, account_ids + ) + for tb in tb_initial_acc: + id_field = group_by_field if account_ids else "account_id" + acc_id = tb[id_field] + if acc_id not in total_amount.keys(): + total_amount[acc_id] = self._prepare_total_amount(tb, account_ids) + else: + total_amount[acc_id]["initial_balance"] = tb["amount"] + total_amount[acc_id]["ending_balance"] += tb["amount"] + return total_amount + + def _prepare_amounts( + self, tb, group_by_field, total_amount, account_id_field, account_ids=None + ): + if account_ids: + acc_id = tb[group_by_field][0] + if acc_id not in total_amount.keys(): + total_amount[acc_id] = self._prepare_total_amount(tb, account_ids) + total_amount[acc_id][tb[account_id_field][0]] = tb["amount"] + total_amount[acc_id]["initial_balance"] = 0.0 + else: + total_amount[acc_id][tb[account_id_field][0]] = tb["amount"] + total_amount[acc_id]["ending_balance"] += tb["amount"] + total_amount[acc_id]["initial_balance"] = 0.0 + else: + acc_id = tb[group_by_field][0] + total_amount[acc_id] = self._prepare_total_amount(tb) + total_amount[acc_id]["amount"] = tb["amount"] + total_amount[acc_id]["initial_balance"] = 0.0 + + @api.model + def _prepare_total_amount(self, tb, account_ids=None): + res = { + "amount": 0.0, + "initial_balance": tb["amount"], + "ending_balance": tb["amount"], + } + if account_ids: + for account in account_ids: + res[account] = 0.0 + + return res + + def _remove_accounts_at_cero(self, total_amount, company): + def is_removable(d): + rounding = company.currency_id.rounding + return float_is_zero( + d["initial_balance"], precision_rounding=rounding + ) and float_is_zero(d["ending_balance"], precision_rounding=rounding) + + accounts_to_remove = [] + for acc_id, ta_data in total_amount.items(): + if is_removable(ta_data): + accounts_to_remove.append(acc_id) + for account_id in accounts_to_remove: + del total_amount[account_id] + + def _get_hierarchy_groups(self, group_ids, groups_data): + for group_id in group_ids: + parent_id = groups_data[group_id]["parent_id"] + while parent_id: + if parent_id not in groups_data.keys(): + group = self.env["account.group"].browse(parent_id) + groups_data[group.id] = { + "id": group.id, + "code": group.code_prefix_start, + "name": group.name, + "parent_id": group.parent_id.id, + "parent_path": group.parent_path, + "complete_code": group.complete_code, + "account_ids": group.compute_account_ids.ids, + "type": "group_type", + "initial_balance": 0, + "balance": 0, + "ending_balance": 0, + } + acc_keys = ["balance"] + acc_keys += ["initial_balance", "ending_balance"] + for acc_key in acc_keys: + groups_data[parent_id][acc_key] += groups_data[group_id][acc_key] + parent_id = groups_data[parent_id]["parent_id"] + return groups_data + + def _get_groups_data(self, accounts_data, total_amount): + accounts_ids = list(accounts_data.keys()) + accounts = self.env["account.account"].browse(accounts_ids) + account_group_relation = {} + for account in accounts: + accounts_data[account.id]["complete_code"] = ( + account.group_id.complete_code + " / " + account.code + if account.group_id.id + else "" + ) + if account.group_id.id: + if account.group_id.id not in account_group_relation.keys(): + account_group_relation.update({account.group_id.id: [account.id]}) + else: + account_group_relation[account.group_id.id].append(account.id) + groups = self.env["account.group"].browse(account_group_relation.keys()) + groups_data = {} + for group in groups: + groups_data.update( + { + group.id: { + "id": group.id, + "code": group.code_prefix_start, + "name": group.name, + "parent_id": group.parent_id.id, + "parent_path": group.parent_path, + "type": "group_type", + "complete_code": group.complete_code, + "account_ids": group.compute_account_ids.ids, + "initial_balance": 0.0, + "balance": 0.0, + "ending_balance": 0.0, + } + } + ) + for group_id in account_group_relation.keys(): + for account_id in account_group_relation[group_id]: + groups_data[group_id]["initial_balance"] += total_amount[account_id][ + "initial_balance" + ] + groups_data[group_id]["balance"] += total_amount[account_id]["amount"] + groups_data[group_id]["ending_balance"] += total_amount[account_id][ + "ending_balance" + ] + group_ids = list(groups_data.keys()) + groups_data = self._get_hierarchy_groups( + group_ids, + groups_data, + ) + return groups_data + + def _hide_accounts_at_0(self, company_id, total_amount): + company = self.env["res.company"].browse(company_id) + self._remove_accounts_at_cero(total_amount, company) + + def _get_tb_initial_acc_bs( + self, domain, date_from, fy_start_date, fields, group_by, lazy=True + ): + initial_domain_bs = self._get_initial_balances_bs_ml_domain( + domain, + date_from, + fy_start_date, + ) + return self.env["account.analytic.line"].read_group( + domain=initial_domain_bs, + fields=fields, + groupby=group_by, + lazy=lazy, + ) + + def _get_tb_period_acc( + self, domain, date_to, date_from, fields, group_by, lazy=True + ): + period_domain = self._get_period_ml_domain( + domain, + date_to, + date_from, + ) + return self.env["account.analytic.line"].read_group( + domain=period_domain, fields=fields, groupby=group_by, lazy=lazy + ) + + def _get_account_codes(self, account_ids): + analytic_accounts = self.env["account.analytic.account"].search( + [("id", "in", account_ids)] + ) + account_codes = [ + account.code if account.code else account.name + for account in sorted(analytic_accounts, key=lambda account: account.id) + ] + codes_string = ", ".join(account_codes) + return codes_string + + def _clean_account_codes(self, account_codes): + return ( + [code.strip() for code in account_codes.split(",")] + if account_codes + else None + ) + + def _update_accounts_data( + self, + accounts_data, + total_amount, + total_amounts, + include_both_accounts=False, + account_ids=None, + ): + for account_id in accounts_data.keys(): + accounts_data[account_id].update( + { + "initial_balance": total_amount[account_id]["initial_balance"], + "ending_balance": total_amount[account_id]["ending_balance"], + "type": "account_type", + "code": accounts_data[account_id]["code"], + } + ) + total_amounts["total_initial_balance"] += total_amount[account_id][ + "initial_balance" + ] + total_amounts["total_ending_balance"] += total_amount[account_id][ + "ending_balance" + ] + # If the report requires both account details, add a nested + # structure within each account. So now we can have the amount + # by the analytic account and the financial account + if include_both_accounts: + accounts_data[account_id]["accounts"] = {} + for account in account_ids: + accounts_data[account_id]["accounts"][account] = total_amount[ + account_id + ][account] + if account not in total_amounts["total_period_balance"]: + total_amounts["total_period_balance"][account] = 0 + total_amounts["total_period_balance"][account] += total_amount[ + account_id + ][account] + else: + accounts_data[account_id].update( + {"balance": total_amount[account_id]["amount"]} + ) + total_amounts["total_period_balance"] += total_amount[account_id][ + "amount" + ] + + def _get_trial_balance(self, accounts_data, total_amount, show_hierarchy): + if show_hierarchy: + groups_data = self._get_groups_data(accounts_data, total_amount) + trial_balance = list(groups_data.values()) + list(accounts_data.values()) + trial_balance = sorted(trial_balance, key=lambda k: k["complete_code"]) + for trial in trial_balance: + trial["level"] = trial["complete_code"].count("/") + else: + trial_balance = list(accounts_data.values()) + return trial_balance + + def _get_total_amounts_dict(self, include_both_accounts): + return { + "total_initial_balance": 0, + "total_period_balance": {} if include_both_accounts else 0, + "total_ending_balance": 0, + } + + def _get_archived_account_ids(self, company_id): + return ( + self.env["account.analytic.account"] + .search([("company_id", "=", company_id), ("active", "=", False)]) + .ids + ) + + @api.model + def _get_data_splited_by_accounts( + self, + account_ids, + company_id, + date_to, + date_from, + fy_start_date, + plan_field, + plan_id, + ): + """ + This function gives the report grouped by financial account and + analytic account spliting the ammount by the 2 accounts + """ + domain = self._get_base_domain( + account_ids, company_id, plan_field, plan_id, "general_account_id" + ) + tb_initial_acc_bs = self._get_tb_initial_acc_bs( + domain=domain, + date_from=date_from, + fy_start_date=fy_start_date, + fields=[plan_field, "general_account_id", "amount"], + group_by=["general_account_id", plan_field], + lazy=False, + ) + tb_initial_acc = [] + for line in tb_initial_acc_bs: + tb_initial_acc.append( + { + "general_account_id": line["general_account_id"][0], + plan_field: line[plan_field][0], + "amount": line["amount"], + } + ) + + tb_initial_acc = [p for p in tb_initial_acc if p["amount"] != 0] + + tb_period_acc = self._get_tb_period_acc( + domain=domain, + date_to=date_to, + date_from=date_from, + fields=[plan_field, "general_account_id", "amount"], + group_by=["general_account_id", plan_field], + lazy=False, + ) + + total_amount = {} + total_amount = self._compute_account_amount( + total_amount, + tb_initial_acc, + tb_period_acc, + "general_account_id", + plan_field, + account_ids, + ) + + self._hide_accounts_at_0(company_id, total_amount) + + accounts_ids = list(total_amount.keys()) + accounts_data = self._get_accounts_data(accounts_ids, "general_account_id") + + return total_amount, accounts_data + + @api.model + def _get_data( + self, + account_ids, + company_id, + date_to, + date_from, + fy_start_date, + plan_field, + plan_id, + group_by_analytic_account, + ): + """ + This function gives the report grouped by financial account + """ + group_by_field = ( + plan_field if group_by_analytic_account else "general_account_id" + ) + + domain = self._get_base_domain( + account_ids, company_id, plan_field, plan_id, group_by_field + ) + + accounts_domain = [("company_id", "=", company_id)] + if account_ids: + accounts_domain += [("id", "in", account_ids)] + + if group_by_field == "general_account_id": + accounts = self.env["account.account"].search(accounts_domain) + else: + accounts = self.env["account.analytic.account"].search(accounts_domain) + tb_initial_acc = [] + + for account in accounts: + tb_initial_acc.append({"account_id": account.id, "amount": 0.0}) + + tb_initial_acc_bs = self._get_tb_initial_acc_bs( + domain=domain, + date_from=date_from, + fy_start_date=fy_start_date, + fields=[plan_field, "general_account_id", "amount"], + group_by=[group_by_field], + ) + for account_rg in tb_initial_acc_bs: + element = list( + filter( + lambda acc_dict: acc_dict["account_id"] + == account_rg[group_by_field][0], + tb_initial_acc, + ) + ) + if element: + element[0]["amount"] += account_rg["amount"] + + tb_initial_acc = [p for p in tb_initial_acc if p["amount"] != 0] + + tb_period_acc = self._get_tb_period_acc( + domain=domain, + date_to=date_to, + date_from=date_from, + fields=[plan_field, "general_account_id", "amount"], + group_by=[group_by_field], + ) + + total_amount = {} + total_amount = self._compute_account_amount( + total_amount, tb_initial_acc, tb_period_acc, group_by_field + ) + + self._hide_accounts_at_0(company_id, total_amount) + + accounts_ids = list(total_amount.keys()) + accounts_data = self._get_accounts_data(accounts_ids, group_by_field) + + return total_amount, accounts_data + + def _get_base_total_by_acc_type_select(self, include_both_accounts, plan_field): + if include_both_accounts: + return f""" + SELECT aa.account_type, aal.{plan_field}, sum(amount) + FROM account_analytic_line AS aal + INNER JOIN account_account AS aa ON aa.id = aal.general_account_id + """ + return """ + SELECT aa.account_type, sum(amount) + FROM account_analytic_line AS aal + INNER JOIN account_account AS aa ON aa.id = aal.general_account_id + """ + + def _get_base_total_by_acc_type_where(self, company_id, account_ids, plan_field): + account_ids_where = ( + f"AND aal.{plan_field} in ({','.join(map(str, account_ids))})" + if account_ids + else "" + ) + archives_account_ids = self._get_archived_account_ids(company_id) + acrhived_account_ids_where = ( + f"AND aal.{plan_field} not in ({','.join(map(str, archives_account_ids))})" + if archives_account_ids + else "" + ) + + return f""" + WHERE aal.company_id = {company_id} + {account_ids_where} + {acrhived_account_ids_where} + AND aal.{plan_field} is not null + """ + + def _get_base_total_acc_type_group_by(self, include_both_accounts, plan_field): + if include_both_accounts: + return f""" + GROUP BY aa.account_type, aal.{plan_field} + """ + return """ + GROUP BY aa.account_type + """ + + def _get_account_type_mapping(self): + return dict( + self.env["account.account"].fields_get(allfields=["account_type"])[ + "account_type" + ]["selection"] + ) + + def _map_accounts_type_by_name( + self, results, account_type_mapping, balance_type, include_both_accounts + ): + result_dict = {} + + # If balance type its period we need to make a specific key for the account_ids + # To have the amount splitted by financial account and analytic account + if balance_type == "total_period_balance" and include_both_accounts: + key_format = "{}|{}" + else: + key_format = "{}" + + for result in results: + if len(result) == 3: + account_type, account_id, total = result + elif len(result) == 2: + account_type, total = result + account_id = None + else: + continue + + account_type_name = account_type_mapping.get(account_type, account_type) + + if include_both_accounts and account_id is not None: + key = key_format.format(account_type_name, account_id) + else: + key = key_format.format(account_type_name) + + if key in result_dict: + result_dict[key] += total + else: + result_dict[key] = total + + return result_dict + + def _get_total_initial_by_acc_type( + self, + base_select, + base_where, + base_group_by, + date_from, + fy_start_date, + ): + query = f""" + {base_select} + {base_where} + AND aal.date < %s + AND aal.date >= %s + {base_group_by} + """ + params = [date_from, fy_start_date] + self.env.cr.execute(query, params) + + return self.env.cr.fetchall() + + def _get_total_period_by_acc_type( + self, + base_select, + base_where, + base_group_by, + date_from, + date_to, + ): + query = f""" + {base_select} + {base_where} + AND aal.date >= %s + AND aal.date <= %s + {base_group_by} + """ + params = [date_from, date_to] + self.env.cr.execute(query, params) + + return self.env.cr.fetchall() + + def _update_balance_by_account_type( + self, balance_type, totals_by_acc_type, totals_dict + ): + for acc_type in totals_by_acc_type: + totals_dict[acc_type][balance_type] = totals_by_acc_type[acc_type] + totals_dict[acc_type]["total_ending_balance"] += totals_by_acc_type[ + acc_type + ] + + def _get_totals_by_acc_type( + self, + company_id, + account_ids, + date_from, + date_to, + plan_field, + group_by_analytic_account, + include_both_accounts, + fy_start_date, + ): + """ + This function calculates and returns the totals + for each account type, providing greater analytical + precision for the report. + Period balance will change if the report includes + the analytic accounts too. + ex: + Inital Balance | Period Balance | Ending Balance + Income: 1.250€ 250€ 1.500€ + Epxense: -500€ -125€ -625€ + """ + account_type_mapping = self._get_account_type_mapping() + base_select = self._get_base_total_by_acc_type_select( + include_both_accounts, plan_field + ) + base_where = self._get_base_total_by_acc_type_where( + company_id, account_ids, plan_field + ) + base_group_by = self._get_base_total_acc_type_group_by( + include_both_accounts, plan_field + ) + + account_types_total_dict = { + account_type_name: self._get_total_amounts_dict(include_both_accounts) + for _account_type, account_type_name in account_type_mapping.items() + } + for _account_type, balances in account_types_total_dict.items(): + for account_id in account_ids: + # Si tenemos que incluir los dos tipos de cuentas entonces + # Debemos crear un subapartado por cada cuenta + if include_both_accounts: + if account_id not in balances["total_period_balance"]: + balances["total_period_balance"][account_id] = 0 + else: + balances["total_period_balance"] = 0 + + total_initial_by_acc_type = self._get_total_initial_by_acc_type( + base_select, base_where, base_group_by, date_from, fy_start_date + ) + + total_period_by_acc_type = self._get_total_period_by_acc_type( + base_select, + base_where, + base_group_by, + date_from, + date_to, + ) + + total_initial_by_acc_type = self._map_accounts_type_by_name( + total_initial_by_acc_type, + account_type_mapping, + "total_initial_balance", + include_both_accounts, + ) + total_period_by_acc_type = self._map_accounts_type_by_name( + total_period_by_acc_type, + account_type_mapping, + "total_period_balance", + include_both_accounts, + ) + + if include_both_accounts: + for key, value in total_period_by_acc_type.items(): + account_type, account_id = key.split("|") + account_id = int(account_id) + if ( + account_id + not in account_types_total_dict[account_type][ + "total_period_balance" + ].keys() + ): + account_types_total_dict[account_type]["total_period_balance"][ + account_id + ] = 0 + account_types_total_dict[account_type]["total_period_balance"][ + account_id + ] += value + account_types_total_dict[account_type]["total_ending_balance"] += value + else: + self._update_balance_by_account_type( + "total_period_balance", + total_period_by_acc_type, + account_types_total_dict, + ) + + self._update_balance_by_account_type( + "total_initial_balance", total_initial_by_acc_type, account_types_total_dict + ) + + # Deletes account types with 0 amounts + filtered_account_types_total_dict = { + account_type_name: balances + for account_type_name, balances in account_types_total_dict.items() + if balances["total_ending_balance"] + } + + return filtered_account_types_total_dict + + def _get_report_values(self, docids, data): + wizard_id = data["wizard_id"] + company = self.env["res.company"].browse(data["company_id"]) + + account_codes = self._get_account_codes(data["account_ids"]) + account_code_list = self._clean_account_codes(account_codes) + + if ( + data["account_ids"] + and not data["group_by_analytic_account"] + and not data["show_hierarchy"] + ): + total_amount, accounts_data = self._get_data_splited_by_accounts( + data["account_ids"], + data["company_id"], + data["date_to"], + data["date_from"], + data["fy_start_date"], + data["plan_field"], + data["plan_id"], + ) + include_both_accounts = True + else: + total_amount, accounts_data = self._get_data( + data["account_ids"], + data["company_id"], + data["date_to"], + data["date_from"], + data["fy_start_date"], + data["plan_field"], + data["plan_id"], + data["group_by_analytic_account"], + ) + include_both_accounts = False + + totals_by_acc_type = self._get_totals_by_acc_type( + data["company_id"], + data["account_ids"], + data["date_from"], + data["date_to"], + data["plan_field"], + data["group_by_analytic_account"], + include_both_accounts, + data["fy_start_date"], + ) + + total_amounts = self._get_total_amounts_dict(include_both_accounts) + self._update_accounts_data( + accounts_data, + total_amount, + total_amounts, + include_both_accounts=include_both_accounts, + account_ids=data["account_ids"], + ) + trial_balance = self._get_trial_balance( + accounts_data, total_amount, data["show_hierarchy"] + ) + + return self._prepare_report_values( + wizard_id, + company, + data, + trial_balance, + total_amount, + accounts_data, + account_codes, + account_code_list, + total_amounts, + totals_by_acc_type, + ) + + def _prepare_report_values( + self, + wizard_id, + company, + data, + trial_balance, + total_amount, + accounts_data, + account_codes, + account_code_list, + total_amounts, + totals_by_acc_type, + ): + return { + "doc_ids": [wizard_id], + "doc_model": "ac.trial.balance.report.wizard", + "docs": self.env["ac.trial.balance.report.wizard"].browse(wizard_id), + "company_name": company.display_name, + "currency_name": company.currency_id.name, + "date_from": data["date_from"], + "date_to": data["date_to"], + "trial_balance": trial_balance, + "total_amount": total_amount, + "accounts_data": accounts_data, + "plan_name": data["plan_name"], + "plan_field": data["plan_field"], + "group_by_analytic_account": data["group_by_analytic_account"], + "show_hierarchy": data["show_hierarchy"], + "limit_hierarchy_level": data["limit_hierarchy_level"], + "show_hierarchy_level": data["hierarchy_level"], + "account_codes": account_codes, + "account_code_list": account_code_list, + "account_ids": data["account_ids"], + "show_months": data["show_months"], + "total_amounts": total_amounts, + "archived_accounts": tuple(self._get_archived_account_ids(company.id)), + "totals_by_acc_type": totals_by_acc_type, + } diff --git a/account_analytic_report/report/trial_balance_analytic_xlsx.py b/account_analytic_report/report/trial_balance_analytic_xlsx.py new file mode 100644 index 00000000000..4f01bc44092 --- /dev/null +++ b/account_analytic_report/report/trial_balance_analytic_xlsx.py @@ -0,0 +1,643 @@ +# Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import _, models + + +class TrialBalanceXslx(models.AbstractModel): + _name = "report.a_f_r.report_trial_balance_analytic_xlsx" + _description = "Trial Balance XLSX Report" + _inherit = "report.account_financial_report.abstract_report_xlsx" + + def _get_report_name(self, report, data=False): + company_id = data.get("company_id", False) + account_code = data.get("account_code", False) + report_name = _("Analytic Trial Balance") + if company_id: + company = self.env["res.company"].browse(company_id) + suffix = f" - {company.name} - {company.currency_id.name}" + report_name = report_name + suffix + if account_code: + report_name += f" [{account_code}]" + return report_name + + def _define_formats(self, workbook, report_data): + super()._define_formats(workbook, report_data) + currency_id = self.env["res.company"]._default_currency_id() + col_format_totals = { + "bold": True, + "bg_color": "#90cf00", + "border": True, + } + + col_format_totals_by_acc_type = { + "bold": True, + "bg_color": "#D9EBD3", + "border": True, + } + report_data["formats"]["format_total"] = workbook.add_format(col_format_totals) + report_data["formats"]["format_amount_total"] = workbook.add_format( + col_format_totals + ) + report_data["formats"]["format_amount_total"].set_num_format( + "#,##0." + "0" * currency_id.decimal_places + ) + + report_data["formats"]["format_acc_type_total"] = workbook.add_format( + col_format_totals_by_acc_type + ) + report_data["formats"]["format_acc_type_amount_total"] = workbook.add_format( + col_format_totals_by_acc_type + ) + report_data["formats"]["format_acc_type_amount_total"].set_num_format( + "#,##0." + "0" * currency_id.decimal_places + ) + + return True + + def _is_report_with_include_both_accounts(self, report): + return report.account_ids and not report.group_by_analytic_account + + def _get_report_columns(self, report): + if self._is_report_with_include_both_accounts(report): + codes = self.env["account.analytic.account"].search( + [("id", "in", report.account_ids.ids)] + ) + res = { + 0: {"header": _("Code"), "field": "code", "width": 15}, + 1: {"header": _("Account"), "field": "name", "width": 70}, + 2: { + "header": _("Initial balance"), + "field": "initial_balance", + "type": "amount", + "width": 14, + }, + } + for i, account in enumerate(codes): + res[i + 3] = { + "header": account.code, + "id": account.id, + "field": "accounts", + "type": "amount", + "width": 14, + } + + res[len(res)] = { + "header": _("Ending balance"), + "field": "ending_balance", + "type": "amount", + "width": 14, + } + else: + res = { + 0: {"header": _("Code"), "field": "code", "width": 10}, + 1: {"header": _("Account"), "field": "name", "width": 70}, + 2: { + "header": _("Initial balance"), + "field": "initial_balance", + "type": "amount", + "width": 14, + }, + 3: { + "header": _("Period balance"), + "field": "balance", + "type": "amount", + "width": 14, + }, + 4: { + "header": _("Ending balance"), + "field": "ending_balance", + "type": "amount", + "width": 14, + }, + } + return res + + def _get_report_filters(self, report): + report_filters = [ + [ + _("Date range filter"), + _("From: %(date_from)s To: %(date_to)s") + % ({"date_from": report.date_from, "date_to": report.date_to}), + ], + [_("Selected Plan"), report.plan_id.name], + [ + _("Grouped by analytic account"), + _("Yes") if report.group_by_analytic_account else _("No"), + ], + ] + + if report.account_ids: + account_codes = self.env[ + "report.account_analytic_report.trial_balance_analytic" + ]._get_account_codes(report.account_ids.ids) + report_filters.append( + [ + _("Accounts Filter"), + account_codes, + ] + ) + + report_filters.append( + [ + _("Limit hierarchy levels"), + ( + _("Level %s") % (report.hierarchy_level) + if report.limit_hierarchy_level + else _("No limit") + ), + ] + ) + + return report_filters + + def _get_col_count_filter_name(self): + return 2 + + def _get_col_count_filter_value(self): + return 3 + + def _write_trial_analytic(self, report_values, report_data): + for balance in report_values["trial_balance"]: + if ( + report_values["show_hierarchy"] + and report_values["limit_hierarchy_level"] + ): + if report_values["hierarchy_level"] > balance["level"]: + self.write_line_from_dict(balance, report_data) + else: + self.write_line_from_dict(balance, report_data) + + def _generate_report_content(self, workbook, report, data, report_data): + report_values = self._get_values_from_report(report, data) + report_data["account_code_list"] = report_values["account_code_list"] + report_data["group_by_analytic_account"] = report_values[ + "group_by_analytic_account" + ] + report_data["total_amounts"] = report_values["total_amounts"] + self.write_array_header(report_data) + + self._write_trial_analytic(report_values, report_data) + + total_rows_by_account_type = self._prepare_total_rows_by_account_type( + report_values, report + ) + + for row in total_rows_by_account_type: + self._write_line_with_format( + report_data, + row, + report_data["formats"]["format_acc_type_total"], + report_data["formats"]["format_acc_type_amount_total"], + ) + + total_row = self._prepare_total_row(report_values, report) + self._write_line_with_format( + report_data, + total_row, + report_data["formats"]["format_total"], + report_data["formats"]["format_amount_total"], + ) + + if report_values["show_months"]: + self.create_page_by_anlytic_accounts( + workbook, report, report_data, report_values + ) + + def _prepare_total_row(self, report_values, report): + total_row = { + "name": _("Total"), + "initial_balance": report_values["total_amounts"]["total_initial_balance"], + "ending_balance": report_values["total_amounts"]["total_ending_balance"], + } + if self._is_report_with_include_both_accounts(report): + total_row["accounts"] = {} + codes = self.env["account.analytic.account"].search( + [("id", "in", report.account_ids.ids)] + ) + for account in codes: + total_row["accounts"].update( + { + account.id: report_values["total_amounts"][ + "total_period_balance" + ][account.id] + } + ) + else: + total_row["balance"] = report_values["total_amounts"][ + "total_period_balance" + ] + + return total_row + + def _prepare_total_rows_by_account_type(self, report_values, report): + total_rows = [] + for acc_type, balances in report_values["totals_by_acc_type"].items(): + total_row = { + "name": acc_type, + "initial_balance": balances["total_initial_balance"], + "ending_balance": balances["total_ending_balance"], + } + if self._is_report_with_include_both_accounts(report): + total_row["accounts"] = {} + codes = self.env["account.analytic.account"].search( + [("id", "in", report.account_ids.ids)] + ) + for account in codes: + if account.id in balances["total_period_balance"].keys(): + total_row["accounts"].update( + {account.id: balances["total_period_balance"][account.id]} + ) + else: + total_row["balance"] = balances["total_period_balance"] + total_rows.append(total_row) + return total_rows + + def _get_values_from_report(self, report, data): + res_data = self.env[ + "report.account_analytic_report.trial_balance_analytic" + ]._get_report_values(report, data) + return { + "show_hierarchy": res_data["show_hierarchy"], + "hierarchy_level": res_data["show_hierarchy_level"], + "limit_hierarchy_level": res_data["limit_hierarchy_level"], + "account_code_list": res_data["account_code_list"], + "show_months": res_data["show_months"], + "group_by_analytic_account": res_data["group_by_analytic_account"], + "trial_balance": res_data["trial_balance"], + "plan_field": res_data["plan_field"], + "total_amounts": res_data["total_amounts"], + "totals_by_acc_type": res_data["totals_by_acc_type"], + "account_ids": res_data["account_ids"], + } + + def write_line_from_dict(self, line_dict, report_data): + if not ( + report_data["account_code_list"] + and not report_data["group_by_analytic_account"] + ): + return super().write_line_from_dict(line_dict, report_data) + else: + for col_pos, column in report_data["columns"].items(): + value = line_dict.get(column["field"], False) + cell_type = column.get("type", "string") + if cell_type == "string": + if line_dict.get("type", "") == "group_type": + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_bold"], + ) + else: + if ( + not isinstance(value, str) + and not isinstance(value, bool) + and not isinstance(value, int) + ): + value = value and value.strftime("%d/%m/%Y") + report_data["sheet"].write_string( + report_data["row_pos"], col_pos, value or "" + ) + elif cell_type == "amount": + if ( + line_dict.get("account_group_id", False) + and line_dict["account_group_id"] + ): + cell_format = report_data["formats"]["format_amount_bold"] + else: + cell_format = report_data["formats"]["format_amount"] + if column["field"] == "accounts": + value_to_write = value[column["id"]] + report_data["sheet"].write_number( + report_data["row_pos"], + col_pos, + float(value_to_write), + cell_format, + ) + else: + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), cell_format + ) + elif cell_type == "amount_currency": + if line_dict.get("currency_name", False): + format_amt = self._get_currency_amt_format_dict( + line_dict, report_data + ) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), format_amt + ) + elif cell_type == "currency_name": + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_right"], + ) + else: + self.write_non_standard_column(cell_type, col_pos, value) + report_data["row_pos"] += 1 + + def _write_line_with_format(self, report_data, row, str_format, amount_format): + for col_pos, column in report_data["columns"].items(): + value = row.get(column["field"], False) + cell_type = column.get("type", "string") + + if cell_type == "amount": + value = value if value else 0 + cell_format = amount_format + + if column["field"] == "accounts": + value = value[column["id"]] + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), cell_format + ) + elif cell_type == "string": + value = value if value else "" + report_data["sheet"].write_string( + report_data["row_pos"], col_pos, str(value), str_format + ) + report_data["row_pos"] += 1 + + def _prepare_data_for_page(self, report): + date_from = report.date_from.strftime("%Y-%m-%d") + date_to = report.date_to.strftime("%Y-%m-%d") + account_id_field = report.plan_id._column_name() + company_id = report.company_id.id + filters = self._get_report_filters(report) + + return { + "date_from": date_from, + "date_to": date_to, + "account_id_field": account_id_field, + "company_id": company_id, + "filters": filters, + } + + def _create_page_for_account( + self, + workbook, + company_id, + report_data, + account, + filters, + date_from, + date_to, + report_values, + ): + """ + Adds a new worksheet for each account using its code as the sheet name. + """ + sheet = workbook.add_worksheet(account.code) + report_data["sheet"] = sheet + report_data["row_pos"] = 0 + filters[4][1] = account.code + + self._write_report_title( + self._get_report_name( + report_data, {"company_id": company_id, "account_code": account.code} + ), + report_data, + ) + self._write_filters(filters, report_data) + + return sheet + + def _get_report_columns_by_month(self, date_from, date_to, account): + res = { + 0: {"header": _("Code"), "field": "code", "width": 15}, + 1: {"header": _("Account"), "field": "name", "width": 50}, + } + + date_from = datetime.strptime(date_from, "%Y-%m-%d") + date_to = datetime.strptime(date_to, "%Y-%m-%d") + + current_date = date_from + + # Loop through each month between date_from and date_to + while current_date <= date_to: + month_year = current_date.strftime("%m-%Y") + + # Add a new column for this month + res[len(res)] = { + "header": month_year, + "field": f"{current_date.month}-{current_date.year}", + "type": "amount", + "width": 14, + } + + # Move to the next month + current_date += relativedelta(months=1) + + res[len(res)] = { + "header": _("Total"), + "field": "total", + "type": "amount", + "width": 14, + } + return res + + def _get_months_query( + self, company_id, account_id_field, account, date_from, date_to, report_values + ): + return f""" + SELECT "account_analytic_line"."general_account_id", + date_trunc('month', + "account_analytic_line"."date"::timestamp)::date,COUNT(*), + SUM("account_analytic_line"."amount") + FROM "account_analytic_line" + LEFT JOIN "account_account" AS "account_analytic_line__general_account_id" + ON ("account_analytic_line"."general_account_id" = + "account_analytic_line__general_account_id"."id") + LEFT JOIN "res_company" AS + "account_analytic_line__general_account_id__company_id" + ON ("account_analytic_line__general_account_id"."company_id" = + "account_analytic_line__general_account_id__company_id"."id") + WHERE ( + ("account_analytic_line".{account_id_field} = {account.id}) + AND ("account_analytic_line"."company_id" = {company_id}) + AND ("account_analytic_line"."date" >= '{date_from}') + AND ("account_analytic_line"."date" <= '{date_to}') + ) + GROUP BY "account_analytic_line"."general_account_id", + date_trunc('month', "account_analytic_line"."date"::timestamp)::date, + "account_analytic_line__general_account_id"."code", + "account_analytic_line__general_account_id__company_id"."sequence", + "account_analytic_line__general_account_id__company_id"."name" + ORDER BY "account_analytic_line__general_account_id"."code", + "account_analytic_line__general_account_id__company_id"."sequence", + "account_analytic_line__general_account_id__company_id"."name", + date_trunc('month', "account_analytic_line"."date"::timestamp)::date ASC + """ + + def _get_total_acc_type_by_month_query( + self, company_id, account_id_field, account, date_from, date_to + ): + return f""" + SELECT + aa.account_type, + aal.{account_id_field}, + date_trunc('month', aal.date::timestamp)::date AS month, + SUM(amount) AS total_amount + FROM + account_analytic_line AS aal + INNER JOIN + account_account AS aa ON aa.id = aal.general_account_id + WHERE + aal.company_id = {company_id} + AND aal.{account_id_field} IS NOT NULL + AND aal.date >= '{date_from}' + AND aal.date <= '{date_to}' + AND aal.{account_id_field} = {account.id} + GROUP BY + aa.account_type, + aal.{account_id_field}, + date_trunc('month', aal.date::timestamp)::date + ORDER BY + month ASC; + """ + + def _get_total_acc_type_by_month( + self, company_id, account_id_field, account, date_from, date_to + ): + self.env.cr.execute( + self._get_total_acc_type_by_month_query( + company_id, account_id_field, account, date_from, date_to + ) + ) + total_acc_type_by_months = self.env.cr.fetchall() + + # Maps the accounts with his redebale name + account_type_mapping = self.env[ + "report.account_analytic_report.trial_balance_analytic" + ]._get_account_type_mapping() + + for i, acc_type in enumerate(total_acc_type_by_months): + total_acc_type_by_months[i] = ( + account_type_mapping[acc_type[0]], + *acc_type[1:], + ) + + return total_acc_type_by_months + + def _get_total_acc_type_by_months_rows(self, total_acc_type_by_months): + total_acc_type_by_months_rows = {} + for total_acc_type in total_acc_type_by_months: + account_type_name = total_acc_type[0] + month_year = f"{total_acc_type[2].month}-{total_acc_type[2].year}" + amount = total_acc_type[3] + + if account_type_name not in total_acc_type_by_months_rows: + total_acc_type_by_months_rows[account_type_name] = { + "name": account_type_name, + "total": 0, + } + + total_acc_type_by_months_rows[account_type_name][month_year] = amount + total_acc_type_by_months_rows[account_type_name]["total"] += amount + + return total_acc_type_by_months_rows + + def _get_amounts_and_total_by_analytic_account(self, amounts_data_by_month): + amounts_by_month = {} + total_row = {"code": _("Total"), "total": 0} + for amount_data in amounts_data_by_month: + account_account = self.env["account.account"].browse(amount_data[0]) + key = f"{amount_data[1].month}-{amount_data[1].year}" + amount = amount_data[3] + + total_row[key] = total_row.get(key, 0) + amount + total_row["total"] += amount + + if account_account.id not in amounts_by_month: + amounts_by_month[account_account.id] = { + "code": account_account.code, + "name": account_account.name, + "total": 0, + } + + amounts_by_month[account_account.id][key] = amount + amounts_by_month[account_account.id]["total"] += amount + return amounts_by_month, total_row + + def _write_amount_by_month(self, amounts_by_month, report_data): + for amount_by_month in amounts_by_month.values(): + if isinstance(amount_by_month, dict): + self.write_line_from_dict(amount_by_month, report_data) + + def _write_totals_by_acc_type(self, total_acc_type_by_months, report_data): + total_acc_type_month_row = self._get_total_acc_type_by_months_rows( + total_acc_type_by_months + ) + # Writes total by account type + for row in total_acc_type_month_row.values(): + self._write_line_with_format( + report_data, + row, + report_data["formats"]["format_acc_type_total"], + report_data["formats"]["format_acc_type_amount_total"], + ) + + def _write_total_row(self, total_row, report_data): + # Writes total row + self._write_line_with_format( + report_data, + total_row, + report_data["formats"]["format_total"], + report_data["formats"]["format_amount_total"], + ) + + def create_page_by_anlytic_accounts( + self, workbook, report, report_data, report_values + ): + report_data_values = self._prepare_data_for_page(report) + date_from = report_data_values["date_from"] + date_to = report_data_values["date_to"] + account_id_field = report_data_values["account_id_field"] + filters = report_data_values["filters"] + company_id = report_data_values["company_id"] + for account in report.account_ids: + self._create_page_for_account( + workbook, + company_id, + report_data, + account, + filters, + date_from, + date_to, + report_values, + ) + + query = self._get_months_query( + company_id, account_id_field, account, date_from, date_to, report_values + ) + + self.env.cr.execute(query) + + amounts_data_by_month = self.env.cr.fetchall() + + report_data["columns"] = self._get_report_columns_by_month( + date_from, date_to, account + ) + + self.write_array_header(report_data) + self._set_column_width(report_data) + + ( + amounts_by_month, + total_row, + ) = self._get_amounts_and_total_by_analytic_account(amounts_data_by_month) + amounts_by_month.update({"account_id": account.id}) + self._write_amount_by_month(amounts_by_month, report_data) + + total_acc_type_by_months = self._get_total_acc_type_by_month( + company_id, account_id_field, account, date_from, date_to + ) + + self._write_totals_by_acc_type(total_acc_type_by_months, report_data) + + self._write_total_row(total_row, report_data) diff --git a/account_analytic_report/reports.xml b/account_analytic_report/reports.xml new file mode 100644 index 00000000000..df50cda93d6 --- /dev/null +++ b/account_analytic_report/reports.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <!-- Trial Balance --> + <record + id="action_report_analytic_trial_balance_qweb" + model="ir.actions.report" + > + <field name="name">Trial Analytic Balance</field> + <field name="model">ac.trial.balance.report.wizard</field> + <field name="report_type">qweb-pdf</field> + <field + name="report_name" + >account_analytic_report.trial_balance_analytic</field> + <field + name="report_file" + >account_analytic_report.trial_balance_analytic</field> + <field + name="paperformat_id" + ref="account_financial_report.report_qweb_paperformat" + /> + </record> + <record + id="action_report_analytic_trial_balance_html" + model="ir.actions.report" + > + <field name="name">Trial Analytic Balance</field> + <field name="model">ac.trial.balance.report.wizard</field> + <field name="report_type">qweb-html</field> + <field + name="report_name" + >account_analytic_report.trial_balance_analytic</field> + <field + name="report_file" + >account_analytic_report.trial_balance_analytic</field> + </record> + <record + id="action_report_analytic_trial_balance_xlsx" + model="ir.actions.report" + > + <field name="name">Trial Balance XLSX</field> + <field name="model">ac.trial.balance.report.wizard</field> + <field name="type">ir.actions.report</field> + <field name="report_name">a_f_r.report_trial_balance_analytic_xlsx</field> + <field name="report_type">xlsx</field> + <field name="report_file">report_trial_balance_analytic</field> + </record> +</odoo> diff --git a/account_analytic_report/security/ir.model.access.csv b/account_analytic_report/security/ir.model.access.csv new file mode 100644 index 00000000000..e16db37c534 --- /dev/null +++ b/account_analytic_report/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_trial_balance_analytic_report_wizard,access_trial_balance_analytic_report_wizard,model_ac_trial_balance_report_wizard,base.group_user,1,1,1,1 diff --git a/account_analytic_report/static/description/icon.png b/account_analytic_report/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0a917e513df1e26aee5a41b73430e102f591fd07 GIT binary patch literal 36663 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 Y0oraPMwFFf`v3p{07*qoM6N<$f+G9j1poj5 literal 0 HcmV?d00001 diff --git a/account_analytic_report/static/description/index.html b/account_analytic_report/static/description/index.html new file mode 100644 index 00000000000..45f2b3c7a97 --- /dev/null +++ b/account_analytic_report/static/description/index.html @@ -0,0 +1,483 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> +<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" /> +<title>Account Analytic Reports</title> +<style type="text/css"> + +/* +:Author: David Goodger (goodger@python.org) +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ +:Copyright: This stylesheet has been placed in the public domain. + +Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. + +See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to +customize this style sheet. +*/ + +/* used to remove borders from tables and images */ +.borderless, table.borderless td, table.borderless th { + border: 0 } + +table.borderless td, table.borderless th { + /* Override padding for "table.docutils td" with "! important". + The right padding separates the table cells. */ + padding: 0 0.5em 0 0 ! important } + +.first { + /* Override more specific margin styles with "! important". */ + margin-top: 0 ! important } + +.last, .with-subtitle { + margin-bottom: 0 ! important } + +.hidden { + display: none } + +.subscript { + vertical-align: sub; + font-size: smaller } + +.superscript { + vertical-align: super; + font-size: smaller } + +a.toc-backref { + text-decoration: none ; + color: black } + +blockquote.epigraph { + margin: 2em 5em ; } + +dl.docutils dd { + margin-bottom: 0.5em } + +object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] { + overflow: hidden; +} + +/* Uncomment (and remove this text!) to get bold-faced definition list terms +dl.docutils dt { + font-weight: bold } +*/ + +div.abstract { + margin: 2em 5em } + +div.abstract p.topic-title { + font-weight: bold ; + text-align: center } + +div.admonition, div.attention, div.caution, div.danger, div.error, +div.hint, div.important, div.note, div.tip, div.warning { + margin: 2em ; + border: medium outset ; + padding: 1em } + +div.admonition p.admonition-title, div.hint p.admonition-title, +div.important p.admonition-title, div.note p.admonition-title, +div.tip p.admonition-title { + font-weight: bold ; + font-family: sans-serif } + +div.attention p.admonition-title, div.caution p.admonition-title, +div.danger p.admonition-title, div.error p.admonition-title, +div.warning p.admonition-title, .code .error { + color: red ; + font-weight: bold ; + font-family: sans-serif } + +/* Uncomment (and remove this text!) to get reduced vertical space in + compound paragraphs. +div.compound .compound-first, div.compound .compound-middle { + margin-bottom: 0.5em } + +div.compound .compound-last, div.compound .compound-middle { + margin-top: 0.5em } +*/ + +div.dedication { + margin: 2em 5em ; + text-align: center ; + font-style: italic } + +div.dedication p.topic-title { + font-weight: bold ; + font-style: normal } + +div.figure { + margin-left: 2em ; + margin-right: 2em } + +div.footer, div.header { + clear: both; + font-size: smaller } + +div.line-block { + display: block ; + margin-top: 1em ; + margin-bottom: 1em } + +div.line-block div.line-block { + margin-top: 0 ; + margin-bottom: 0 ; + margin-left: 1.5em } + +div.sidebar { + margin: 0 0 0.5em 1em ; + border: medium outset ; + padding: 1em ; + background-color: #ffffee ; + width: 40% ; + float: right ; + clear: right } + +div.sidebar p.rubric { + font-family: sans-serif ; + font-size: medium } + +div.system-messages { + margin: 5em } + +div.system-messages h1 { + color: red } + +div.system-message { + border: medium outset ; + padding: 1em } + +div.system-message p.system-message-title { + color: red ; + font-weight: bold } + +div.topic { + margin: 2em } + +h1.section-subtitle, h2.section-subtitle, h3.section-subtitle, +h4.section-subtitle, h5.section-subtitle, h6.section-subtitle { + margin-top: 0.4em } + +h1.title { + text-align: center } + +h2.subtitle { + text-align: center } + +hr.docutils { + width: 75% } + +img.align-left, .figure.align-left, object.align-left, table.align-left { + clear: left ; + float: left ; + margin-right: 1em } + +img.align-right, .figure.align-right, object.align-right, table.align-right { + clear: right ; + float: right ; + margin-left: 1em } + +img.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left } + +.align-center { + clear: both ; + text-align: center } + +.align-right { + text-align: right } + +/* reset inner alignment in figures */ +div.align-right { + text-align: inherit } + +/* div.align-center * { */ +/* text-align: left } */ + +.align-top { + vertical-align: top } + +.align-middle { + vertical-align: middle } + +.align-bottom { + vertical-align: bottom } + +ol.simple, ul.simple { + margin-bottom: 1em } + +ol.arabic { + list-style: decimal } + +ol.loweralpha { + list-style: lower-alpha } + +ol.upperalpha { + list-style: upper-alpha } + +ol.lowerroman { + list-style: lower-roman } + +ol.upperroman { + list-style: upper-roman } + +p.attribution { + text-align: right ; + margin-left: 50% } + +p.caption { + font-style: italic } + +p.credits { + font-style: italic ; + font-size: smaller } + +p.label { + white-space: nowrap } + +p.rubric { + font-weight: bold ; + font-size: larger ; + color: maroon ; + text-align: center } + +p.sidebar-title { + font-family: sans-serif ; + font-weight: bold ; + font-size: larger } + +p.sidebar-subtitle { + font-family: sans-serif ; + font-weight: bold } + +p.topic-title { + font-weight: bold } + +pre.address { + margin-bottom: 0 ; + margin-top: 0 ; + font: inherit } + +pre.literal-block, pre.doctest-block, pre.math, pre.code { + margin-left: 2em ; + margin-right: 2em } + +pre.code .ln { color: gray; } /* line numbers */ +pre.code, code { background-color: #eeeeee } +pre.code .comment, code .comment { color: #5C6576 } +pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } +pre.code .literal.string, code .literal.string { color: #0C5404 } +pre.code .name.builtin, code .name.builtin { color: #352B84 } +pre.code .deleted, code .deleted { background-color: #DEB0A1} +pre.code .inserted, code .inserted { background-color: #A3D289} + +span.classifier { + font-family: sans-serif ; + font-style: oblique } + +span.classifier-delimiter { + font-family: sans-serif ; + font-weight: bold } + +span.interpreted { + font-family: sans-serif } + +span.option { + white-space: nowrap } + +span.pre { + white-space: pre } + +span.problematic, pre.problematic { + color: red } + +span.section-subtitle { + /* font-size relative to parent (h1..h6 element) */ + font-size: 80% } + +table.citation { + border-left: solid 1px gray; + margin-left: 1px } + +table.docinfo { + margin: 2em 4em } + +table.docutils { + margin-top: 0.5em ; + margin-bottom: 0.5em } + +table.footnote { + border-left: solid 1px black; + margin-left: 1px } + +table.docutils td, table.docutils th, +table.docinfo td, table.docinfo th { + padding-left: 0.5em ; + padding-right: 0.5em ; + vertical-align: top } + +table.docutils th.field-name, table.docinfo th.docinfo-name { + font-weight: bold ; + text-align: left ; + white-space: nowrap ; + padding-left: 0 } + +/* "booktabs" style (no vertical lines) */ +table.docutils.booktabs { + border: 0px; + border-top: 2px solid; + border-bottom: 2px solid; + border-collapse: collapse; +} +table.docutils.booktabs * { + border: 0px; +} +table.docutils.booktabs th { + border-bottom: thin solid; + text-align: left; +} + +h1 tt.docutils, h2 tt.docutils, h3 tt.docutils, +h4 tt.docutils, h5 tt.docutils, h6 tt.docutils { + font-size: 100% } + +ul.auto-toc { + list-style-type: none } + +</style> +</head> +<body> +<div class="document" id="account-analytic-reports"> +<h1 class="title">Account Analytic Reports</h1> + +<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +!! This file is generated by oca-gen-addon-readme !! +!! changes will be overwritten. !! +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +!! source digest: sha256:e3b2f8d263dd282038c6d240451ddf65612a4d8dfbf754af136900aa97285230 +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> +<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/account-financial-reporting/tree/17.0/account_analytic_report"><img alt="OCA/account-financial-reporting" src="https://img.shields.io/badge/github-OCA%2Faccount--financial--reporting-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/account-financial-reporting-17-0/account-financial-reporting-17-0-account_analytic_report"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/account-financial-reporting&target_branch=17.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p> +<p>This module introduces an analytic report that provides an intuitive way +to view and analyze analytic balances. It simplifies the process, +offering enhanced insights and making it easier to leverage this +information effectively.</p> +<p><strong>Table of contents</strong></p> +<div class="contents local topic" id="contents"> +<ul class="simple"> +<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li> +<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li> +<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul> +<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li> +<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li> +<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li> +</ul> +</li> +</ul> +</div> +<div class="section" id="usage"> +<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1> +<p>Using this module is straightforward. Follow these steps:</p> +<ul> +<li><div class="first line-block"> +<div class="line"><strong>Navigate to the Report</strong>:</div> +<div class="line">Go to <strong>Invoicing</strong> -> <strong>Reporting</strong> -> <strong>Analytic Trial Balance</strong>.</div> +</div> +</li> +<li><div class="first line-block"> +<div class="line"><strong>Customize the Report with Filters</strong>:</div> +<div class="line">Adjust the report using the available options:</div> +</div> +<ul> +<li><div class="first line-block"> +<div class="line"><strong>Group by Analytic Account</strong>:</div> +<div class="line">Groups the results by analytic accounts instead of financial +accounts.</div> +</div> +</li> +<li><div class="first line-block"> +<div class="line"><strong>Show Hierarchy and Limit Hierarchy Level</strong>:</div> +<div class="line">Displays the amounts split by the hierarchy levels of financial +accounts.</div> +</div> +</li> +<li><div class="first line-block"> +<div class="line"><strong>Filter Accounts</strong>:</div> +<div class="line">When used independently (without grouping by analytic accounts +or showing hierarchy), the results will be split by both +financial accounts.</div> +<div class="line"><strong>Example</strong>: Filtering by accounts <em>Test 1</em> and <em>Test 2</em>:</div> +</div> +<pre class="code text literal-block"> + | Initial Balance | Test 1 | Test 2 | Ending Balance +400000 | 0 | $3600 | $2400 | $6000 +</pre> +</li> +<li><div class="first line-block"> +<div class="line"><strong>Show Months</strong> (Excel export only):</div> +<div class="line">Enabled when filtering accounts without grouping by analytic +accounts or showing hierarchy. It generates a separate sheet in +the Excel file for each filtered account, detailing the amounts +by month within the selected date range.</div> +</div> +</li> +</ul> +</li> +</ul> +</div> +<div class="section" id="bug-tracker"> +<h1><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h1> +<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/account-financial-reporting/issues">GitHub Issues</a>. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +<a class="reference external" href="https://github.com/OCA/account-financial-reporting/issues/new?body=module:%20account_analytic_report%0Aversion:%2017.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p> +<p>Do not contact contributors directly about support or help with technical issues.</p> +</div> +<div class="section" id="credits"> +<h1><a class="toc-backref" href="#toc-entry-3">Credits</a></h1> +<div class="section" id="authors"> +<h2><a class="toc-backref" href="#toc-entry-4">Authors</a></h2> +<ul class="simple"> +<li>APSL-Nagarro</li> +</ul> +</div> +<div class="section" id="contributors"> +<h2><a class="toc-backref" href="#toc-entry-5">Contributors</a></h2> +<ul class="simple"> +<li><a class="reference external" href="https://apsl.tech">APSL-Nagarro</a>:<ul> +<li>Bernat Obrador</li> +<li>Miquel Alzanillas</li> +</ul> +</li> +</ul> +</div> +<div class="section" id="maintainers"> +<h2><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h2> +<p>This module is maintained by the OCA.</p> +<a class="reference external image-reference" href="https://odoo-community.org"> +<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /> +</a> +<p>OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.</p> +<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainers</a>:</p> +<p><a class="reference external image-reference" href="https://github.com/BernatObrador"><img alt="BernatObrador" src="https://github.com/BernatObrador.png?size=40px" /></a> <a class="reference external image-reference" href="https://github.com/miquelalzanillas"><img alt="miquelalzanillas" src="https://github.com/miquelalzanillas.png?size=40px" /></a></p> +<p>This module is part of the <a class="reference external" href="https://github.com/OCA/account-financial-reporting/tree/17.0/account_analytic_report">OCA/account-financial-reporting</a> project on GitHub.</p> +<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p> +</div> +</div> +</div> +</body> +</html> diff --git a/account_analytic_report/tests/__init__.py b/account_analytic_report/tests/__init__.py new file mode 100644 index 00000000000..d2dd961ed87 --- /dev/null +++ b/account_analytic_report/tests/__init__.py @@ -0,0 +1 @@ +from . import test_trial_analytic_balance diff --git a/account_analytic_report/tests/test_trial_analytic_balance.py b/account_analytic_report/tests/test_trial_analytic_balance.py new file mode 100644 index 00000000000..c1bac8594c6 --- /dev/null +++ b/account_analytic_report/tests/test_trial_analytic_balance.py @@ -0,0 +1,374 @@ +# Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestTrialAnalyticBalanceReport(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.account_type_map = cls.env[ + "report.account_analytic_report.trial_balance_analytic" + ]._get_account_type_mapping() + + cls.analytic_plan_1 = cls.env["account.analytic.plan"].create( + { + "name": "Plan 1", + } + ) + account_group = cls.env["account.group"] + cls.group5 = account_group.create({"code_prefix_start": "5", "name": "Group 5"}) + cls.group4 = account_group.create({"code_prefix_start": "4", "name": "Group 4"}) + cls.group42 = account_group.create( + {"code_prefix_start": "42", "name": "Group 4", "parent_id": cls.group4.id} + ) + + cls.expense_account = cls.env["account.account"].create( + { + "name": "Expenses Account", + "code": "5000", + "account_type": "expense", + "company_id": cls.env.user.company_id.id, + "group_id": cls.group5.id, + } + ) + cls.income_account = cls.env["account.account"].create( + { + "name": "Income Account", + "code": "4000", + "account_type": "income", + "company_id": cls.env.user.company_id.id, + "group_id": cls.group4.id, + } + ) + cls.income_account_2 = cls.env["account.account"].create( + { + "name": "Income Account 2", + "code": "4200", + "account_type": "income", + "company_id": cls.env.user.company_id.id, + "group_id": cls.group42.id, + } + ) + + cls.aaa_1 = cls.env["account.analytic.account"].create( + {"name": "Account 1", "plan_id": cls.analytic_plan_1.id} + ) + + cls.aaa_2 = cls.env["account.analytic.account"].create( + {"name": "Account 2", "plan_id": cls.analytic_plan_1.id} + ) + cls.account_field = cls.analytic_plan_1._column_name() + cls.aal_1 = cls.env["account.analytic.line"].create( + { + "name": "aal 1", + cls.account_field: cls.aaa_1.id, + "general_account_id": cls.expense_account.id, + "amount": -150.0, + "date": "2024-09-30", + } + ) + cls.aal_2 = cls.env["account.analytic.line"].create( + { + "name": "aal 1", + cls.account_field: cls.aaa_2.id, + "general_account_id": cls.expense_account.id, + "amount": -50, + "date": "2024-11-30", + } + ) + cls.aal_3 = cls.env["account.analytic.line"].create( + { + "name": "aal 1", + cls.account_field: cls.aaa_2.id, + "general_account_id": cls.income_account.id, + "amount": 250, + "date": "2024-12-31", + } + ) + + cls.date_from = "2024-10-01" + cls.date_to = "2024-12-31" + cls.fy_start_date = "2024-01-01" + + def _get_report_lines( + self, account_ids=False, show_hierarchy=False, group_by_analytic_account=False + ): + company = self.env.user.company_id + trial_analytic_balance = self.env["ac.trial.balance.report.wizard"].create( + { + "date_from": self.date_from, + "date_to": self.date_to, + "show_hierarchy": show_hierarchy, + "company_id": company.id, + "account_ids": account_ids, + "fy_start_date": self.fy_start_date, + "plan_id": self.analytic_plan_1.id, + "group_by_analytic_account": group_by_analytic_account, + } + ) + data = trial_analytic_balance._prepare_report_trial_balance_analytic() + res_data = self.env[ + "report.account_analytic_report.trial_balance_analytic" + ]._get_report_values(trial_analytic_balance, data) + return res_data + + def _accounts_in_report(self, trial_balance): + accounts_in_report = [] + for account in trial_balance: + accounts_in_report.append(account["id"]) + + return accounts_in_report + + def _check_total_amounts_by_acc_type( + self, totals_by_acc_type, include_both_accounts=False + ): + for type_name, total_by_acc_type in totals_by_acc_type.items(): + if type_name == self.account_type_map["expense"]: + self.assertTrue(total_by_acc_type["total_initial_balance"] == -150) + self.assertTrue(total_by_acc_type["total_ending_balance"] == -200) + + if include_both_accounts: + for aaa_id, amount in total_by_acc_type[ + "total_period_balance" + ].items(): + if aaa_id == self.aaa_2.id: + self.assertEqual(amount, -50) + else: + self.assertEqual(amount, 0) + else: + self.assertTrue(total_by_acc_type["total_period_balance"] == -50) + elif type_name == self.account_type_map["income"]: + self.assertTrue(total_by_acc_type["total_initial_balance"] == 0) + self.assertTrue(total_by_acc_type["total_ending_balance"] == 250) + if include_both_accounts: + for aaa_id, amount in total_by_acc_type[ + "total_period_balance" + ].items(): + if aaa_id == self.aaa_2.id: + self.assertEqual(amount, 250) + else: + self.assertEqual(amount, 0) + else: + self.assertTrue(total_by_acc_type["total_period_balance"] == 250) + + def test01_trial_analytic_balance(self): + res_data = self._get_report_lines() + trial_analytic_balance = res_data["trial_balance"] + accounts_in_report = self._accounts_in_report(trial_analytic_balance) + totals = res_data["total_amounts"] + + self.assertTrue(len(accounts_in_report) == 2) + self.assertTrue(self.expense_account.id in accounts_in_report) + self.assertTrue(self.income_account.id in accounts_in_report) + self.assertFalse(self.income_account_2.id in accounts_in_report) + + # Checks total amounts by account type + self._check_total_amounts_by_acc_type(res_data["totals_by_acc_type"]) + + # Checks total amounts + self.assertEqual(totals["total_initial_balance"], -150) + self.assertEqual(totals["total_period_balance"], -50 + 250) + self.assertEqual( + totals["total_ending_balance"], + -150 + -50 + 250, + ) + + # Check balances for every account + for account in trial_analytic_balance: + if account["id"] == self.income_account.id: + self.assertEqual(account["initial_balance"], 0) + self.assertEqual(account["balance"], 250) + self.assertEqual(account["ending_balance"], 250) + else: + self.assertEqual(account["initial_balance"], -150) + self.assertEqual(account["balance"], -50) + self.assertEqual(account["ending_balance"], -150 + -50) + + def test02_trial_analytic_balance_with_splited_accounts(self): + res_data = self._get_report_lines(account_ids=[self.aaa_1.id, self.aaa_2.id]) + trial_analytic_balance = res_data["trial_balance"] + totals = res_data["total_amounts"] + + accounts_in_report = self._accounts_in_report(trial_analytic_balance) + self.assertTrue(len(accounts_in_report) == 2) + self.assertTrue(self.expense_account.id in accounts_in_report) + self.assertTrue(self.income_account.id in accounts_in_report) + self.assertFalse(self.income_account_2.id in accounts_in_report) + + self.assertTrue(self.aaa_1.name in res_data["account_code_list"]) + self.assertTrue(self.aaa_2.name in res_data["account_code_list"]) + + # Checks total amounts by account type + self._check_total_amounts_by_acc_type( + res_data["totals_by_acc_type"], include_both_accounts=True + ) + + # Checks total amounts + self.assertEqual(totals["total_initial_balance"], -150) + self.assertEqual( + totals["total_ending_balance"], + -150 + -50 + 250, + ) + + for aaa_id, amount in totals["total_period_balance"].items(): + if aaa_id == self.aaa_2.id: + self.assertEqual(amount, -50 + 250) + else: + self.assertEqual(amount, 0) + + # Check balances for every account + for account in trial_analytic_balance: + if account["id"] == self.income_account.id: + self.assertEqual(account["initial_balance"], 0) + self.assertEqual(account["ending_balance"], 250) + for aaa_id, amount in account["accounts"].items(): + if aaa_id == self.aaa_2.id: + self.assertEqual(amount, 250) + else: + self.assertEqual(amount, 0) + else: + self.assertEqual(account["initial_balance"], -150) + self.assertEqual(account["ending_balance"], -150 + -50) + for aaa_id, amount in account["accounts"].items(): + if aaa_id == self.aaa_2.id: + self.assertEqual(amount, -50) + else: + self.assertEqual(amount, 0) + + def test03_trial_analytic_balance_gruped_by_analytic_account(self): + res_data = self._get_report_lines(group_by_analytic_account=True) + trial_analytic_balance = res_data["trial_balance"] + totals = res_data["total_amounts"] + accounts_in_report = self._accounts_in_report(trial_analytic_balance) + + self.assertTrue(len(accounts_in_report) == 2) + self.assertTrue(self.aaa_1.id in accounts_in_report) + self.assertTrue(self.aaa_2.id in accounts_in_report) + + self._check_total_amounts_by_acc_type(res_data["totals_by_acc_type"]) + self.assertEqual(totals["total_initial_balance"], -150) + self.assertEqual(totals["total_period_balance"], -50 + 250) + self.assertEqual( + totals["total_ending_balance"], + -150 + -50 + 250, + ) + + for account in trial_analytic_balance: + if account["id"] == self.aaa_1.id: + self.assertEqual(account["initial_balance"], -150) + self.assertEqual(account["balance"], 0) + self.assertEqual(account["ending_balance"], -150) + else: + self.assertEqual(account["initial_balance"], 0) + self.assertEqual(account["balance"], -50 + 250) + self.assertEqual(account["ending_balance"], -50 + 250) + + def test04_trial_analytic_balance_gruped_by_analytic_account_filtered(self): + res_data = self._get_report_lines( + group_by_analytic_account=True, account_ids=[self.aaa_1.id] + ) + trial_analytic_balance = res_data["trial_balance"] + totals = res_data["total_amounts"] + accounts_in_report = self._accounts_in_report(trial_analytic_balance) + + self.assertTrue(len(accounts_in_report) == 1) + self.assertTrue(self.aaa_1.id in accounts_in_report) + self.assertFalse(self.aaa_2.id in accounts_in_report) + + for type_name, total_by_acc_type in res_data["totals_by_acc_type"].items(): + if type_name == self.account_type_map["expense"]: + self.assertEqual(total_by_acc_type["total_initial_balance"], -150) + self.assertEqual(total_by_acc_type["total_period_balance"], 0) + self.assertEqual(total_by_acc_type["total_ending_balance"], -150) + self.assertEqual(totals["total_initial_balance"], -150) + self.assertEqual(totals["total_period_balance"], 0) + self.assertEqual(totals["total_ending_balance"], -150) + + for account in trial_analytic_balance: + if account["id"] == self.aaa_1.id: + self.assertEqual(account["initial_balance"], -150) + self.assertEqual(account["balance"], 0) + self.assertEqual(account["ending_balance"], -150) + + def test05_trial_analytic_balance_show_hirarchy(self): + self.env["account.analytic.line"].create( + { + "name": "aal 1", + self.account_field: self.aaa_2.id, + "general_account_id": self.income_account_2.id, + "amount": 300, + "date": "2024-12-31", + } + ) + res_data = self._get_report_lines(show_hierarchy=True) + trial_analytic_balance = res_data["trial_balance"] + accounts_in_report = self._accounts_in_report(trial_analytic_balance) + totals = res_data["total_amounts"] + + self.assertTrue(len(accounts_in_report) == 6) + self.assertTrue(self.expense_account.id in accounts_in_report) + self.assertTrue(self.income_account.id in accounts_in_report) + self.assertTrue(self.income_account_2.id in accounts_in_report) + + # Checks total amounts by account type + for type_name, total_by_acc_type in res_data["totals_by_acc_type"].items(): + if type_name == self.account_type_map["expense"]: + self.assertTrue(total_by_acc_type["total_initial_balance"] == -150) + self.assertTrue(total_by_acc_type["total_period_balance"] == -50) + self.assertTrue(total_by_acc_type["total_ending_balance"] == -200) + elif type_name == self.account_type_map["income"]: + self.assertTrue(total_by_acc_type["total_initial_balance"] == 0) + self.assertTrue(total_by_acc_type["total_period_balance"] == 250 + 300) + self.assertTrue(total_by_acc_type["total_ending_balance"] == 250 + 300) + + # Checks total amounts + self.assertEqual(totals["total_initial_balance"], -150) + self.assertEqual(totals["total_period_balance"], -50 + 250 + 300) + self.assertEqual(totals["total_ending_balance"], -150 + -50 + 250 + 300) + + # Check balances for every account + for account in trial_analytic_balance: + if account["type"] == "group_type": + if account["code"] == "4": + self.assertEqual(account["name"], "Group 4") + self.assertEqual(account["complete_code"], "4") + self.assertEqual(account["level"], 0) + self.assertTrue(self.income_account.id in account["account_ids"]) + self.assertEqual(account["initial_balance"], 0) + self.assertEqual(account["balance"], 250 + 300) + self.assertEqual(account["ending_balance"], 250 + 300) + if account["code"] == "42": + self.assertEqual(account["name"], "Group 4") + self.assertEqual(account["complete_code"], "4/42") + self.assertEqual(account["level"], 1) + self.assertTrue(self.income_account_2.id in account["account_ids"]) + self.assertEqual(account["initial_balance"], 0) + self.assertEqual(account["balance"], 300) + self.assertEqual(account["ending_balance"], 300) + if account["code"] == "5": + self.assertEqual(account["name"], "Group 5") + self.assertEqual(account["complete_code"], "5") + self.assertEqual(account["level"], 0) + self.assertTrue(self.expense_account.id in account["account_ids"]) + self.assertEqual(account["initial_balance"], -150) + self.assertEqual(account["balance"], -50) + self.assertEqual(account["ending_balance"], -150 + -50) + else: + if account["id"] == self.income_account.id: + self.assertEqual(account["initial_balance"], 0) + self.assertEqual(account["balance"], 250) + self.assertEqual(account["ending_balance"], 250) + elif account["id"] == self.income_account_2.id: + self.assertEqual(account["initial_balance"], 0) + self.assertEqual(account["balance"], 300) + self.assertEqual(account["ending_balance"], 300) + else: + self.assertEqual(account["initial_balance"], -150) + self.assertEqual(account["balance"], -50) + self.assertEqual(account["ending_balance"], -150 + -50) diff --git a/account_analytic_report/views/account_analytic_line.xml b/account_analytic_report/views/account_analytic_line.xml new file mode 100644 index 00000000000..08ae8458450 --- /dev/null +++ b/account_analytic_report/views/account_analytic_line.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> +<!-- # Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). --> + <!-- + This view modification sets the priority of the account.analytic.line tree view + to 1, ensuring that this view is used instead of the timesheet view when clicking + on the amounts in the report. This prioritization helps in displaying the correct + tree view for analytic lines rather than the timesheet-related view. + --> + <record id="analytic.view_account_analytic_line_tree" model="ir.ui.view"> + <field name="priority">1</field> + </record> +</odoo> diff --git a/account_analytic_report/views/report_trial_balance_analytic.xml b/account_analytic_report/views/report_trial_balance_analytic.xml new file mode 100644 index 00000000000..3322d05a8fb --- /dev/null +++ b/account_analytic_report/views/report_trial_balance_analytic.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> +<!-- # Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). --> + <template id="report_trial_balance_analytic"> + <div class="o_account_financial_reports_page"> + <t t-call="account_financial_report.report_buttons" /> + <t t-call="account_financial_report.report_trial_balance_base" /> + </div> + </template> +</odoo> diff --git a/account_analytic_report/wizard/__init__.py b/account_analytic_report/wizard/__init__.py new file mode 100644 index 00000000000..801628b39d4 --- /dev/null +++ b/account_analytic_report/wizard/__init__.py @@ -0,0 +1 @@ +from . import trial_balance_analytic_wizard_view diff --git a/account_analytic_report/wizard/trial_balance_analytic_wizard_view.py b/account_analytic_report/wizard/trial_balance_analytic_wizard_view.py new file mode 100644 index 00000000000..1f469a5a84d --- /dev/null +++ b/account_analytic_report/wizard/trial_balance_analytic_wizard_view.py @@ -0,0 +1,170 @@ +# Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import date_utils + + +class AnalyticTrialBalanceReportWizard(models.TransientModel): + """Trial balance report wizard.""" + + _name = "ac.trial.balance.report.wizard" + _description = "Analytic Trial Balance Report Wizard" + _inherit = "account_financial_report_abstract_wizard" + + date_range_id = fields.Many2one(comodel_name="date.range", string="Date range") + date_from = fields.Date(required=True) + date_to = fields.Date(required=True) + fy_start_date = fields.Date(compute="_compute_fy_start_date") + account_ids = fields.Many2many( + comodel_name="account.analytic.account", string="Filter accounts" + ) + plan_id = fields.Many2one( + "account.analytic.plan", domain="[('parent_id', '=', False)]", required=True + ) + + group_by_analytic_account = fields.Boolean(string="Group by Analytic Account") + show_hierarchy = fields.Boolean(help="Shows hierarchy of the financial accounts") + limit_hierarchy_level = fields.Boolean(help="Limits hierarchy level") + hierarchy_level = fields.Integer(help="Hierarchy levels to show", default=1) + + show_months = fields.Boolean( + help=""" + This option works only when exporting to Excel. It will create a separate sheet + for each selected analytic account, displaying all financial accounts with a + balance. + For each account, it shows the monthly balance within the selected date range. + """ + ) + + @api.depends("date_from") + def _compute_fy_start_date(self): + for wiz in self: + if wiz.date_from: + date_from, date_to = date_utils.get_fiscal_year( + wiz.date_from, + day=self.company_id.fiscalyear_last_day, + month=int(self.company_id.fiscalyear_last_month), + ) + wiz.fy_start_date = date_from + else: + wiz.fy_start_date = False + + @api.onchange("company_id") + def onchange_company_id(self): + """Handle company change.""" + if ( + self.company_id + and self.date_range_id.company_id + and self.date_range_id.company_id != self.company_id + ): + self.date_range_id = False + + res = { + "domain": { + "date_range_id": [], + } + } + if not self.company_id: + return res + else: + # res["domain"]["account_ids"] += [("company_id", "=", self.company_id.id)] + res["domain"]["date_range_id"] += [ + "|", + ("company_id", "=", self.company_id.id), + ("company_id", "=", False), + ] + return res + + @api.onchange("date_range_id") + def onchange_date_range_id(self): + """Handle date range change.""" + self.date_from = self.date_range_id.date_start + self.date_to = self.date_range_id.date_end + + @api.onchange("group_by_analytic_account") + def onchange_group_by_analytic_account(self): + if self.group_by_analytic_account: + self._not_show_hierarchy() + + @api.onchange("plan_id") + def _onchange_plan_id(self): + if self.account_ids: + self.account_ids = False + self.show_months = False + + @api.constrains("company_id", "date_range_id") + def _check_company_id_date_range_id(self): + for rec in self.sudo(): + if ( + rec.company_id + and rec.date_range_id.company_id + and rec.company_id != rec.date_range_id.company_id + ): + raise ValidationError( + _( + "The Company in the Trial Balance Report Wizard and in " + "Date Range must be the same." + ) + ) + + @api.constrains("show_hierarchy", "hierarchy_level") + def _check_show_hierarchy_level(self): + for rec in self: + if rec.show_hierarchy and rec.hierarchy_level <= 0: + raise UserError( + _("The hierarchy level to filter on must be greater than 0.") + ) + + @api.onchange("account_ids") + def _onchange_account_ids(self): + if self.account_ids: + self._not_show_hierarchy() + + def _print_report(self, report_type): + self.ensure_one() + data = self._prepare_report_trial_balance_analytic() + if report_type == "xlsx": + report_name = "a_f_r.report_trial_balance_analytic_xlsx" + else: + report_name = "account_analytic_report.trial_balance_analytic" + + return ( + self.env["ir.actions.report"] + .search( + [("report_name", "=", report_name), ("report_type", "=", report_type)], + limit=1, + ) + .report_action(self, data=data) + ) + + def _not_show_hierarchy(self): + self.show_hierarchy = False + self.limit_hierarchy_level = False + self.hierarchy_level = 1 + + def _prepare_report_trial_balance_analytic(self): + self.ensure_one() + sorted_accounts_ids = sorted([account.id for account in self.account_ids]) + return { + "wizard_id": self.id, + "date_from": self.date_from, + "date_to": self.date_to, + "company_id": self.company_id.id, + "account_ids": sorted_accounts_ids or [], + "fy_start_date": self.fy_start_date, + "account_financial_report_lang": self.env.lang, + "plan_field": self.plan_id._column_name(), + "plan_name": self.plan_id.name, + "plan_id": self.plan_id.id, + "group_by_analytic_account": self.group_by_analytic_account, + "show_hierarchy": self.show_hierarchy, + "limit_hierarchy_level": self.limit_hierarchy_level, + "hierarchy_level": self.hierarchy_level, + "show_months": self.show_months, + } + + def _export(self, report_type): + """Default export is PDF.""" + return self._print_report(report_type) diff --git a/account_analytic_report/wizard/trial_balance_analytic_wizard_view.xml b/account_analytic_report/wizard/trial_balance_analytic_wizard_view.xml new file mode 100644 index 00000000000..fca61181a04 --- /dev/null +++ b/account_analytic_report/wizard/trial_balance_analytic_wizard_view.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <!-- # Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). --> + <record id="analytic_trial_balance_wizard" model="ir.ui.view"> + <field name="name">Analytic Trial Balance</field> + <field name="model">ac.trial.balance.report.wizard</field> + <field name="arch" type="xml"> + <form> + <group name="main_info"> + <field + name="company_id" + options="{'no_create': True}" + groups="base.group_multi_company" + /> + </group> + <div> + <group name="filters"> + <group name="date_range"> + <field name="date_range_id" /> + <field name="date_from" /> + <field name="date_to" /> + <field name="fy_start_date" invisible="1" /> + </group> + <group name="other_filters"> + <field name="plan_id" /> + <field name="group_by_analytic_account" /> + <field + name="show_hierarchy" + invisible="group_by_analytic_account or account_ids" + /> + <field + name="limit_hierarchy_level" + invisible="not show_hierarchy or group_by_analytic_account" + /> + <field + name="hierarchy_level" + invisible="not limit_hierarchy_level or group_by_analytic_account" + /> + </group> + </group> + <div /> + <group name="account_filter" col="4"> + <label for="account_ids" colspan="4" /> + <field + name="account_ids" + nolabel="1" + widget="many2many_tags" + options="{'no_create': True}" + colspan="4" + domain="[('root_plan_id', '=', plan_id)]" + /> + <field + name="show_months" + invisible="not account_ids or group_by_analytic_account" + /> + </group> + </div> + <footer> + <div> + <button + name="button_export_html" + string="View" + type="object" + default_focus="1" + class="oe_highlight" + /> + or + <button + name="button_export_pdf" + string="Export PDF" + type="object" + /> + or + <button + name="button_export_xlsx" + string="Export XLSX" + type="object" + /> + or + <button string="Cancel" class="oe_link" special="cancel" /> + </div> + </footer> + </form> + </field> + </record> + <record id="action_analytic_trial_balance_wizard" model="ir.actions.act_window"> + <field name="name">Analytic Trial Balance</field> + <field name="res_model">ac.trial.balance.report.wizard</field> + <field name="view_mode">form</field> + <field name="view_id" ref="analytic_trial_balance_wizard" /> + <field name="target">new</field> + </record> +</odoo>