Skip to content
Merged
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
8 changes: 6 additions & 2 deletions project_timesheet_hours_only/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
1 change: 1 addition & 0 deletions project_timesheet_hours_only/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

from . import project_project
from . import sale_order

Check warning on line 5 in project_timesheet_hours_only/models/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

project_timesheet_hours_only/models/__init__.py#L5

'.sale_order' imported but unused (F401)
46 changes: 46 additions & 0 deletions project_timesheet_hours_only/models/sale_order.py
Original file line number Diff line number Diff line change
@@ -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)]])
3 changes: 3 additions & 0 deletions project_timesheet_hours_only/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 4 in project_timesheet_hours_only/tests/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

project_timesheet_hours_only/tests/__init__.py#L4

'.test_project_timesheet' imported but unused (F401)
from . import test_sale_order_qty_delivered

Check warning on line 5 in project_timesheet_hours_only/tests/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

project_timesheet_hours_only/tests/__init__.py#L5

'.test_sale_order_qty_delivered' imported but unused (F401)
Original file line number Diff line number Diff line change
@@ -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)
Loading