From b93a6d0bb90f0f1e014e795083508e9090692ba6 Mon Sep 17 00:00:00 2001 From: Abdellatif Benzbiria Date: Wed, 24 Jun 2026 12:30:43 +0000 Subject: [PATCH 1/2] [14.0][IMP] project_timesheet_hours_only --- project_timesheet_hours_only/__manifest__.py | 8 +- .../models/__init__.py | 1 + .../models/sale_order.py | 46 +++++++++++ .../tests/__init__.py | 3 + .../tests/test_sale_order_qty_delivered.py | 82 +++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 project_timesheet_hours_only/models/sale_order.py create mode 100644 project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py diff --git a/project_timesheet_hours_only/__manifest__.py b/project_timesheet_hours_only/__manifest__.py index 68f98c50..19426e7c 100644 --- a/project_timesheet_hours_only/__manifest__.py +++ b/project_timesheet_hours_only/__manifest__.py @@ -5,12 +5,16 @@ "name": "Project Timesheet Hours Only", "summary": "Filter project timesheet smart button to " "exclude material consumption and show hours only", - "version": "1.0.0", + "version": "14.0.1.1.0", "category": "Project", "author": "Numigi", "website": "https://www.numigi.com", "license": "AGPL-3", - "depends": ["hr_timesheet", "project"], + "depends": [ + "hr_timesheet", + "project", + "sale_timesheet" + ], "data": [ "views/project_project_views.xml", ], diff --git a/project_timesheet_hours_only/models/__init__.py b/project_timesheet_hours_only/models/__init__.py index 86e6be36..9356db1b 100644 --- a/project_timesheet_hours_only/models/__init__.py +++ b/project_timesheet_hours_only/models/__init__.py @@ -2,3 +2,4 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). from . import project_project +from . import sale_order diff --git a/project_timesheet_hours_only/models/sale_order.py b/project_timesheet_hours_only/models/sale_order.py new file mode 100644 index 00000000..b43ff7ab --- /dev/null +++ b/project_timesheet_hours_only/models/sale_order.py @@ -0,0 +1,46 @@ +# © Numigi (tm) and all its contributors (https://numigi.com/r/home) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import models, api +from odoo.osv import expression + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + @api.depends('analytic_account_id.line_ids') + def _compute_timesheet_ids(self): + # Call the original method to retrieve the standard timesheets. + super(SaleOrder, self)._compute_timesheet_ids() + + for order in self: + # Filter the results using the same logic (t.task_id) to keep + # actual labor hours only and exclude material consumption. + order.timesheet_ids = order.timesheet_ids.filtered(lambda t: t.task_id) + + # Update the counter displayed on the smart button. + order.timesheet_count = len(order.timesheet_ids) + + # Note: the 'timesheet_total_duration' field updates by itself + # since it depends on the 'timesheet_ids' we just filtered. + + def action_view_timesheet(self): + # Get the action from the original smart button. + action = super(SaleOrder, self).action_view_timesheet() + + # Restrict the displayed lines to those linked to a task. + if isinstance(action, dict) and action.get('domain'): + action['domain'].append(('task_id', '!=', False)) + + return action + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _timesheet_compute_delivered_quantity_domain(self): + # Apply the same "hours only" logic to the delivered quantity: only + # analytic lines linked to a task (actual timesheets) are counted, + # material consumption (task_id == False) is excluded. + domain = super()._timesheet_compute_delivered_quantity_domain() + return expression.AND([domain, [('task_id', '!=', False)]]) \ No newline at end of file diff --git a/project_timesheet_hours_only/tests/__init__.py b/project_timesheet_hours_only/tests/__init__.py index c63ff811..7c53c661 100644 --- a/project_timesheet_hours_only/tests/__init__.py +++ b/project_timesheet_hours_only/tests/__init__.py @@ -1,2 +1,5 @@ # © Numigi (tm) and all its contributors (https://numigi.com/r/home) # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import test_project_timesheet +from . import test_sale_order_qty_delivered diff --git a/project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py b/project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py new file mode 100644 index 00000000..547a1166 --- /dev/null +++ b/project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py @@ -0,0 +1,82 @@ +# © Numigi (tm) and all its contributors (https://numigi.com/r/home) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo.tests import common + + +class TestSaleOrderLineQtyDelivered(common.SavepointCase): + """Check that the delivered quantity of a timesheet-billed service line + excludes material consumption (task_id == False) in a mixed project.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.uom_hour = cls.env.ref("uom.product_uom_hour") + cls.partner = cls.env["res.partner"].create({"name": "Test Customer"}) + + cls.analytic_account = cls.env["account.analytic.account"].create({ + "name": "Test Mixed Project - AA", + "company_id": cls.env.company.id, + }) + cls.project = cls.env["project.project"].create({ + "name": "Test Mixed Project", + "allow_timesheets": True, + "analytic_account_id": cls.analytic_account.id, + }) + cls.task = cls.env["project.task"].create({ + "name": "Test Task", + "project_id": cls.project.id, + }) + + # Service product billed on timesheets. + cls.service_product = cls.env["product.product"].create({ + "name": "Timesheet Service", + "type": "service", + "service_type": "timesheet", + "invoice_policy": "order", + "uom_id": cls.uom_hour.id, + "uom_po_id": cls.uom_hour.id, + "list_price": 100.0, + }) + + cls.order = cls.env["sale.order"].create({ + "partner_id": cls.partner.id, + "order_line": [(0, 0, { + "product_id": cls.service_product.id, + "product_uom_qty": 20.0, + "product_uom": cls.uom_hour.id, + })], + }) + cls.order_line = cls.order.order_line + + def _create_analytic_line(self, unit_amount, task=None): + return self.env["account.analytic.line"].create({ + "name": "Line", + "account_id": self.analytic_account.id, + "project_id": self.project.id, + "task_id": task.id if task else False, + "so_line": self.order_line.id, + "product_uom_id": self.uom_hour.id, + "unit_amount": unit_amount, + "amount": -unit_amount * 50, + }) + + def test_qty_delivered_method_is_timesheet(self): + self.assertEqual(self.order_line.qty_delivered_method, "timesheet") + + def test_domain_excludes_material(self): + domain = self.order_line._timesheet_compute_delivered_quantity_domain() + self.assertIn(("task_id", "!=", False), domain) + + def test_qty_delivered_counts_only_labor(self): + # 10h of labor (linked to a task). + self._create_analytic_line(10.0, task=self.task) + # 5h of "material" (no task) on the same sale order line. + self._create_analytic_line(5.0, task=None) + + self.order_line._compute_qty_delivered() + + # Only the 10h of labor are counted. + self.assertEqual(self.order_line.qty_delivered, 10.0) \ No newline at end of file From 6e601758fba892ea5e0b772d6f78d4aa5156f516 Mon Sep 17 00:00:00 2001 From: Abdellatif Benzbiria Date: Wed, 24 Jun 2026 12:36:08 +0000 Subject: [PATCH 2/2] [14.0][IMP] project_timesheet_hours_only --- project_timesheet_hours_only/__manifest__.py | 2 +- project_timesheet_hours_only/models/sale_order.py | 2 +- .../tests/test_sale_order_qty_delivered.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/project_timesheet_hours_only/__manifest__.py b/project_timesheet_hours_only/__manifest__.py index 19426e7c..32ef5420 100644 --- a/project_timesheet_hours_only/__manifest__.py +++ b/project_timesheet_hours_only/__manifest__.py @@ -14,7 +14,7 @@ "hr_timesheet", "project", "sale_timesheet" - ], + ], "data": [ "views/project_project_views.xml", ], diff --git a/project_timesheet_hours_only/models/sale_order.py b/project_timesheet_hours_only/models/sale_order.py index b43ff7ab..09ebf7c2 100644 --- a/project_timesheet_hours_only/models/sale_order.py +++ b/project_timesheet_hours_only/models/sale_order.py @@ -43,4 +43,4 @@ def _timesheet_compute_delivered_quantity_domain(self): # analytic lines linked to a task (actual timesheets) are counted, # material consumption (task_id == False) is excluded. domain = super()._timesheet_compute_delivered_quantity_domain() - return expression.AND([domain, [('task_id', '!=', False)]]) \ No newline at end of file + return expression.AND([domain, [('task_id', '!=', False)]]) diff --git a/project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py b/project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py index 547a1166..bec66c72 100644 --- a/project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py +++ b/project_timesheet_hours_only/tests/test_sale_order_qty_delivered.py @@ -79,4 +79,4 @@ def test_qty_delivered_counts_only_labor(self): self.order_line._compute_qty_delivered() # Only the 10h of labor are counted. - self.assertEqual(self.order_line.qty_delivered, 10.0) \ No newline at end of file + self.assertEqual(self.order_line.qty_delivered, 10.0)