diff --git a/account_liquidity_forecast/README.rst b/account_liquidity_forecast/README.rst new file mode 100644 index 00000000000..a64dd57e7a7 --- /dev/null +++ b/account_liquidity_forecast/README.rst @@ -0,0 +1,78 @@ +========================== +Account Liquidity Forecast +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:32303c30dc7d9ce2c82299004c2f99409472e2163d9b65b72098273c9a5c047a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/16.0/account_liquidity_forecast + :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-16-0/account-financial-reporting-16-0-account_liquidity_forecast + :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=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* ForgeFlow + +Contributors +~~~~~~~~~~~~ + +* `ForgeFlow `__: + + * Jordi Ballester + * Jasmin Solanki + +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. + +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_liquidity_forecast/__init__.py b/account_liquidity_forecast/__init__.py new file mode 100644 index 00000000000..cf6083cff11 --- /dev/null +++ b/account_liquidity_forecast/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import report +from . import wizards diff --git a/account_liquidity_forecast/__manifest__.py b/account_liquidity_forecast/__manifest__.py new file mode 100644 index 00000000000..d4e19c304a7 --- /dev/null +++ b/account_liquidity_forecast/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +{ + "name": "Account Liquidity Forecast", + "version": "16.0.1.0.0", + "category": "Reporting", + "summary": "Account Liquidity Forecast", + "author": "ForgeFlow," "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-financial-reporting", + "depends": ["account", "report_xlsx", "report_xlsx_helper"], + "data": [ + "security/ir.model.access.csv", + "security/security.xml", + "wizards/account_liquidity_forecast_wizard_views.xml", + "views/report_liquidity_forecast_views.xml", + "views/account_liquidity_forecast_planning_item_views.xml", + "views/account_liquidity_forecast_planning_group_views.xml", + "menuitems.xml", + "reports.xml", + "report/templates/layouts.xml", + "report/templates/liquidity_forecast.xml", + ], + "assets": { + "web.assets_backend": [ + "account_liquidity_forecast/static/src/js/*", + ], + }, + "installable": True, + "application": True, + "auto_install": False, + "license": "AGPL-3", +} diff --git a/account_liquidity_forecast/menuitems.xml b/account_liquidity_forecast/menuitems.xml new file mode 100644 index 00000000000..d3d8a2e23be --- /dev/null +++ b/account_liquidity_forecast/menuitems.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/account_liquidity_forecast/models/__init__.py b/account_liquidity_forecast/models/__init__.py new file mode 100644 index 00000000000..774ac4b4890 --- /dev/null +++ b/account_liquidity_forecast/models/__init__.py @@ -0,0 +1,4 @@ +from . import ir_actions_report +from . import account_account +from . import account_liquidity_forecast_planning_group +from . import account_liquidity_forecast_planning_item diff --git a/account_liquidity_forecast/models/account_account.py b/account_liquidity_forecast/models/account_account.py new file mode 100644 index 00000000000..91dd9495823 --- /dev/null +++ b/account_liquidity_forecast/models/account_account.py @@ -0,0 +1,27 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models + + +class AccountAccount(models.Model): + _inherit = "account.account" + + def _get_open_items_at_date(self, date, only_posted_moves): + if not date or not self: + return [] + move_states = ["posted"] + if not only_posted_moves: + move_states.append("draft") + amls = self.env["account.move.line"].search( + [ + ("reconciled", "=", False), + ("account_id", "in", self.ids), + "|", + ("date_maturity", "<=", date), + "&", + ("date_maturity", "=", False), + ("date", "<=", date), + ] + ) + return amls diff --git a/account_liquidity_forecast/models/account_liquidity_forecast_planning_group.py b/account_liquidity_forecast/models/account_liquidity_forecast_planning_group.py new file mode 100644 index 00000000000..a85bbdafe37 --- /dev/null +++ b/account_liquidity_forecast/models/account_liquidity_forecast_planning_group.py @@ -0,0 +1,17 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class AccountLiquidityForecastPlanningGroup(models.Model): + _name = "account.liquidity.forecast.planning.group" + _description = "Liquidity Forecast Planning Group" + + name = fields.Char(required=True, translate=True) + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + default=lambda self: self.env.company, + ) diff --git a/account_liquidity_forecast/models/account_liquidity_forecast_planning_item.py b/account_liquidity_forecast/models/account_liquidity_forecast_planning_item.py new file mode 100644 index 00000000000..d7d49a44054 --- /dev/null +++ b/account_liquidity_forecast/models/account_liquidity_forecast_planning_item.py @@ -0,0 +1,37 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class AccountLiquidityForecastPlanningItem(models.Model): + _name = "account.liquidity.forecast.planning.item" + _description = "Liquidity Forecast Planning Item" + + name = fields.Char(required=True) + group_id = fields.Many2one( + string="Planning Group", + comodel_name="account.liquidity.forecast.planning.group", + ) + company_id = fields.Many2one( + "res.company", + string="Company", + required=True, + default=lambda self: self.env.company, + index=True, + ) + company_currency_id = fields.Many2one( + string="Company Currency", + related="company_id.currency_id", + readonly=True, + store=True, + precompute=True, + ) + amount = fields.Monetary(currency_field="company_currency_id") + direction = fields.Selection( + selection=[("in", "Incoming"), ("out", "Outgoing")], + default="in", + index=True, + ) + date = fields.Date(index=True) + expiry_date = fields.Date(index=True) diff --git a/account_liquidity_forecast/models/ir_actions_report.py b/account_liquidity_forecast/models/ir_actions_report.py new file mode 100644 index 00000000000..6e8fb6dc75e --- /dev/null +++ b/account_liquidity_forecast/models/ir_actions_report.py @@ -0,0 +1,27 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, models + + +class IrActionsReport(models.Model): + _inherit = "ir.actions.report" + + @api.model + def _prepare_liquidity_forecast_report_context(self, data): + lang = data and data.get("liquidity_forecast_report_lang") or "" + return dict(self.env.context or {}, lang=lang) if lang else False + + @api.model + def _render_qweb_html(self, report_ref, docids, data=None): + context = self._prepare_liquidity_forecast_report_context(data) + obj = self.with_context(**context) if context else self + return super(IrActionsReport, obj)._render_qweb_html( + report_ref, docids, data=data + ) + + @api.model + def _render_xlsx(self, report_ref, docids, data=None): + context = self._prepare_liquidity_forecast_report_context(data) + obj = self.with_context(**context) if context else self + return super(IrActionsReport, obj)._render_xlsx(report_ref, docids, data=data) diff --git a/account_liquidity_forecast/readme/CONTRIBUTORS.rst b/account_liquidity_forecast/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..098ba599315 --- /dev/null +++ b/account_liquidity_forecast/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `ForgeFlow `__: + + * Jordi Ballester + * Jasmin Solanki diff --git a/account_liquidity_forecast/readme/DESCRIPTION.rst b/account_liquidity_forecast/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..e69de29bb2d diff --git a/account_liquidity_forecast/report/__init__.py b/account_liquidity_forecast/report/__init__.py new file mode 100644 index 00000000000..3bfb97cb7ce --- /dev/null +++ b/account_liquidity_forecast/report/__init__.py @@ -0,0 +1,2 @@ +from . import liquidity_forecast +from . import liquidity_forecast_xlsx diff --git a/account_liquidity_forecast/report/liquidity_forecast.py b/account_liquidity_forecast/report/liquidity_forecast.py new file mode 100644 index 00000000000..2f4eaa72e33 --- /dev/null +++ b/account_liquidity_forecast/report/liquidity_forecast.py @@ -0,0 +1,516 @@ +# Copyright 2023 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import calendar +import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import _, fields, models +from odoo.tools.float_utils import float_is_zero + + +class LiquidityForecastReport(models.AbstractModel): + _name = "report.account_liquidity_forecast.liquidity_forecast" + _description = "Liquidity Forecast Report" + + def _init_period(self, line, period): + line["periods"][period["sequence"]] = { + "amount": 0.0, + "domain": "", + } + + def _complete_liquidity_forecast_lines( + self, data, liquidity_forecast_lines, line, period, periods + ): + if line["code"] == "cash_flow_line_out_payable": + self._init_period(line, period) + self._complete_cash_flow_lines_payable( + data, liquidity_forecast_lines, period, periods + ) + if line["code"] == "beginning_balance": + self._init_period(line, period) + self._complete_beginning_balance( + data, liquidity_forecast_lines, line, period + ) + if line["code"] == "ending_balance": + self._init_period(line, period) + self._complete_ending_balance(data, liquidity_forecast_lines, line, period) + if line["code"] == "total_cash_inflows": + self._init_period(line, period) + self._complete_total_cash_inflows( + data, liquidity_forecast_lines, line, period + ) + if line["code"] == "total_cash_outflows": + self._init_period(line, period) + self._complete_total_cash_outflows( + data, liquidity_forecast_lines, line, period + ) + if line["code"] == "net_cash_flow": + self._init_period(line, period) + self._complete_net_cash_flow(data, liquidity_forecast_lines, line, period) + return True + + def _complete_beginning_balance(self, data, liquidity_forecast_lines, line, period): + if period["sequence"] == 0: + domain = [ + ("account_id.account_type", "=", "asset_cash"), + ("company_id", "=", data["company_id"]), + ] + if data["only_posted_moves"]: + domain += [("move_id.state", "=", "posted")] + else: + domain += [("move_id.state", "in", ["posted", "draft"])] + + initial_balances = self.env["account.move.line"].read_group( + domain=domain, + fields=["balance:sum"], + groupby=["company_id"], + ) + initial_balance_amount = 0.0 + if initial_balances: + initial_balance = initial_balances[0] + initial_balance_amount = initial_balance["balance"] + line["periods"][period["sequence"]]["amount"] = initial_balance_amount + else: + ending_balance_line = list( + filter( + lambda d: d["code"] == "ending_balance", liquidity_forecast_lines + ) + ) + line["periods"][period["sequence"]]["amount"] = ending_balance_line[0][ + "periods" + ][period["sequence"] - 1]["amount"] + + def _complete_ending_balance(self, data, liquidity_forecast_lines, line, period): + starting_balance_line = list( + filter(lambda d: d["code"] == "beginning_balance", liquidity_forecast_lines) + )[0] + net_cash_flow_line = list( + filter(lambda d: d["code"] == "net_cash_flow", liquidity_forecast_lines) + )[0] + line["periods"][period["sequence"]]["amount"] = ( + starting_balance_line["periods"][period["sequence"]]["amount"] + + net_cash_flow_line["periods"][period["sequence"]]["amount"] + ) + + def _complete_total_cash_inflows( + self, data, liquidity_forecast_lines, line, period + ): + cash_inflow = 0.0 + cash_inflow_lines = list( + filter(lambda d: "cash_flow_line_in" in d["code"], liquidity_forecast_lines) + ) + for cash_inflow_line in cash_inflow_lines: + cash_inflow += cash_inflow_line["periods"][period["sequence"]]["amount"] + line["periods"][period["sequence"]]["amount"] = cash_inflow + + def _complete_total_cash_outflows( + self, data, liquidity_forecast_lines, line, period + ): + cash_outflow = 0.0 + cash_outflow_lines = list( + filter( + lambda d: "cash_flow_line_out" in d["code"], liquidity_forecast_lines + ) + ) + for cash_outflow_line in cash_outflow_lines: + cash_outflow += cash_outflow_line["periods"][period["sequence"]]["amount"] + line["periods"][period["sequence"]]["amount"] = cash_outflow + + def _complete_net_cash_flow(self, data, liquidity_forecast_lines, line, period): + cash_flow = 0.0 + cash_flow_lines = list( + filter(lambda d: "cash_flow_line" in d["code"], liquidity_forecast_lines) + ) + for cash_flow_line in cash_flow_lines: + cash_flow += cash_flow_line["periods"][period["sequence"]]["amount"] + line["periods"][period["sequence"]]["amount"] = cash_flow + + def _prepare_cash_flow_lines( + self, data, liquidity_forecast_lines, period, periods, accounts, date_type + ): + company_id = data.get("company_id", self.env.user.company_id.id) + company = self.env["res.company"].browse(company_id) + open_amls = accounts._get_open_items_at_date( + period["date_to"], data["only_posted_moves"] + ) + period_open_amls = self.env["account.move.line"] + rounding = company.currency_id.rounding + for open_aml in open_amls: + date_due = open_aml[date_type] or open_aml["date"] + if ( + ( + (period["sequence"] == 0 and period["date_to"] >= date_due) + or not date_due + ) + or ( + period["sequence"] > 0 + and period["date_to"] >= date_due >= period["date_from"] + ) + ) and not float_is_zero( + open_aml.amount_residual, precision_rounding=rounding + ): + period_open_amls |= open_aml + in_flows = {} + out_flows = {} + for open_aml in period_open_amls: + account = open_aml.account_id + open_item_amount = open_aml.amount_residual + if open_item_amount > 0 and account not in in_flows.keys(): + in_flows[account] = {"amount": 0.0, "move_line_ids": []} + if open_item_amount < 0 and account not in out_flows.keys(): + out_flows[account] = {"amount": 0.0, "move_line_ids": []} + if open_item_amount > 0: + in_flows[account]["amount"] += open_item_amount + in_flows[account]["move_line_ids"].append(open_aml.id) + else: + out_flows[account]["amount"] += open_item_amount + out_flows[account]["move_line_ids"].append(open_aml.id) + for account in in_flows.keys(): + in_cash_flow_lines = list( + filter( + lambda d: "cash_flow_line_%s_account_%s" % ("in", account.code) + in d["code"], + liquidity_forecast_lines, + ) + ) + if not in_cash_flow_lines: + in_cash_flow_line = { + "code": "cash_flow_line_%s_account_%s" % ("in", account.code), + "type": "amount", + "level": "detail", + "model": "account.move.line", + "title": account.display_name, + "periods": {}, + "sequence": 1100, + } + for p in periods: + in_cash_flow_line["periods"][p["sequence"]] = { + "amount": 0.0, + "domain": "", + } + liquidity_forecast_lines.append(in_cash_flow_line) + else: + in_cash_flow_line = in_cash_flow_lines[0] + in_cash_flow_line["periods"][period["sequence"]]["amount"] += in_flows[ + account + ]["amount"] + in_cash_flow_line["periods"][period["sequence"]]["domain"] = [ + ("id", "in", in_flows[account]["move_line_ids"]) + ] + for account in out_flows.keys(): + out_cash_flow_lines = list( + filter( + lambda d: "cash_flow_line_%s_account_%s" % ("out", account.code) + in d["code"], + liquidity_forecast_lines, + ) + ) + if not out_cash_flow_lines: + out_cash_flow_line = { + "code": "cash_flow_line_%s_account_%s" % ("out", account.code), + "type": "amount", + "level": "detail", + "model": "account.move.line", + "title": account.display_name, + "periods": {}, + "sequence": 3100, + } + for p in periods: + out_cash_flow_line["periods"][p["sequence"]] = { + "amount": 0.0, + "domain": "", + } + liquidity_forecast_lines.append(out_cash_flow_line) + else: + out_cash_flow_line = out_cash_flow_lines[0] + out_cash_flow_line["periods"][period["sequence"]]["amount"] += out_flows[ + account + ]["amount"] + out_cash_flow_line["periods"][period["sequence"]]["domain"] = [ + ("id", "in", out_flows[account]["move_line_ids"]) + ] + + def _prepare_cash_flow_lines_move_line( + self, + data, + liquidity_forecast_lines, + period, + periods, + ): + accounts = self.env["account.account"].search( + [ + ("account_type", "in", ["asset_receivable", "liability_payable"]), + ("company_id", "=", data["company_id"]), + ] + ) + self._prepare_cash_flow_lines( + data, liquidity_forecast_lines, period, periods, accounts, "date_maturity" + ) + + def _prepare_cash_flow_lines_payment( + self, data, liquidity_forecast_lines, period, periods + ): + company_id = data.get("company_id", self.env.user.company_id.id) + company = self.env["res.company"].browse(company_id) + bank_journals = self.env["account.journal"].search( + [ + ("type", "=", "bank"), + ("company_id", "=", company.id), + ] + ) + accounts = self.env["account.account"] + for bank_journal in bank_journals: + accounts += bank_journal._get_journal_inbound_outstanding_payment_accounts() + accounts += ( + bank_journal._get_journal_outbound_outstanding_payment_accounts() + ) + self._prepare_cash_flow_lines( + data, liquidity_forecast_lines, period, periods, accounts, "date" + ) + + def _prepare_cash_flow_lines_payment_planning_item( + self, data, liquidity_forecast_lines, period, periods, direction="in" + ): + company_id = data.get("company_id", self.env.user.company_id.id) + domain = [ + ("company_id", "=", company_id), + ("date", "<=", period["date_to"]), + ("direction", "=", direction), + ("expiry_date", ">=", fields.Date.today()), + ] + if period["sequence"] > 0: + domain += [("date", ">=", period["date_from"])] + totals = self.env["account.liquidity.forecast.planning.item"].read_group( + domain=domain, + fields=["amount:sum"], + groupby=["group_id"], + ) + for total in totals: + group_id = total["group_id"] and total["group_id"][0] or False + group_name = "" + group = self.env["account.liquidity.forecast.planning.group"] + if group_id: + group = self.env["account.liquidity.forecast.planning.group"].browse( + group_id + ) + group_name = group and group.name or "" + title = group_name or _("Forecast Planning Items") + code = "cash_flow_line_%s_planned_item" % direction + if group: + code = "%s_%s" % (code, group_name) + cash_flow_lines = list( + filter( + lambda d: code in d["code"], + liquidity_forecast_lines, + ) + ) + if not cash_flow_lines: + cash_flow_line = { + "code": code, + "type": "amount", + "level": "detail", + "model": "account.liquidity.forecast.planning.item", + "title": title, + "periods": {}, + } + if direction == "in": + cash_flow_line["sequence"] = 1200 + else: + cash_flow_line["sequence"] = 3200 + for p in periods: + cash_flow_line["periods"][p["sequence"]] = { + "amount": 0.0, + "domain": "", + } + liquidity_forecast_lines.append(cash_flow_line) + else: + cash_flow_line = cash_flow_lines[0] + sign = direction == "in" and 1 or -1 + cash_flow_line["periods"][period["sequence"]]["amount"] += ( + total["amount"] * sign + ) + cash_flow_line["periods"][period["sequence"]]["domain"] = total["__domain"] + + def _prepare_cash_flow_lines_payment_planning_item_in( + self, data, liquidity_forecast_lines, period, periods + ): + self._prepare_cash_flow_lines_payment_planning_item( + data, liquidity_forecast_lines, period, periods, direction="in" + ) + + def _prepare_cash_flow_lines_payment_planning_item_out( + self, data, liquidity_forecast_lines, period, periods + ): + self._prepare_cash_flow_lines_payment_planning_item( + data, liquidity_forecast_lines, period, periods, direction="out" + ) + + def _generate_periods(self, data): + date_from = fields.Date.from_string(data["date_from"]) + date_to = fields.Date.from_string(data["date_to"]) + period_length = data["period_length"] + periods = [] + current_date = date_from + sequence = 0 + period_name = { + "days": _("Day"), + "weeks": _("Week"), + "months": _("Month"), + } + while current_date <= date_to: + # Define the period as a dictionary with sequence and name + if sequence == 0: + name = "%s %s" % (_("Current"), period_name[period_length]) + else: + name = "%s %s" % (period_name[period_length], sequence) + period = { + "sequence": sequence, + "name": name, + "date_from": current_date, + } + # Move to the next week + if period_length == "days": + current_date += relativedelta(days=1) + elif period_length == "weeks": + weekday = current_date.weekday() + days_until_end_of_week = 6 - weekday + end_of_week = current_date + datetime.timedelta( + days=days_until_end_of_week + ) + current_date = end_of_week + elif period_length == "months": + _x, last_day = calendar.monthrange( + current_date.year, current_date.month + ) + end_of_month = datetime.date( + current_date.year, current_date.month, last_day + ) + current_date = end_of_month + period["date_to"] = current_date + periods.append(period) + sequence += 1 + current_date += datetime.timedelta(days=1) + return periods + + def _prepare_liquidity_forecast_lines_period( + self, data, liquidity_forecast_lines, period, periods + ): + """Extend with your own methods""" + self._prepare_cash_flow_lines_move_line( + data, liquidity_forecast_lines, period, periods + ) + self._prepare_cash_flow_lines_payment( + data, liquidity_forecast_lines, period, periods + ) + self._prepare_cash_flow_lines_payment_planning_item_in( + data, liquidity_forecast_lines, period, periods + ) + self._prepare_cash_flow_lines_payment_planning_item_out( + data, liquidity_forecast_lines, period, periods + ) + return True + + def _prepare_liquidity_forecast_lines(self, data): + periods = self._generate_periods(data) + liquidity_forecast_lines = [ + { + "code": "beginning_balance", + "type": "amount", + "level": "heading", + "model": "", + "title": _("BEGINNING BALANCE"), + "sequence": 10, + "periods": {}, + }, + { + "code": "cash_inflows", + "type": "text", + "level": "heading", + "model": "", + "title": _("CASH INFLOWS"), + "sequence": 1000, + "periods": {}, + }, + { + "code": "total_cash_inflows", + "type": "amount", + "level": "heading", + "model": "", + "title": _("Total Cash Inflows"), + "sequence": 2000, + "periods": {}, + }, + { + "code": "cash_outflows", + "type": "text", + "level": "heading", + "model": "", + "title": _("CASH OUTFLOWS"), + "sequence": 3000, + "periods": {}, + }, + { + "code": "total_cash_outflows", + "type": "amount", + "level": "heading", + "domain": "", + "model": "", + "title": _("Total Cash Outflows"), + "sequence": 4000, + "periods": {}, + }, + { + "code": "net_cash_flow", + "type": "amount", + "level": "heading", + "domain": "", + "model": "", + "title": _("NET CASH FLOW"), + "sequence": 5000, + "periods": {}, + }, + { + "code": "ending_balance", + "type": "amount", + "level": "heading", + "domain": "", + "model": "", + "title": _("ENDING BALANCE"), + "sequence": 6000, + "periods": {}, + }, + ] + for period in periods: + self._prepare_liquidity_forecast_lines_period( + data, liquidity_forecast_lines, period, periods + ) + for line in liquidity_forecast_lines: + self._complete_liquidity_forecast_lines( + data, liquidity_forecast_lines, line, period, periods + ) + liquidity_forecast_lines = sorted( + liquidity_forecast_lines, key=lambda x: x["sequence"] + ) + return liquidity_forecast_lines, periods + + def _get_report_values(self, docids, data): + wizard_id = data["wizard_id"] + company = self.env["res.company"].browse(data["company_id"]) + liquidity_forecast_lines, periods = self._prepare_liquidity_forecast_lines(data) + + return { + "doc_ ids": [wizard_id], + "doc_model": "liquidity.forecast.report.wizard", + "docs": self.env["account.liquidity.forecast.report.wizard"].browse( + wizard_id + ), + "company_name": company.display_name, + "company_currency": company.currency_id, + "currency_name": company.currency_id.name, + "date_from": data["date_from"], + "date_to": data["date_to"], + "only_posted_moves": data["only_posted_moves"], + "liquidity_forecast_lines": liquidity_forecast_lines, + "periods": periods, + } diff --git a/account_liquidity_forecast/report/liquidity_forecast_xlsx.py b/account_liquidity_forecast/report/liquidity_forecast_xlsx.py new file mode 100644 index 00000000000..0679eec2146 --- /dev/null +++ b/account_liquidity_forecast/report/liquidity_forecast_xlsx.py @@ -0,0 +1,175 @@ +# Author: Jasmin Solanki +# Copyright 2023 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _, models + +from odoo.addons.report_xlsx_helper.report.report_xlsx_format import FORMATS + + +def copy_format(book, fmt): + properties = [f[4:] for f in dir(fmt) if f[0:4] == "set_"] + dft_fmt = book.add_format() + return book.add_format( + { + k: v + for k, v in fmt.__dict__.items() + if k in properties and dft_fmt.__dict__[k] != v + } + ) + + +class LiquidityForecastXslx(models.AbstractModel): + _name = "report.report_liquidity_forecast_xlsx" + _description = "Liquidity Forecast Report" + _inherit = "report.report_xlsx.abstract" + + def _get_report_name(self, report, data=False): + company_id = data.get("company_id", False) + report_name = _("Liquidity Forecast") + if company_id: + company = self.env["res.company"].browse(company_id) + suffix = " - {} - {}".format(company.name, company.currency_id.name) + report_name = report_name + suffix + return report_name + + def excel_column_name(self, column_number): + alphabet = "" + while column_number > 0: + remainder = (column_number - 1) % 26 + alphabet = chr(65 + remainder) + alphabet + column_number = (column_number - 1) // 26 + return alphabet + + def _size_columns(self, sheet, total_col_count, data): + for i in range(total_col_count + 1): + if i == 0: + sheet.set_column("A:A", 30) + else: + sheet.set_column( + "%(col)s:%(col)s" % ({"col": self.excel_column_name(i + 1)}), 15 + ) + + def generate_xlsx_report(self, workbook, data, objects): + self._define_formats(workbook) + report_data = self.env[ + "report.account_liquidity_forecast.liquidity_forecast" + ]._get_report_values(objects.ids, data) + FORMATS["format_ws_title_center"] = workbook.add_format( + {"bold": True, "font_size": 14, "align": "center"} + ) + company_id = data.get("company_id", False) + if company_id: + company = self.env["res.company"].browse(company_id) + else: + company = self.env.user.company_id + currency = report_data["company_currency"] + if currency.position == "after": + money_string = "#,##0.%s " % ( + "0" * currency.decimal_places + ) + "[${}]".format(currency.symbol) + elif currency.position == "before": + money_string = "[${}]".format(currency.symbol) + " #,##0.%s" % ( + "0" * currency.decimal_places + ) + FORMATS["money_format"] = workbook.add_format({"num_format": money_string}) + FORMATS["money_format_bold"] = workbook.add_format( + {"num_format": money_string, "bold": True} + ) + FORMATS["format_center_bold"].text_wrap = 1 + FORMATS["format_center"].text_wrap = 1 + sheet = workbook.add_worksheet(_("Liquidity Forecast")) + sheet.set_landscape() + total_col_count = len(report_data["periods"]) + self._size_columns(sheet, total_col_count, data) + row_pos = 0 + sheet.merge_range( + row_pos, + 0, + row_pos, + 4, + _("Liquidity Forecast - %(company_name)s - %(currency_name)s") + % ( + { + "company_name": company.display_name, + "currency_name": report_data["currency_name"], + } + ), + FORMATS["format_ws_title_center"], + ) + row_pos += 1 + sheet.merge_range( + row_pos, + 0, + row_pos, + 2, + _("Date range filter"), + FORMATS["format_theader_yellow_center"], + ) + sheet.merge_range( + row_pos, + 3, + row_pos, + 4, + _("Target moves filter"), + FORMATS["format_theader_yellow_center"], + ) + row_pos += 1 + sheet.merge_range( + row_pos, + 0, + row_pos, + 2, + "From %s To %s" % (report_data["date_from"], report_data["date_to"]), + FORMATS["format_center"], + ) + sheet.merge_range( + row_pos, + 3, + row_pos, + 4, + "All posted entries" if report_data["only_posted_moves"] else "All entries", + FORMATS["format_center"], + ) + row_pos += 1 + sheet.write( + row_pos, + 0, + "Items", + FORMATS["format_center_bold"], + ) + col = 1 + for period in report_data["periods"]: + sheet.write( + row_pos, + col, + period["name"], + FORMATS["format_center_bold"], + ) + col += 1 + row_pos += 1 + for line in report_data["liquidity_forecast_lines"]: + sheet.write( + row_pos, + 0, + line["title"], + ( + FORMATS["format_left_bold"] + if line.get("level") == "heading" + else FORMATS["format_left"] + ), + ) + col = 1 + for period in line["periods"].values(): + sheet.write( + row_pos, + col, + period["amount"], + ( + FORMATS["money_format_bold"] + if line.get("level") == "heading" + else FORMATS["money_format"] + ), + ) + col += 1 + row_pos += 1 diff --git a/account_liquidity_forecast/report/templates/layouts.xml b/account_liquidity_forecast/report/templates/layouts.xml new file mode 100644 index 00000000000..5aa1a94bf17 --- /dev/null +++ b/account_liquidity_forecast/report/templates/layouts.xml @@ -0,0 +1,32 @@ + + + + diff --git a/account_liquidity_forecast/report/templates/liquidity_forecast.xml b/account_liquidity_forecast/report/templates/liquidity_forecast.xml new file mode 100644 index 00000000000..2bf6ea3c0dd --- /dev/null +++ b/account_liquidity_forecast/report/templates/liquidity_forecast.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + diff --git a/account_liquidity_forecast/reports.xml b/account_liquidity_forecast/reports.xml new file mode 100644 index 00000000000..92ed96b5e5d --- /dev/null +++ b/account_liquidity_forecast/reports.xml @@ -0,0 +1,42 @@ + + + + + Liquidity Forecast report qweb paperformat + + custom + 297 + 210 + Landscape + 12 + 8 + 5 + 5 + + 10 + 110 + + + + Liquidity Forecast + liquidity.forecast.report.wizard + qweb-pdf + account_liquidity_forecast.liquidity_forecast + account_liquidity_forecast.liquidity_forecast + + + + Liquidity Forecast + liquidity.forecast.report.wizard + qweb-html + account_liquidity_forecast.liquidity_forecast + account_liquidity_forecast.liquidity_forecast + + + Liquidity Forecast + account.liquidity.forecast.report.wizard + report_liquidity_forecast_xlsx + xlsx + report_liquidity_forecast + + diff --git a/account_liquidity_forecast/security/ir.model.access.csv b/account_liquidity_forecast/security/ir.model.access.csv new file mode 100644 index 00000000000..b419d35a7f6 --- /dev/null +++ b/account_liquidity_forecast/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_liquidity_forecast_planning_group_user,access_liquidity_forecast_planning_group_user,model_account_liquidity_forecast_planning_group,account.group_account_user,1,0,0,0 +access_liquidity_forecast_planning_group_manager,access_liquidity_forecast_planning_group_manager,model_account_liquidity_forecast_planning_group,account.group_account_manager,1,1,1,1 +access_liquidity_forecast_planning_item,access_liquidity_forecast_planning_item,model_account_liquidity_forecast_planning_item,account.group_account_user,1,1,1,1 +access_liquidity_forecast_report_wizard,access_liquidity_forecast_report_wizard,model_account_liquidity_forecast_report_wizard,account.group_account_user,1,1,1,1 diff --git a/account_liquidity_forecast/security/security.xml b/account_liquidity_forecast/security/security.xml new file mode 100644 index 00000000000..5117914369c --- /dev/null +++ b/account_liquidity_forecast/security/security.xml @@ -0,0 +1,12 @@ + + + + + Liquidity Foreast Planning Item + + ['|',('company_id','=',False),('company_id', 'in', company_ids)] + + + diff --git a/account_liquidity_forecast/static/description/icon.png b/account_liquidity_forecast/static/description/icon.png new file mode 100644 index 00000000000..272d0294347 Binary files /dev/null and b/account_liquidity_forecast/static/description/icon.png differ diff --git a/account_liquidity_forecast/static/description/index.html b/account_liquidity_forecast/static/description/index.html new file mode 100644 index 00000000000..74a1388f843 --- /dev/null +++ b/account_liquidity_forecast/static/description/index.html @@ -0,0 +1,424 @@ + + + + + + +Account Liquidity Forecast + + + +
+

