diff --git a/mis_builder_analytic_source/README.rst b/mis_builder_analytic_source/README.rst new file mode 100644 index 000000000..79dbd6988 --- /dev/null +++ b/mis_builder_analytic_source/README.rst @@ -0,0 +1,150 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========================== +MIS Builder Analytic Source +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2915ef0217f40fd7a61797aef360a8b9d7c58a347f49489cd9cdbe609ccb5fdb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-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%2Fmis--builder-lightgray.png?logo=github + :target: https://github.com/OCA/mis-builder/tree/18.0/mis_builder_analytic_source + :alt: OCA/mis-builder +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/mis-builder-18-0/mis-builder-18-0-mis_builder_analytic_source + :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/mis-builder&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Since Odoo 16 a journal item no longer has one analytic account: it has +an ``analytic_distribution`` that can split it between several accounts. +The analytic filter of a MIS report on journal items keeps the **full** +balance of every item whose distribution contains the analytic account, +so a P&L per project, contract or department is overstated whenever an +item is split. Costs that only exist as analytic lines, such as +timesheets, are not seen at all. + +This module adds ``mis.analytic.move.line``, a SQL view over +``account.analytic.line`` with the fields MIS Builder expects from a +move lines source (``account_id``, ``company_id``, ``date``, ``debit``, +``credit``, ``parent_state``, ``analytic_distribution``). Analytic lines +already carry the prorated amount, so a MIS report that uses this model +as its move lines source gives the exact figures per analytic account, +including timesheet costs. + +Each analytic line holds one account per root analytic plan. The view +exposes them as one distribution key +(``"project_account,department_account"`` at 100%), the same format as +journal items, so the analytic filter works for the accounts of every +plan and a line is never counted twice. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Analytic lines without a financial account (timesheets, manual analytic +entries) are reported on the company's *MIS Analytic Fallback Account* +(Invoicing > Configuration > Settings, Analytics section). When it is +not set, the first *Cost of Revenue* account of the company (by code) is +used. + +Usage +===== + +1. In the MIS report template, set *Move lines source* to *Analytic line + as MIS move line*. +2. Use the analytic filter of the report instance, or an analytic domain + per column. +3. Expressions can also filter on the origin of the line, for example + ``balp[5%][('line_origin', '=', 'timesheet')]`` or + ``-balp[6%][('category', '=', 'invoice')]``. + +The sign follows journal items: a positive analytic amount (revenue) is +a credit and a negative one (cost) is a debit, so the usual +``-balp[...]`` expressions for income keep working. + +Known issues / Roadmap +====================== + +- Analytic lines are always in the company currency; amounts in another + currency are not converted. +- Draft journal items have no analytic lines, so the *posted and draft* + target move option shows the same figures as *posted only*. Manual + analytic entries and timesheets count as posted. +- The view is rebuilt when an analytic plan is created, moved or + deleted, because each root plan is a column of the analytic lines. + +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 +------- + +* KMEE + +Contributors +------------ + +- `KMEE `__: + + - Luis Felipe Mileo mileo@kmee.com.br + +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-mileo| image:: https://github.com/mileo.png?size=40px + :target: https://github.com/mileo + :alt: mileo + +Current `maintainer `__: + +|maintainer-mileo| + +This module is part of the `OCA/mis-builder `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mis_builder_analytic_source/__init__.py b/mis_builder_analytic_source/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/mis_builder_analytic_source/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mis_builder_analytic_source/__manifest__.py b/mis_builder_analytic_source/__manifest__.py new file mode 100644 index 000000000..d7952b42a --- /dev/null +++ b/mis_builder_analytic_source/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 KMEE +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "MIS Builder Analytic Source", + "summary": "Analytic lines as the move lines source of MIS reports, so " + "analytic filters give the prorated amounts", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "category": "Reporting", + "author": "KMEE, Odoo Community Association (OCA)", + "maintainers": ["mileo"], + "website": "https://github.com/OCA/mis-builder", + "license": "AGPL-3", + "depends": ["mis_builder", "account"], + "data": [ + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/mis_analytic_move_line_views.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, +} diff --git a/mis_builder_analytic_source/models/__init__.py b/mis_builder_analytic_source/models/__init__.py new file mode 100644 index 000000000..2739a6f8a --- /dev/null +++ b/mis_builder_analytic_source/models/__init__.py @@ -0,0 +1,4 @@ +from . import account_analytic_plan +from . import mis_analytic_move_line +from . import res_company +from . import res_config_settings diff --git a/mis_builder_analytic_source/models/account_analytic_plan.py b/mis_builder_analytic_source/models/account_analytic_plan.py new file mode 100644 index 000000000..93c2f7ed0 --- /dev/null +++ b/mis_builder_analytic_source/models/account_analytic_plan.py @@ -0,0 +1,30 @@ +# Copyright 2026 KMEE +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, models + + +class AccountAnalyticPlan(models.Model): + _inherit = "account.analytic.plan" + + def _mis_analytic_refresh_view(self): + # each root plan is a column of account_analytic_line: creating, moving or + # removing a plan adds or drops a column (DROP COLUMN ... CASCADE also drops + # the view), so the view is rebuilt afterwards + self.env["mis.analytic.move.line"].init() + + @api.model_create_multi + def create(self, vals_list): + plans = super().create(vals_list) + self._mis_analytic_refresh_view() + return plans + + def write(self, vals): + res = super().write(vals) + if {"parent_id", "name"} & set(vals): + self._mis_analytic_refresh_view() + return res + + def unlink(self): + res = super().unlink() + self._mis_analytic_refresh_view() + return res diff --git a/mis_builder_analytic_source/models/mis_analytic_move_line.py b/mis_builder_analytic_source/models/mis_analytic_move_line.py new file mode 100644 index 000000000..74886507c --- /dev/null +++ b/mis_builder_analytic_source/models/mis_analytic_move_line.py @@ -0,0 +1,181 @@ +# Copyright 2026 KMEE +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""SQL view exposing account.analytic.line with the shape MIS Builder expects from a +move lines source (account_id, company_id, date, debit, credit, parent_state and an +analytic_distribution). + +Why: MIS Builder's analytic filter on account.move.line keeps the FULL balance of any +line that merely contains the analytic account in its distribution. Analytic lines are +already prorated (one line per distribution key, with the distributed amount), so a +report whose move_lines_source is this model gives exact figures per analytic account, +and it also sees costs that never hit the general ledger (timesheets and manual +analytic entries). + +Each analytic line carries one account per root analytic plan (one column per plan). +The view exposes them as a single distribution key "id1,id2" at 100%, the format used +by journal items, so filtering on the account of any plan keeps working without +counting the line twice. + +Sign convention follows account.move.line: analytic amount > 0 (revenue) becomes credit, +amount < 0 (cost) becomes debit, so ``balance = debit - credit = -amount`` and the usual +``-balp[...]`` expressions for income keep working. +""" + +from odoo import fields, models +from odoo.tools import SQL +from odoo.tools.sql import column_exists, create_column, drop_view_if_exists + + +class MisAnalyticMoveLine(models.Model): + _name = "mis.analytic.move.line" + _description = "Analytic line as MIS move line" + _inherit = "analytic.mixin" + _auto = False + _order = "date desc, id desc" + + name = fields.Char(readonly=True) + date = fields.Date(readonly=True) + company_id = fields.Many2one("res.company", readonly=True) + company_currency_id = fields.Many2one( + "res.currency", related="company_id.currency_id", readonly=True + ) + account_id = fields.Many2one( + "account.account", string="Financial Account", readonly=True + ) + analytic_account_id = fields.Many2one( + "account.analytic.account", + string="Project Analytic Account", + readonly=True, + help="Account of the project plan. The accounts of the other plans are in " + "the analytic distribution.", + ) + partner_id = fields.Many2one("res.partner", readonly=True) + product_id = fields.Many2one("product.product", readonly=True) + category = fields.Selection( + selection=lambda self: ( + self.env["account.analytic.line"]._fields["category"].selection + ), + readonly=True, + ) + line_origin = fields.Selection( + selection=[ + ("journal", "Journal Item"), + ("timesheet", "Timesheet"), + ("manual", "Manual Analytic Entry"), + ], + readonly=True, + help="Journal Item: analytic line generated by a posted journal item " + "(invoices, bills, entries). Timesheet: line linked to a project " + "(hr_timesheet). Manual: any other analytic line.", + ) + is_timesheet = fields.Boolean(readonly=True) + move_line_id = fields.Many2one("account.move.line", readonly=True) + analytic_line_id = fields.Many2one("account.analytic.line", readonly=True) + parent_state = fields.Selection( + [("draft", "Draft"), ("posted", "Posted"), ("cancel", "Cancelled")], + readonly=True, + ) + unit_amount = fields.Float(string="Quantity", readonly=True) + debit = fields.Monetary(currency_field="company_currency_id", readonly=True) + credit = fields.Monetary(currency_field="company_currency_id", readonly=True) + balance = fields.Monetary(currency_field="company_currency_id", readonly=True) + amount = fields.Monetary( + string="Analytic Amount", currency_field="company_currency_id", readonly=True + ) + + def _plan_columns(self): + """Columns of account_analytic_line holding the account of each root plan.""" + plans = ( + self.env["account.analytic.plan"] + .sudo() + .with_context(active_test=False) + .search([("parent_id", "=", False)]) + ) + columns = {"account_id"} | {plan._column_name() for plan in plans} + return sorted( + column + for column in columns + if column_exists(self.env.cr, "account_analytic_line", column) + ) + + def _timesheet_sql(self): + """Timesheets are analytic lines with a project (hr_timesheet).""" + if column_exists(self.env.cr, "account_analytic_line", "project_id"): + return SQL("aal.project_id IS NOT NULL") + return SQL("FALSE") + + def init(self): + # analytic.mixin.init() would index the analytic_distribution column, which + # does not apply to a view: it is deliberately not called. + cr = self.env.cr + drop_view_if_exists(cr, self._table) + # This view may be created before res.company gets its new column: create + # the column read by the view up front (_auto_init then adds the foreign key). + if not column_exists(cr, "res_company", "mis_analytic_fallback_account_id"): + create_column(cr, "res_company", "mis_analytic_fallback_account_id", "int4") + plan_columns = [ + SQL("aal.%s", SQL.identifier(column)) for column in self._plan_columns() + ] + accounts = SQL(", ").join(plan_columns) + timesheet = self._timesheet_sql() + # Lines without a financial account (timesheets, manual entries) fall back + # to the company's configured account or, when not set, to its first + # "Cost of Revenue" account, so they still show up in a P&L. + cr.execute( + SQL( + """ + CREATE OR REPLACE VIEW %(table)s AS ( + SELECT + aal.id AS id, + aal.name AS name, + aal.date AS date, + aal.company_id AS company_id, + COALESCE( + aal.general_account_id, + rc.mis_analytic_fallback_account_id, + ( + SELECT a.id + FROM account_account a + JOIN account_account_res_company_rel r + ON r.account_account_id = a.id + WHERE r.res_company_id = aal.company_id + AND a.account_type = 'expense_direct_cost' + AND NOT a.deprecated + ORDER BY + a.code_store->>split_part(rc.parent_path, '/', 1), + a.id + LIMIT 1 + ) + ) AS account_id, + aal.account_id AS analytic_account_id, + aal.partner_id AS partner_id, + aal.product_id AS product_id, + aal.category AS category, + CASE + WHEN aal.move_line_id IS NOT NULL THEN 'journal' + WHEN %(timesheet)s THEN 'timesheet' + ELSE 'manual' + END AS line_origin, + (%(timesheet)s) AS is_timesheet, + aal.move_line_id AS move_line_id, + aal.id AS analytic_line_id, + COALESCE(am.state, 'posted') AS parent_state, + aal.unit_amount AS unit_amount, + CASE WHEN aal.amount < 0 THEN -aal.amount ELSE 0 END AS debit, + CASE WHEN aal.amount > 0 THEN aal.amount ELSE 0 END AS credit, + -aal.amount AS balance, + aal.amount AS amount, + jsonb_build_object(concat_ws(',', %(accounts)s), 100) + AS analytic_distribution + FROM account_analytic_line aal + JOIN res_company rc ON rc.id = aal.company_id + LEFT JOIN account_move_line aml ON aml.id = aal.move_line_id + LEFT JOIN account_move am ON am.id = aml.move_id + WHERE COALESCE(%(accounts)s) IS NOT NULL + ) + """, + table=SQL.identifier(self._table), + timesheet=timesheet, + accounts=accounts, + ) + ) diff --git a/mis_builder_analytic_source/models/res_company.py b/mis_builder_analytic_source/models/res_company.py new file mode 100644 index 000000000..35a45d30a --- /dev/null +++ b/mis_builder_analytic_source/models/res_company.py @@ -0,0 +1,17 @@ +# Copyright 2026 KMEE +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + mis_analytic_fallback_account_id = fields.Many2one( + comodel_name="account.account", + string="MIS Analytic Fallback Account", + domain="[('deprecated', '=', False)]", + check_company=True, + help="Financial account used by the MIS analytic source for analytic lines " + "without a financial account (e.g. timesheets). When empty, the company's " + "first 'Cost of Revenue' account is used.", + ) diff --git a/mis_builder_analytic_source/models/res_config_settings.py b/mis_builder_analytic_source/models/res_config_settings.py new file mode 100644 index 000000000..8a208f939 --- /dev/null +++ b/mis_builder_analytic_source/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2026 KMEE +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + mis_analytic_fallback_account_id = fields.Many2one( + related="company_id.mis_analytic_fallback_account_id", + readonly=False, + ) diff --git a/mis_builder_analytic_source/pyproject.toml b/mis_builder_analytic_source/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/mis_builder_analytic_source/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mis_builder_analytic_source/readme/CONFIGURE.md b/mis_builder_analytic_source/readme/CONFIGURE.md new file mode 100644 index 000000000..c06934961 --- /dev/null +++ b/mis_builder_analytic_source/readme/CONFIGURE.md @@ -0,0 +1,4 @@ +Analytic lines without a financial account (timesheets, manual analytic entries) +are reported on the company's *MIS Analytic Fallback Account* (Invoicing > +Configuration > Settings, Analytics section). When it is not set, the first *Cost of +Revenue* account of the company (by code) is used. diff --git a/mis_builder_analytic_source/readme/CONTRIBUTORS.md b/mis_builder_analytic_source/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..2b8b19e0f --- /dev/null +++ b/mis_builder_analytic_source/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [KMEE](https://www.kmee.com.br): + - Luis Felipe Mileo diff --git a/mis_builder_analytic_source/readme/DESCRIPTION.md b/mis_builder_analytic_source/readme/DESCRIPTION.md new file mode 100644 index 000000000..35cd5eecc --- /dev/null +++ b/mis_builder_analytic_source/readme/DESCRIPTION.md @@ -0,0 +1,18 @@ +Since Odoo 16 a journal item no longer has one analytic account: it has an +`analytic_distribution` that can split it between several accounts. The analytic +filter of a MIS report on journal items keeps the **full** balance of every item +whose distribution contains the analytic account, so a P&L per project, contract or +department is overstated whenever an item is split. Costs that only exist as +analytic lines, such as timesheets, are not seen at all. + +This module adds `mis.analytic.move.line`, a SQL view over `account.analytic.line` +with the fields MIS Builder expects from a move lines source (`account_id`, +`company_id`, `date`, `debit`, `credit`, `parent_state`, `analytic_distribution`). +Analytic lines already carry the prorated amount, so a MIS report that uses this +model as its move lines source gives the exact figures per analytic account, +including timesheet costs. + +Each analytic line holds one account per root analytic plan. The view exposes them +as one distribution key (`"project_account,department_account"` at 100%), the same +format as journal items, so the analytic filter works for the accounts of every plan +and a line is never counted twice. diff --git a/mis_builder_analytic_source/readme/ROADMAP.md b/mis_builder_analytic_source/readme/ROADMAP.md new file mode 100644 index 000000000..b4b59d764 --- /dev/null +++ b/mis_builder_analytic_source/readme/ROADMAP.md @@ -0,0 +1,7 @@ +- Analytic lines are always in the company currency; amounts in another currency + are not converted. +- Draft journal items have no analytic lines, so the *posted and draft* target move + option shows the same figures as *posted only*. Manual analytic entries and + timesheets count as posted. +- The view is rebuilt when an analytic plan is created, moved or deleted, because + each root plan is a column of the analytic lines. diff --git a/mis_builder_analytic_source/readme/USAGE.md b/mis_builder_analytic_source/readme/USAGE.md new file mode 100644 index 000000000..64c69943a --- /dev/null +++ b/mis_builder_analytic_source/readme/USAGE.md @@ -0,0 +1,10 @@ +1. In the MIS report template, set *Move lines source* to *Analytic line as MIS + move line*. +2. Use the analytic filter of the report instance, or an analytic domain per column. +3. Expressions can also filter on the origin of the line, for example + `balp[5%][('line_origin', '=', 'timesheet')]` or + `-balp[6%][('category', '=', 'invoice')]`. + +The sign follows journal items: a positive analytic amount (revenue) is a credit and +a negative one (cost) is a debit, so the usual `-balp[...]` expressions for income +keep working. diff --git a/mis_builder_analytic_source/security/ir.model.access.csv b/mis_builder_analytic_source/security/ir.model.access.csv new file mode 100644 index 000000000..954491802 --- /dev/null +++ b/mis_builder_analytic_source/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_mis_analytic_move_line,mis.analytic.move.line read,model_mis_analytic_move_line,account.group_account_readonly,1,0,0,0 diff --git a/mis_builder_analytic_source/security/ir_rule.xml b/mis_builder_analytic_source/security/ir_rule.xml new file mode 100644 index 000000000..99bf8a97c --- /dev/null +++ b/mis_builder_analytic_source/security/ir_rule.xml @@ -0,0 +1,10 @@ + + + + + MIS analytic move line multi-company + + [('company_id', 'in', company_ids)] + + diff --git a/mis_builder_analytic_source/static/description/index.html b/mis_builder_analytic_source/static/description/index.html new file mode 100644 index 000000000..e98db74ac --- /dev/null +++ b/mis_builder_analytic_source/static/description/index.html @@ -0,0 +1,496 @@ + + + + + +MIS Builder Analytic Source + + + +
+ + + +Odoo Community Association + +
+

