diff --git a/project_task_analytic_account_sync/README.md b/project_task_analytic_account_sync/README.md new file mode 100644 index 0000000..ca76024 --- /dev/null +++ b/project_task_analytic_account_sync/README.md @@ -0,0 +1,22 @@ +# Project Task Analytic Account Sync + +This module forces the analytic account of every project task to always be the same as the one defined on its project. It removes the possibility of manual divergences introduced by the `is_analytic_account_id_changed` flag in Odoo core. + +## Behavior + +- **Task creation**: When a task is created and assigned to a project, its `analytic_account_id` is set to the project's analytic account. +- **Project change**: When a task is moved to another project, its analytic account is updated to the new project's account. +- **Project analytic account change**: When a project's analytic account is modified, the change is propagated to **all** existing tasks (active and archived). +- **Manual override ignored**: Writing a different analytic account directly on a task is immediately reverted. +- **Analytic account auto-creation**: When Odoo auto-creates a project analytic account, the existing tasks are synchronized. + +## Usage + +No user action is required. The synchronization is transparent and enforced server-side. + +## Technical notes + +- The `analytic_account_id` field on `project.task` is kept as a computed stored field but the compute is overridden to always ignore `is_analytic_account_id_changed`. +- `project.project.write` detects changes in `analytic_account_id` and propagates them in batch. +- `project.task.write` detects changes in `project_id` or `analytic_account_id` and corrects the value immediately. +- The task form view makes the analytic account field readonly to avoid confusion. diff --git a/project_task_analytic_account_sync/__init__.py b/project_task_analytic_account_sync/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/project_task_analytic_account_sync/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_task_analytic_account_sync/__manifest__.py b/project_task_analytic_account_sync/__manifest__.py new file mode 100644 index 0000000..02c5fbf --- /dev/null +++ b/project_task_analytic_account_sync/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Project Task Analytic Account Sync", + "version": "16.0.1.0.0", + "category": "Project", + "license": "AGPL-3", + "summary": "Synchronize project task analytic accounts strictly with their project", + "author": "Criptomart", + "website": "https://github.com/criptomart/odoo-addons", + "depends": ["project"], + "data": [ + "views/project_task_views.xml", + ], + "installable": True, + "application": False, + "auto_install": False, + "sequence": 99, +} diff --git a/project_task_analytic_account_sync/models/__init__.py b/project_task_analytic_account_sync/models/__init__.py new file mode 100644 index 0000000..2128188 --- /dev/null +++ b/project_task_analytic_account_sync/models/__init__.py @@ -0,0 +1,2 @@ +from . import project_project +from . import project_task diff --git a/project_task_analytic_account_sync/models/project_project.py b/project_task_analytic_account_sync/models/project_project.py new file mode 100644 index 0000000..aa77f9c --- /dev/null +++ b/project_task_analytic_account_sync/models/project_project.py @@ -0,0 +1,20 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + def write(self, vals): + res = super().write(vals) + if "analytic_account_id" in vals: + self.env["project.task"].with_context(active_test=False).search( + [("project_id", "in", self.ids)] + ).write({"analytic_account_id": vals["analytic_account_id"]}) + return res + + # No need to override _create_analytic_account: the core method already + # calls project.write({'analytic_account_id': ...}) which triggers our + # write() override above and propagates the account to all tasks. diff --git a/project_task_analytic_account_sync/models/project_task.py b/project_task_analytic_account_sync/models/project_task.py new file mode 100644 index 0000000..6a7d736 --- /dev/null +++ b/project_task_analytic_account_sync/models/project_task.py @@ -0,0 +1,42 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ProjectTask(models.Model): + _inherit = "project.task" + + @api.depends("project_id.analytic_account_id") + def _compute_analytic_account_id(self): + super()._compute_analytic_account_id() + for task in self: + expected = ( + task.project_id.analytic_account_id if task.project_id else False + ) + if task.analytic_account_id != expected: + task.analytic_account_id = expected + + def write(self, vals): + if "project_id" in vals and "analytic_account_id" not in vals: + # Project changed: force the correct account from the new project + new_project = self.env["project.project"].browse(vals["project_id"]) + vals["analytic_account_id"] = ( + new_project.analytic_account_id.id if new_project else False + ) + elif "analytic_account_id" in vals and "project_id" not in vals: + # Validate provided account against the current project + for task in self: + expected = ( + task.project_id.analytic_account_id.id if task.project_id else False + ) + if vals["analytic_account_id"] != expected: + vals["analytic_account_id"] = expected + break + elif "project_id" in vals and "analytic_account_id" in vals: + # Both changed: force account from the new project + new_project = self.env["project.project"].browse(vals["project_id"]) + vals["analytic_account_id"] = ( + new_project.analytic_account_id.id if new_project else False + ) + return super().write(vals) diff --git a/project_task_analytic_account_sync/tests/__init__.py b/project_task_analytic_account_sync/tests/__init__.py new file mode 100644 index 0000000..31c2177 --- /dev/null +++ b/project_task_analytic_account_sync/tests/__init__.py @@ -0,0 +1 @@ +from . import test_project_task_analytic_account_sync diff --git a/project_task_analytic_account_sync/tests/test_project_task_analytic_account_sync.py b/project_task_analytic_account_sync/tests/test_project_task_analytic_account_sync.py new file mode 100644 index 0000000..a80791e --- /dev/null +++ b/project_task_analytic_account_sync/tests/test_project_task_analytic_account_sync.py @@ -0,0 +1,82 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestProjectTaskAnalyticAccountSync(TransactionCase): + def setUp(self): + super().setUp() + self.AnalyticAccount = self.env["account.analytic.account"] + self.Project = self.env["project.project"] + self.Task = self.env["project.task"] + + self.plan = self.env["account.analytic.plan"].create( + {"name": "Test Plan", "company_id": self.env.company.id} + ) + + self.analytic_1 = self.AnalyticAccount.create( + {"name": "Analytic Account 1", "plan_id": self.plan.id} + ) + self.analytic_2 = self.AnalyticAccount.create( + {"name": "Analytic Account 2", "plan_id": self.plan.id} + ) + + self.project_a = self.Project.create( + { + "name": "Project A", + "analytic_account_id": self.analytic_1.id, + } + ) + self.project_b = self.Project.create( + { + "name": "Project B", + "analytic_account_id": self.analytic_2.id, + } + ) + + def test_task_sync_on_create(self): + """RF-03: a new task must adopt the project's analytic account.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + + def test_task_sync_on_project_change(self): + """RF-04: moving a task to another project updates its analytic account.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + task.write({"project_id": self.project_b.id}) + self.assertEqual(task.analytic_account_id, self.analytic_2) + + def test_task_sync_on_project_analytic_change(self): + """RF-02: changing the project's analytic account propagates to tasks.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + self.project_a.write({"analytic_account_id": self.analytic_2.id}) + self.assertEqual(task.analytic_account_id, self.analytic_2) + + def test_task_sync_overrides_manual_change(self): + """RF-01: a manual change on the task must be reverted immediately.""" + task = self.Task.create( + { + "name": "Test Task", + "project_id": self.project_a.id, + } + ) + self.assertEqual(task.analytic_account_id, self.analytic_1) + task.write({"analytic_account_id": self.analytic_2.id}) + self.assertEqual(task.analytic_account_id, self.analytic_1) diff --git a/project_task_analytic_account_sync/views/project_task_views.xml b/project_task_analytic_account_sync/views/project_task_views.xml new file mode 100644 index 0000000..db2e1c0 --- /dev/null +++ b/project_task_analytic_account_sync/views/project_task_views.xml @@ -0,0 +1,13 @@ + + + + project.task.form.analytic.sync + project.task + + + + 1 + + + +