From 00dadcfb9dfa23d7599728286bf66b4b464ac0ac 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 | 3 + account_analytic_report/__manifest__.py | 30 + 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 | 285 ++++++++++ .../report/trial_balance_analytic.py | 520 ++++++++++++++++++ .../report/trial_balance_analytic_xlsx.py | 309 +++++++++++ account_analytic_report/reports.xml | 27 + .../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 +++++++++++++++ .../views/report_trial_balance_analytic.xml | 9 + account_analytic_report/wizard/__init__.py | 1 + .../trial_balance_analytic_wizard_view.py | 166 ++++++ .../trial_balance_analytic_wizard_view.xml | 82 +++ 23 files changed, 2005 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/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/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..0f506e0138e4 --- /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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-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..85a6f113a676 --- /dev/null +++ b/account_analytic_report/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import report +from . import wizard \ No newline at end of file diff --git a/account_analytic_report/__manifest__.py b/account_analytic_report/__manifest__.py new file mode 100644 index 000000000000..38b0858ecb3c --- /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", + "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" + ], + "assets": { + "web.assets_backend": [ + "account_analytic_report/static/src/js/*", + ], + }, + 'application': False, + 'installable': True, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/account_analytic_report/menuitems.xml b/account_analytic_report/menuitems.xml new file mode 100644 index 000000000000..686e0fe8572c --- /dev/null +++ b/account_analytic_report/menuitems.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file 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..711912663de2 --- /dev/null +++ b/account_analytic_report/report/__init__.py @@ -0,0 +1,2 @@ +from . import trial_balance_analytic +from . import trial_balance_analytic_xlsx \ No newline at end of file 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..63dc7f42f49e --- /dev/null +++ b/account_analytic_report/report/templates/trial_balance_analytic.xml @@ -0,0 +1,285 @@ + + + + + + + + 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..a5b6e949999f --- /dev/null +++ b/account_analytic_report/report/trial_balance_analytic.py @@ -0,0 +1,520 @@ +# 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, + } + } + ) + 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, + ): + bs_ml_domain = domain + [("date", "<", date_from)] + 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, hide_account_at_0, company_id, total_amount): + if hide_account_at_0: + 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, fields, group_by, lazy=True): + initial_domain_bs = self._get_initial_balances_bs_ml_domain( + domain, + date_from, + ) + 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 = analytic_accounts.filtered(lambda account: account.code).mapped("code") + 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, include_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"], + }) + # If the report requires analytic 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_accounts: + accounts_data[account_id]["accounts"] = {} + for account in account_ids: + accounts_data[account_id]["accounts"][account] = total_amount[account_id][account] + else: + accounts_data[account_id].update({ + '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 + + @api.model + def _get_data_splited_by_accounts( + self, + account_ids, + company_id, + date_to, + date_from, + hide_account_at_0, + fy_start_date, + plan_field, + plan_id, + ): + 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, + 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": 0.0, + } + ) + + if hide_account_at_0: + 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(hide_account_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, + hide_account_at_0, + fy_start_date, + plan_field, + plan_id, + group_by_analytic_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)] + + 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, + fields = [plan_field, "general_account_id", "amount"], + group_by=[group_by_field] + ) + + if hide_account_at_0: + 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(hide_account_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_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) + + # Determinar si agrupar por cuenta analĂ­tica o no + 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["hide_account_at_0"], + data["fy_start_date"], + data["plan_field"], + data["plan_id"], + ) + include_accounts = True + else: + total_amount, accounts_data = self._get_data( + data["account_ids"], + data["company_id"], + data["date_to"], + data["date_from"], + data["hide_account_at_0"], + data["fy_start_date"], + data["plan_field"], + data["plan_id"], + data["group_by_analytic_account"], + ) + include_accounts = False + + self._update_accounts_data(accounts_data, total_amount, include_accounts=include_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) + + def _prepare_report_values(self, wizard_id, company, data, trial_balance, total_amount, accounts_data, account_codes, account_code_list): + 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"], + "hide_account_at_0": data["hide_account_at_0"], + "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"] + } \ No newline at end of file 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..8d247e93b4c3 --- /dev/null +++ b/account_analytic_report/report/trial_balance_analytic_xlsx.py @@ -0,0 +1,309 @@ +# Copyright 2024 (APSL - Nagarro) Bernat Obrador +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, models +from dateutil.relativedelta import relativedelta +from datetime import datetime + + +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) + 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 + return report_name + + def _get_report_columns(self, report): + if report.account_ids and not report.group_by_analytic_account: + codes = self.env["account.analytic.account"].search([('id', 'in', report.account_ids.ids)]) + 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, + }, + } + 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}), + ], + [ + _("Account at 0 filter"), + _("Hide") if report.hide_account_at_0 else _("Show"), + ], + [ + _("Selected Plan"), + report.plan_id.name + ], + [ + _("Grouped by analytic account"), + _("Yes") if report.group_by_analytic_account else _('No') + ], + [ + _("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 _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"] + self.write_array_header(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) + + if report_values['show_months']: + self.create_page_by_anlytic_accounts(workbook, report, report_data, report_values) + + 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"], + 'account_code_list': res_data["account_code_list"], + 'group_by_analytic_account': res_data["group_by_analytic_account"], + 'trial_balance': res_data['trial_balance'], + 'plan_field': res_data['plan_field'], + } + + 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"]): + 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 _get_months_query(self, 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" = {self.env.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 create_page_by_anlytic_accounts(self, workbook, report, report_data, report_values): + 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() + + for account in report.account_ids: + # Add a new worksheet for each account using its code as the sheet name + sheet = workbook.add_worksheet(account.code) + report_data["row_pos"] = 1 + + query = self._get_months_query(account_id_field, account, date_from, date_to, report_values) + + self.env.cr.execute(query) + + result = self.env.cr.fetchall() + + report_data["columns"] = self._get_report_columns_by_month(date_from, date_to, account) + report_data["sheet"] = sheet + + self.write_headers(report_data) + line_dicts = {} + for row_data in result: + account = self.env["account.account"].browse(row_data[0]) + month_key = f"month_{row_data[1].month}" + + if account.id not in line_dicts: + line_dicts[account.id] = { + "code": account.code, + "name": account.name, + "total": row_data[3] + } + #Sums the amount of the months + else: + line_dicts[account.id]['total'] += row_data[3] + if month_key not in line_dicts[account.id]: + line_dicts[account.id][month_key] = 0 + + line_dicts[account.id][month_key] = row_data[3] + + for line_dict in line_dicts.values(): + self.write_line_from_dict(line_dict, report_data) + + def _get_report_columns_by_month(self, date_from, date_to, account): + res = { + 0: {"header": _("Code"), "field": "code", "width": 10}, + 1: {"header": _("Account"), "field": "name", "width": 70}, + } + + 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"month_{current_date.month}", "type": "amount", "width": 14} + + # Move to the next month + current_date += relativedelta(months=1) + + # Add a final column for the "Total" + res[len(res)] = { + "header": _("Total"), "field": "total", "type": "amount", "width": 14 + } + return res + + def write_headers(self, report_data): + """Write headers to the current sheet""" + for col_pos, column in report_data["columns"].items(): + header_value = column["header"] + report_data["sheet"].write_string( + 0, + col_pos, + header_value, + report_data["formats"]["format_header_center"] + ) diff --git a/account_analytic_report/reports.xml b/account_analytic_report/reports.xml new file mode 100644 index 000000000000..16121f611ba2 --- /dev/null +++ b/account_analytic_report/reports.xml @@ -0,0 +1,27 @@ + + + + + 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 + + \ No newline at end of file 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..810579ec2c1f --- /dev/null +++ b/account_analytic_report/security/security.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file 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..6eae0ea52cc0 --- /dev/null +++ b/account_analytic_report/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +Account Analytic Reports + + + +
+

Account Analytic Reports

+ + +

Beta License: LGPL-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/views/report_trial_balance_analytic.xml b/account_analytic_report/views/report_trial_balance_analytic.xml new file mode 100644 index 000000000000..5c69953c0d52 --- /dev/null +++ b/account_analytic_report/views/report_trial_balance_analytic.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/account_analytic_report/wizard/__init__.py b/account_analytic_report/wizard/__init__.py new file mode 100644 index 000000000000..d493c19f648e --- /dev/null +++ b/account_analytic_report/wizard/__init__.py @@ -0,0 +1 @@ +from . import trial_balance_analytic_wizard_view \ No newline at end of file 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..581cfeb745e7 --- /dev/null +++ b/account_analytic_report/wizard/trial_balance_analytic_wizard_view.py @@ -0,0 +1,166 @@ +# 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" + ) + hide_account_at_0 = fields.Boolean( + string="Hide accounts at 0", + default=True, + help="When this option is enabled, the trial balance will " + "not display accounts that have initial balance = " + "debit = credit = end balance = 0", + ) + 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.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() + return { + "wizard_id": self.id, + "date_from": self.date_from, + "date_to": self.date_to, + "company_id": self.company_id.id, + "account_ids": self.account_ids.ids or [], + "fy_start_date": self.fy_start_date, + "hide_account_at_0": self.hide_account_at_0, + "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..0904331fc418 --- /dev/null +++ b/account_analytic_report/wizard/trial_balance_analytic_wizard_view.xml @@ -0,0 +1,82 @@ + + + + + Analytic Trial Balance + ac.trial.balance.report.wizard + +
+ + + +
+ + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ + + + + Analytic Trial Balance + ac.trial.balance.report.wizard + form + + new + + \ No newline at end of file