MIS Builder Analytic Source

+ +

Alpha License: AGPL-3 OCA/mis-builder Translate me on Weblate Try me on Runboat

+

Since Odoo 16 a journal item no longer has one analytic account: it has +an analytic_distribution that can split it between several accounts. +The analytic filter of a MIS report on journal items keeps the full +balance of every item whose distribution contains the analytic account, +so a P&L per project, contract or department is overstated whenever an +item is split. Costs that only exist as analytic lines, such as +timesheets, are not seen at all.

+

This module adds mis.analytic.move.line, a SQL view over +account.analytic.line with the fields MIS Builder expects from a +move lines source (account_id, company_id, date, debit, +credit, parent_state, analytic_distribution). Analytic lines +already carry the prorated amount, so a MIS report that uses this model +as its move lines source gives the exact figures per analytic account, +including timesheet costs.

+

Each analytic line holds one account per root analytic plan. The view +exposes them as one distribution key +("project_account,department_account" at 100%), the same format as +journal items, so the analytic filter works for the accounts of every +plan and a line is never counted twice.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+

Analytic lines without a financial account (timesheets, manual analytic +entries) are reported on the company’s MIS Analytic Fallback Account +(Invoicing > Configuration > Settings, Analytics section). When it is +not set, the first Cost of Revenue account of the company (by code) is +used.

