Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions mis_builder_analytic_source/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://odoo-community.org/page/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 <https://github.com/OCA/mis-builder/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/mis-builder/issues/new?body=module:%20mis_builder_analytic_source%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
-------

* KMEE

Contributors
------------

- `KMEE <https://www.kmee.com.br>`__:

- 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 <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-mileo|

This module is part of the `OCA/mis-builder <https://github.com/OCA/mis-builder/tree/18.0/mis_builder_analytic_source>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions mis_builder_analytic_source/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
22 changes: 22 additions & 0 deletions mis_builder_analytic_source/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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,
}
4 changes: 4 additions & 0 deletions mis_builder_analytic_source/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from . import account_analytic_plan
from . import mis_analytic_move_line
from . import res_company
from . import res_config_settings
30 changes: 30 additions & 0 deletions mis_builder_analytic_source/models/account_analytic_plan.py
Original file line number Diff line number Diff line change
@@ -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
181 changes: 181 additions & 0 deletions mis_builder_analytic_source/models/mis_analytic_move_line.py
Original file line number Diff line number Diff line change
@@ -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,
)
)
Loading
Loading