Account Liquidity Forecast

+ + +

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

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+
    +
  • ForgeFlow:
      +
    • Jordi Ballester
    • +
    • Jasmin Solanki
    • +
    +
  • +
+
+
+

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.

+

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_liquidity_forecast/static/src/css/report.css b/account_liquidity_forecast/static/src/css/report.css new file mode 100644 index 00000000000..3a17fd724e1 --- /dev/null +++ b/account_liquidity_forecast/static/src/css/report.css @@ -0,0 +1,116 @@ +a { + color: #00337b; +} +.act_as_table { + display: table !important; + background-color: white; +} +.act_as_row { + display: table-row !important; + page-break-inside: avoid; +} +.act_as_cell { + display: table-cell !important; + page-break-inside: avoid; +} +.act_as_thead { + display: table-header-group !important; +} +.act_as_tbody { + display: table-row-group !important; +} +.list_table, +.data_table, +.totals_table { + width: 100% !important; +} +.act_as_row.labels { + background-color: #f0f0f0 !important; +} +.list_table, +.data_table, +.totals_table, +.list_table .act_as_row { + border-left: 0px; + border-right: 0px; + text-align: center; + font-size: 10px; + padding-right: 3px; + padding-left: 3px; + padding-top: 2px; + padding-bottom: 2px; + border-collapse: collapse; +} +.totals_table { + font-weight: bold; + text-align: center; +} +.list_table .act_as_row.labels, +.list_table .act_as_row.initial_balance, +.list_table .act_as_row.lines { + border-color: grey !important; + border-bottom: 1px solid lightGrey !important; +} +.data_table .act_as_cell { + border: 1px solid lightGrey; + text-align: center; +} +.data_table .act_as_cell, +.list_table .act_as_cell, +.totals_table .act_as_cell { + word-wrap: break-word; +} +.data_table .act_as_row.labels, +.totals_table .act_as_row.labels { + font-weight: bold; +} +.initial_balance .act_as_cell { + font-style: italic; +} +.account_title { + font-size: 11px; + font-weight: bold; +} +.account_title.labels { + background-color: #f0f0f0 !important; +} +.act_as_cell.amount { + word-wrap: normal; + text-align: right; +} +.act_as_cell.left { + text-align: left; +} +.act_as_cell.right { + text-align: right; +} +/*.list_table .act_as_cell {*/ +/* border-right:1px solid lightGrey; uncomment to active column lines */ +/*}*/ +.list_table .act_as_cell.first_column { + padding-left: 0px; + /* border-left:1px solid lightGrey; uncomment to active column lines */ +} +.overflow_ellipsis { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} +.custom_footer { + font-size: 7px !important; +} +.page_break { + page-break-inside: avoid; +} + +.button_row { + padding-bottom: 10px; +} + +.o_account_financial_reports_page { + padding-top: 10px; + width: 90%; + margin-right: auto; + margin-left: auto; + font-family: Helvetica, Arial; +} diff --git a/account_liquidity_forecast/static/src/js/report.esm.js b/account_liquidity_forecast/static/src/js/report.esm.js new file mode 100644 index 00000000000..535d43c0859 --- /dev/null +++ b/account_liquidity_forecast/static/src/js/report.esm.js @@ -0,0 +1,73 @@ +/** @odoo-module */ + +import {useComponent, useEffect} from "@odoo/owl"; + +function toTitleCase(str) { + return str + .replaceAll(".", " ") + .replace( + /\w\S*/g, + (txt) => `${txt.charAt(0).toUpperCase()}${txt.substr(1).toLowerCase()}` + ); +} + +function enrich(component, targetElement, selector, isIFrame = false) { + let doc = window.document; + let contentDocument = targetElement; + + // If we are in an iframe, we need to take the right document + // both for the element and the doc + if (isIFrame) { + contentDocument = targetElement.contentDocument; + doc = contentDocument; + } + + // If there are selector, we may have multiple blocks of code to enrich + const targets = []; + if (selector) { + targets.push(...contentDocument.querySelectorAll(selector)); + } else { + targets.push(contentDocument); + } + + // Search the elements with the selector, update them and bind an action. + for (const currentTarget of targets) { + const elementsToWrap = currentTarget.querySelectorAll("[res-model][domain]"); + for (const element of elementsToWrap.values()) { + const wrapper = doc.createElement("a"); + wrapper.setAttribute("href", "#"); + wrapper.addEventListener("click", (ev) => { + ev.preventDefault(); + component.env.services.action.doAction({ + type: "ir.actions.act_window", + res_model: element.getAttribute("res-model"), + domain: element.getAttribute("domain"), + name: toTitleCase(element.getAttribute("res-model")), + views: [ + [false, "list"], + [false, "form"], + ], + }); + }); + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + } + } +} + +export function useEnrichWithActionLinks(ref, selector = null) { + const comp = useComponent(); + useEffect( + (element) => { + // If we get an iframe, we need to wait until everything is loaded + if (element.matches("iframe")) { + element.addEventListener("load", () => + enrich(comp, element, selector, true) + ); + } else { + enrich(comp, element, selector); + } + }, + () => [ref.el] + ); +} diff --git a/account_liquidity_forecast/static/src/js/report_action.esm.js b/account_liquidity_forecast/static/src/js/report_action.esm.js new file mode 100644 index 00000000000..455a26c2209 --- /dev/null +++ b/account_liquidity_forecast/static/src/js/report_action.esm.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ +import {ReportAction} from "@web/webclient/actions/reports/report_action"; +import {patch} from "web.utils"; +import {useEnrichWithActionLinks} from "./report.esm"; + +const MODULE_NAME = "account_liquidity_forecast"; + +patch(ReportAction.prototype, "account_liquidity_forecast.ReportAction", { + setup() { + this._super.apply(this, arguments); + this.isAccountFinancialReport = this.props.report_name.startsWith( + `${MODULE_NAME}.` + ); + useEnrichWithActionLinks(this.iframe); + }, + + export() { + this.action.doAction({ + type: "ir.actions.report", + report_type: "xlsx", + report_name: this._get_xlsx_name(this.props.report_name), + report_file: this._get_xlsx_name(this.props.report_file), + data: this.props.data || {}, + context: this.props.context || {}, + display_name: this.title, + }); + }, + + /** + * @param {String} str + * @returns {String} + */ + _get_xlsx_name(str) { + if (!_.isString(str)) { + return str; + } + return `report_liquidity_forecast_xlsx`; + }, +}); diff --git a/account_liquidity_forecast/views/account_liquidity_forecast_planning_group_views.xml b/account_liquidity_forecast/views/account_liquidity_forecast_planning_group_views.xml new file mode 100644 index 00000000000..a0565d0477a --- /dev/null +++ b/account_liquidity_forecast/views/account_liquidity_forecast_planning_group_views.xml @@ -0,0 +1,48 @@ + + + + + Liquidity Forecast Planning Group Form + account.liquidity.forecast.planning.group + +
+ + +
+
+
+ + + Liquidity Forecast Planning Group List + account.liquidity.forecast.planning.group + + + + + + + + + + Liquidity Forecast Planning Groups + account.liquidity.forecast.planning.group + tree,form + + +
diff --git a/account_liquidity_forecast/views/account_liquidity_forecast_planning_item_views.xml b/account_liquidity_forecast/views/account_liquidity_forecast_planning_item_views.xml new file mode 100644 index 00000000000..6378cf5c86b --- /dev/null +++ b/account_liquidity_forecast/views/account_liquidity_forecast_planning_item_views.xml @@ -0,0 +1,64 @@ + + + + + Liquidity Forecast Planning Item Form + account.liquidity.forecast.planning.item + +
+ + +
+
+
+ + + Liquidity Forecast Planning Item List + account.liquidity.forecast.planning.item + + + + + + + + + + + + + + + Liquidity Forecast Planning Groups + account.liquidity.forecast.planning.item + tree,form + + +
diff --git a/account_liquidity_forecast/views/report_liquidity_forecast_views.xml b/account_liquidity_forecast/views/report_liquidity_forecast_views.xml new file mode 100644 index 00000000000..8753c4035e5 --- /dev/null +++ b/account_liquidity_forecast/views/report_liquidity_forecast_views.xml @@ -0,0 +1,9 @@ + + + + diff --git a/account_liquidity_forecast/wizards/__init__.py b/account_liquidity_forecast/wizards/__init__.py new file mode 100644 index 00000000000..78edaf860e3 --- /dev/null +++ b/account_liquidity_forecast/wizards/__init__.py @@ -0,0 +1 @@ +from . import account_liquidity_forecast_wizard diff --git a/account_liquidity_forecast/wizards/account_liquidity_forecast_wizard.py b/account_liquidity_forecast/wizards/account_liquidity_forecast_wizard.py new file mode 100644 index 00000000000..da3994d293e --- /dev/null +++ b/account_liquidity_forecast/wizards/account_liquidity_forecast_wizard.py @@ -0,0 +1,81 @@ +# Copyright 2023 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class LiquidityForecastReportWizard(models.TransientModel): + + _name = "account.liquidity.forecast.report.wizard" + _description = "Liquidity Forecast Report Wizard" + + company_id = fields.Many2one( + comodel_name="res.company", + default=lambda self: self.env.company.id, + required=False, + string="Company", + ) + period_length = fields.Selection( + selection=[ + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ], + default="days", + required=True, + ) + date_to = fields.Date(required=True, default=fields.Date.today) + target_move = fields.Selection( + [("posted", "All Posted Entries"), ("all", "All Entries")], + string="Target Moves", + required=True, + default="posted", + ) + + def button_export_html(self): + self.ensure_one() + report_type = "qweb-html" + return self._export(report_type) + + def button_export_pdf(self): + self.ensure_one() + report_type = "qweb-pdf" + return self._export(report_type) + + def button_export_xlsx(self): + self.ensure_one() + report_type = "xlsx" + return self._export(report_type) + + def _export(self, report_type): + """Default export is PDF.""" + return self._print_report(report_type) + + def _print_report(self, report_type): + self.ensure_one() + data = self._prepare_report_liquidity_forecast() + if report_type == "xlsx": + report_name = "report_liquidity_forecast_xlsx" + else: + report_name = "account_liquidity_forecast.liquidity_forecast" + return ( + self.env["ir.actions.report"] + .search( + [("report_name", "=", report_name), ("report_type", "=", report_type)], + limit=1, + ) + .report_action(self, data=data) + ) + + def _prepare_report_liquidity_forecast(self): + self.ensure_one() + return { + "wizard_id": self.id, + "date_from": fields.Date.today(), + "date_to": self.date_to, + "only_posted_moves": self.target_move == "posted", + "company_id": self.company_id.id, + "res_company": self.company_id, + "period_length": self.period_length, + "liquidity_forecast_report_lang": self.env.lang, + } diff --git a/account_liquidity_forecast/wizards/account_liquidity_forecast_wizard_views.xml b/account_liquidity_forecast/wizards/account_liquidity_forecast_wizard_views.xml new file mode 100644 index 00000000000..f37f3ccfb83 --- /dev/null +++ b/account_liquidity_forecast/wizards/account_liquidity_forecast_wizard_views.xml @@ -0,0 +1,61 @@ + + + + Liquidity Forecast + account.liquidity.forecast.report.wizard + +
+ + + +
+ + + + + + + + + +
+
+
+
+
+
+
+
+ + Liquidity Forecast + account.liquidity.forecast.report.wizard + form + + new + +
diff --git a/setup/account_liquidity_forecast/odoo/addons/account_liquidity_forecast b/setup/account_liquidity_forecast/odoo/addons/account_liquidity_forecast new file mode 120000 index 00000000000..99cdec0ce30 --- /dev/null +++ b/setup/account_liquidity_forecast/odoo/addons/account_liquidity_forecast @@ -0,0 +1 @@ +../../../../account_liquidity_forecast \ No newline at end of file diff --git a/setup/account_liquidity_forecast/setup.py b/setup/account_liquidity_forecast/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_liquidity_forecast/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)