+
+
+

Usage

+
    +
  1. In the MIS report template, set Move lines source to Analytic line +as MIS move line.
  2. +
  3. Use the analytic filter of the report instance, or an analytic domain +per column.
  4. +
  5. Expressions can also filter on the origin of the line, for example +balp[5%][('line_origin', '=', 'timesheet')] or +-balp[6%][('category', '=', 'invoice')].
  6. +
+

The sign follows journal items: a positive analytic amount (revenue) is +a credit and a negative one (cost) is a debit, so the usual +-balp[...] expressions for income keep working.

+
+
+

Known issues / Roadmap

+
    +
  • Analytic lines are always in the company currency; amounts in another +currency are not converted.
  • +
  • Draft journal items have no analytic lines, so the posted and draft +target move option shows the same figures as posted only. Manual +analytic entries and timesheets count as posted.
  • +
  • The view is rebuilt when an analytic plan is created, moved or +deleted, because each root plan is a column of the analytic lines.
  • +
+
+
+

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

+
    +
  • KMEE
  • +
+
+
+

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 maintainer:

+

mileo

+

This module is part of the OCA/mis-builder project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/mis_builder_analytic_source/tests/__init__.py b/mis_builder_analytic_source/tests/__init__.py new file mode 100644 index 000000000..1c4a706c5 --- /dev/null +++ b/mis_builder_analytic_source/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mis_analytic_move_line diff --git a/mis_builder_analytic_source/tests/test_mis_analytic_move_line.py b/mis_builder_analytic_source/tests/test_mis_analytic_move_line.py new file mode 100644 index 000000000..4f3826ee7 --- /dev/null +++ b/mis_builder_analytic_source/tests/test_mis_analytic_move_line.py @@ -0,0 +1,177 @@ +# Copyright 2026 KMEE +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, fields +from odoo.tests import tagged +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.mis_builder.models.accounting_none import AccountingNone +from odoo.addons.mis_builder.models.aep import AccountingExpressionProcessor as AEP + + +@tagged("post_install", "-at_install") +class TestMisAnalyticMoveLine(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.company_data["company"] + projects, departments = cls.env["account.analytic.plan"].create( + [{"name": "Projects MIS"}, {"name": "Departments MIS"}] + ) + cls.project_a, cls.project_b, cls.department = cls.env[ + "account.analytic.account" + ].create( + [ + {"name": "Project A", "plan_id": projects.id}, + {"name": "Project B", "plan_id": projects.id}, + {"name": "Sales department", "plan_id": departments.id}, + ] + ) + cls.expense = cls.company_data["default_account_expense"] + cls.date = fields.Date.from_string("2026-03-15") + # one expense of 1000 split 60/40 between two projects, all of it on the + # sales department (second analytic plan) + cls.move = cls.env["account.move"].create( + { + "move_type": "entry", + "journal_id": cls.company_data["default_journal_misc"].id, + "date": cls.date, + "line_ids": [ + Command.create( + { + "name": "Shared cost", + "account_id": cls.expense.id, + "debit": 1000.0, + "analytic_distribution": { + f"{cls.project_a.id},{cls.department.id}": 60, + f"{cls.project_b.id},{cls.department.id}": 40, + }, + } + ), + Command.create( + { + "name": "Shared cost", + "account_id": cls.company_data[ + "default_account_payable" + ].id, + "credit": 1000.0, + } + ), + ], + } + ) + cls.move.action_post() + cls.View = cls.env["mis.analytic.move.line"] + + def _balp(self, aml_model, domain): + aep = AEP(self.company) + expr = f"balp[{self.expense.code}]" + aep.parse_expr(expr) + aep.done_parsing() + aep.do_queries( + date_from=self.date, + date_to=self.date, + additional_move_line_filter=domain, + aml_model=aml_model, + ) + return safe_eval(aep.replace_expr(expr), {"AccountingNone": AccountingNone}) + + def _filter(self, account): + return [("distribution_analytic_account_ids", "in", account.ids)] + + def test_lines_of_the_move(self): + """The view has the analytic lines of the move, with debit and credit.""" + lines = self.View.search([("move_line_id.move_id", "=", self.move.id)]) + self.assertTrue(lines) + self.assertEqual(set(lines.mapped("line_origin")), {"journal"}) + self.assertEqual(set(lines.mapped("parent_state")), {"posted"}) + self.assertEqual(lines.account_id, self.expense) + self.assertFalse(any(lines.mapped("credit"))) + + def test_analytic_filter_full_vs_prorated(self): + """The analytic filter on journal items keeps the full balance. + + The same filter on this source gives the prorated amount, for the accounts + of every analytic plan. + """ + project_filter = self._filter(self.project_a) + self.assertAlmostEqual(self._balp("account.move.line", project_filter), 1000.0) + self.assertAlmostEqual( + self._balp("mis.analytic.move.line", project_filter), 600.0 + ) + self.assertAlmostEqual( + self._balp("mis.analytic.move.line", self._filter(self.project_b)), 400.0 + ) + self.assertAlmostEqual( + self._balp("mis.analytic.move.line", self._filter(self.department)), 1000.0 + ) + + def test_new_plan_rebuilds_view(self): + """A new root plan adds a column to analytic lines: the view follows.""" + plan = self.env["account.analytic.plan"].create({"name": "Regions MIS"}) + region = self.env["account.analytic.account"].create( + {"name": "North", "plan_id": plan.id} + ) + manual = self.env["account.analytic.line"].create( + { + "name": "Regional cost", + plan._column_name(): region.id, + "amount": -30.0, + "date": self.date, + "company_id": self.company.id, + } + ) + line = self.View.search([("analytic_line_id", "=", manual.id)]) + self.assertAlmostEqual(line.debit, 30.0) + self.assertEqual(line.analytic_distribution, {str(region.id): 100}) + self.assertEqual(self.View.search(self._filter(region)), line) + + def test_manual_line_fallback_account(self): + """A line without financial account falls back to a cost account.""" + manual = self.env["account.analytic.line"].create( + { + "name": "Manual cost", + "account_id": self.project_a.id, + "amount": -50.0, + "date": self.date, + "company_id": self.company.id, + } + ) + line = self.View.search([("analytic_line_id", "=", manual.id)]) + self.assertEqual(line.line_origin, "manual") + self.assertAlmostEqual(line.debit, 50.0) + first_cost = self.env["account.account"].search( + [ + ("company_ids", "in", self.company.id), + ("account_type", "=", "expense_direct_cost"), + ], + order="code, id", + limit=1, + ) + self.assertEqual(line.account_id, first_cost) + fallback = self.company_data["default_account_expense"] + self.company.mis_analytic_fallback_account_id = fallback + self.env.flush_all() + self.View.invalidate_model() + self.assertEqual(line.account_id, fallback) + + def test_multi_company_rule(self): + """Users only see the lines of their companies.""" + other = self.env["res.company"].create({"name": "Other MIS company"}) + user = self.env["res.users"].create( + { + "name": "MIS reader", + "login": "mis_reader_analytic", + "company_id": other.id, + "company_ids": [Command.set(other.ids)], + "groups_id": [ + Command.set(self.env.ref("account.group_account_readonly").ids) + ], + } + ) + self.assertFalse( + self.View.with_user(user).search( + [("move_line_id.move_id", "=", self.move.id)] + ) + ) diff --git a/mis_builder_analytic_source/views/mis_analytic_move_line_views.xml b/mis_builder_analytic_source/views/mis_analytic_move_line_views.xml new file mode 100644 index 000000000..4b8880bc8 --- /dev/null +++ b/mis_builder_analytic_source/views/mis_analytic_move_line_views.xml @@ -0,0 +1,78 @@ + + + + + mis.analytic.move.line.list + mis.analytic.move.line + + + + + + + + + + + + + + + + + + + + + + + + mis.analytic.move.line.search + mis.analytic.move.line + + + + + + + + + + + + + + + + + + diff --git a/mis_builder_analytic_source/views/res_config_settings_views.xml b/mis_builder_analytic_source/views/res_config_settings_views.xml new file mode 100644 index 000000000..f3b9dc3f1 --- /dev/null +++ b/mis_builder_analytic_source/views/res_config_settings_views.xml @@ -0,0 +1,27 @@ + + + + + res.config.settings.form (mis_builder_analytic_source) + res.config.settings + + + + + + + + + +