From 81254ba80060e124f93a05a0d877ffc0bf7c19ba Mon Sep 17 00:00:00 2001 From: bobrador Date: Thu, 7 Nov 2024 15:18:37 +0100 Subject: [PATCH] [WIP] account_analytic_report: New module account_analytic_report --- account_analytic_report/README.rst | 105 +++ account_analytic_report/__init__.py | 2 + account_analytic_report/__manifest__.py | 31 + account_analytic_report/i18n/es.po | 410 ++++++++ account_analytic_report/menuitems.xml | 15 + account_analytic_report/pyproject.toml | 3 + account_analytic_report/readme/CONFIGURE.md | 0 .../readme/CONTRIBUTORS.md | 3 + account_analytic_report/readme/DESCRIPTION.md | 0 account_analytic_report/readme/HISTORY.md | 0 account_analytic_report/readme/ROADMAP.md | 0 account_analytic_report/report/__init__.py | 2 + .../templates/trial_balance_analytic.xml | 492 ++++++++++ .../report/trial_balance_analytic.py | 872 ++++++++++++++++++ .../report/trial_balance_analytic_xlsx.py | 642 +++++++++++++ account_analytic_report/reports.xml | 47 + .../security/ir.model.access.csv | 2 + account_analytic_report/security/security.xml | 3 + .../static/description/icon.png | Bin 0 -> 36663 bytes .../static/description/index.html | 440 +++++++++ account_analytic_report/tests/__init__.py | 1 + .../tests/test_trial_analytic_balance.py | 287 ++++++ .../views/account_analytic_line.xml | 12 + .../views/report_trial_balance_analytic.xml | 9 + account_analytic_report/wizard/__init__.py | 1 + .../trial_balance_analytic_wizard_view.py | 170 ++++ .../trial_balance_analytic_wizard_view.xml | 93 ++ 27 files changed, 3642 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/CONFIGURE.md 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/HISTORY.md create mode 100644 account_analytic_report/readme/ROADMAP.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/security/security.xml 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 000000000000..ba9206b88f87 --- /dev/null +++ b/account_analytic_report/README.rst @@ -0,0 +1,105 @@ +======================== +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| + + + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + + + +Known issues / Roadmap +====================== + + + +Changelog +========= + + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* APSL-Nagarro + +Contributors +------------ + +- `APSL-Nagarro `__: + + - 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 `__: + +|maintainer-BernatObrador| |maintainer-miquelalzanillas| + +This module is part of the `OCA/account-financial-reporting `_ 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 000000000000..c4e388b714a9 --- /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 000000000000..85244345e009 --- /dev/null +++ b/account_analytic_report/__manifest__.py @@ -0,0 +1,31 @@ +# 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", + "security/security.xml", + "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 000000000000..e46cb22cdb43 --- /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 000000000000..64f8e6a2f939 --- /dev/null +++ b/account_analytic_report/menuitems.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/account_analytic_report/pyproject.toml b/account_analytic_report/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /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/CONFIGURE.md b/account_analytic_report/readme/CONFIGURE.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/account_analytic_report/readme/CONTRIBUTORS.md b/account_analytic_report/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..fd6acfe2c7c8 --- /dev/null +++ b/account_analytic_report/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [APSL-Nagarro](https://apsl.tech): + - Bernat Obrador + - Miquel Alzanillas \ No newline at end of file diff --git a/account_analytic_report/readme/DESCRIPTION.md b/account_analytic_report/readme/DESCRIPTION.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/account_analytic_report/readme/HISTORY.md b/account_analytic_report/readme/HISTORY.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/account_analytic_report/readme/ROADMAP.md b/account_analytic_report/readme/ROADMAP.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/account_analytic_report/report/__init__.py b/account_analytic_report/report/__init__.py new file mode 100644 index 000000000000..db771ac683a0 --- /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 000000000000..d83721f155e8 --- /dev/null +++ b/account_analytic_report/report/templates/trial_balance_analytic.xml @@ -0,0 +1,492 @@ + + + + + + + + + + + + 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 000000000000..b707251cc960 --- /dev/null +++ b/account_analytic_report/report/trial_balance_analytic.py @@ -0,0 +1,872 @@ +# 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 _get_computed_groups_data(self, accounts_data, total_amount): + groups = self.env["account.group"].search([("id", "!=", False)]) + groups_data = {} + for group in groups: + len_group_code = len(group.code_prefix_start) + 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 account in accounts_data.values(): + if group.code_prefix_start == account["code"][:len_group_code]: + acc_id = account["id"] + group_id = group.id + groups_data[group_id]["initial_balance"] += total_amount[acc_id][ + "initial_balance" + ] + groups_data[group_id]["balance"] += total_amount[acc_id]["balance"] + groups_data[group_id]["ending_balance"] += total_amount[acc_id][ + "ending_balance" + ] + 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 000000000000..390b4e35b73f --- /dev/null +++ b/account_analytic_report/report/trial_balance_analytic_xlsx.py @@ -0,0 +1,642 @@ +# 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): + 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 super()._define_formats(workbook, report_data) + + 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 000000000000..df50cda93d60 --- /dev/null +++ b/account_analytic_report/reports.xml @@ -0,0 +1,47 @@ + + + + + Trial Analytic Balance + ac.trial.balance.report.wizard + qweb-pdf + account_analytic_report.trial_balance_analytic + account_analytic_report.trial_balance_analytic + + + + Trial Analytic Balance + ac.trial.balance.report.wizard + qweb-html + account_analytic_report.trial_balance_analytic + account_analytic_report.trial_balance_analytic + + + Trial Balance XLSX + ac.trial.balance.report.wizard + ir.actions.report + a_f_r.report_trial_balance_analytic_xlsx + xlsx + report_trial_balance_analytic + + 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 000000000000..e16db37c5347 --- /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/security/security.xml b/account_analytic_report/security/security.xml new file mode 100644 index 000000000000..56d2f04b3b9a --- /dev/null +++ b/account_analytic_report/security/security.xml @@ -0,0 +1,3 @@ + + + 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 000000000000..fab19003b16b --- /dev/null +++ b/account_analytic_report/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +Account Analytic Reports + + + +
+

Account Analytic Reports

+ + +

Beta License: AGPL-3 OCA/account-financial-reporting Translate me on Weblate Try me on Runboat

+

Table of contents

+ + + + +
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • APSL-Nagarro
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainers:

+

BernatObrador miquelalzanillas

+

This module is part of the OCA/account-financial-reporting 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/tests/__init__.py b/account_analytic_report/tests/__init__.py new file mode 100644 index 000000000000..d2dd961ed878 --- /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 000000000000..dc0eb6938ba9 --- /dev/null +++ b/account_analytic_report/tests/test_trial_analytic_balance.py @@ -0,0 +1,287 @@ +# 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", + } + ) + + cls.expense_account = cls.env["account.account"].create( + { + "name": "Expenses Account", + "code": "5000", + "account_type": "expense", + "company_id": cls.env.user.company_id.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, + } + ) + 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, + } + ) + + 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} + ) + account_field = cls.analytic_plan_1._column_name() + cls.aal_1 = cls.env["account.analytic.line"].create( + { + "name": "aal 1", + 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", + 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", + 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 test03_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) 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 000000000000..0aeca39833f5 --- /dev/null +++ b/account_analytic_report/views/account_analytic_line.xml @@ -0,0 +1,12 @@ + + + + + 1 + + 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 000000000000..e24ab35c2a94 --- /dev/null +++ b/account_analytic_report/views/report_trial_balance_analytic.xml @@ -0,0 +1,9 @@ + + + + diff --git a/account_analytic_report/wizard/__init__.py b/account_analytic_report/wizard/__init__.py new file mode 100644 index 000000000000..801628b39d43 --- /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 000000000000..2ac782405e78 --- /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() + date_to = fields.Date() + 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)]" + ) + + 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 000000000000..26a8785b832e --- /dev/null +++ b/account_analytic_report/wizard/trial_balance_analytic_wizard_view.xml @@ -0,0 +1,93 @@ + + + + + Analytic Trial Balance + ac.trial.balance.report.wizard + +
+ + + +
+ + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ + + + + Analytic Trial Balance + ac.trial.balance.report.wizard + form + + new + +