Skip to content

Commit

Permalink
[16.0][ADD] hr_timesheet_hours_billed:hours_billed
Browse files Browse the repository at this point in the history
This module allows to specify billed amount of time in timesheet while
still keeping original time tracked. Additional field "Hours Billed" and
"Approved" "Approved by" and "Approved on" are added to timesheet.
These fields are visible only for "Timesheets: Administrator" group
  • Loading branch information
OCA-git-bot authored and Andrey committed Jul 4, 2023
1 parent a9d4161 commit 81b8fae
Show file tree
Hide file tree
Showing 14 changed files with 340 additions and 0 deletions.
Empty file.
1 change: 1 addition & 0 deletions hr_timesheet_hours_billed/__init__.py
@@ -0,0 +1 @@
from . import models
17 changes: 17 additions & 0 deletions hr_timesheet_hours_billed/__manifest__.py
@@ -0,0 +1,17 @@
{
"name": "Timesheet Hours Billed",
"summary": """Add ‘Hours Billed’, 'Approved', 'Approved by',
'Approved on' field for timesheets""",
"version": "16.0.1.0.0",
"category": "Timesheet",
"website": "https://github.com/OCA/timesheet",
"maintainers": ["solo4games", "CetmixGitDrone"],
"author": "Odoo Community Association (OCA), Cetmix",
"license": "LGPL-3",
"application": False,
"installable": True,
"depends": ["hr_timesheet", "sale_management"],
"data": [
"views/account_analytic_line_view.xml",
],
}
2 changes: 2 additions & 0 deletions hr_timesheet_hours_billed/models/__init__.py
@@ -0,0 +1,2 @@
from . import account_analytic_line
from . import sale_order_line
39 changes: 39 additions & 0 deletions hr_timesheet_hours_billed/models/account_analytic_line.py
@@ -0,0 +1,39 @@
# Copyright 2023-nowdays Cetmix OU (https://cetmix.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import api, fields, models


class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"

approved = fields.Boolean(default=False, inverse="_inverse_approved")
approved_user_id = fields.Many2one(
"res.users",
string="Approved by",
index=True,
tracking=True,
readonly=True,
)
approved_date = fields.Datetime(copy=False, readonly=True)
unit_amount_billed = fields.Float(string="Hours Billed")

# set approved_user_id and date if approved state change
def _inverse_approved(self):
user = self.env.user
now = fields.Datetime.now()
for record in self.filtered(lambda rec: rec.approved):
record.write({"approved_date": now, "approved_user_id": user})

# override create method to initialize unit_amount_billed
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get("unit_amount_billed"):
vals["unit_amount_billed"] = vals.get("unit_amount", 0.0)
return super(AccountAnalyticLine, self).create(vals_list)

def action_approve_records(self):
self.filtered(lambda rec: not rec.approved).write({"approved": True})

def action_decline_records(self):
self.filtered(lambda rec: rec.approved).write({"approved": False})
75 changes: 75 additions & 0 deletions hr_timesheet_hours_billed/models/sale_order_line.py
@@ -0,0 +1,75 @@
# Copyright 2023-nowdays Cetmix OU (https://cetmix.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import api, models
from odoo.osv import expression


class SaleOrderLine(models.Model):
_inherit = "sale.order.line"

@api.depends(
"qty_delivered_method",
"analytic_line_ids.so_line",
"analytic_line_ids.unit_amount_billed",
"analytic_line_ids.product_uom_id",
"analytic_line_ids.approved",
)
def _compute_qty_delivered(self):
"""This method compute the delivered quantity of the SO lines:
it covers the case provide by sale module, aka
expense/vendor bills (sum of unit_amount_billed of AAL), and manual case.
"""

return super(SaleOrderLine, self)._compute_qty_delivered()

def _get_delivered_quantity_by_analytic(self, additional_domain):
"""Compute and write the delivered quantity of current SO lines,
based on their related analytic lines.
:param additional_domain: domain to restrict AAL to include in computation
(required since timesheet is an AAL with a project ...)
"""
result = {}

# avoid recomputation if no SO lines concerned
if not self:
return result

# group analytic lines by product uom and so line
domain = expression.AND([[("so_line", "in", self.ids)], additional_domain])
data = self.env["account.analytic.line"].read_group(
domain,
["so_line", "unit_amount_billed", "approved", "product_uom_id"],
["product_uom_id", "so_line", "approved"],
lazy=False,
)

# convert uom and sum all unit_amount_billed of analytic lines
# to get the delivered qty of SO lines
# browse so lines and product uoms here to make them share the same prefetch
lines = self.browse([item["so_line"][0] for item in data])
lines_map = {line.id: line for line in lines}
product_uom_ids = [
item["product_uom_id"][0] for item in data if item["product_uom_id"]
]
product_uom_map = {
uom.id: uom for uom in self.env["uom.uom"].browse(product_uom_ids)
}
for item in data:
if not item["product_uom_id"]:
continue
so_line_id = item["so_line"][0]
so_line = lines_map[so_line_id]
result.setdefault(so_line_id, 0.0)
uom = product_uom_map.get(item["product_uom_id"][0])
if item.get("approved"):
if so_line.product_uom.category_id == uom.category_id:
qty = uom._compute_quantity(
item["unit_amount_billed"],
so_line.product_uom,
rounding_method="HALF-UP",
)
else:
qty = item["unit_amount_billed"]
result[so_line_id] += qty

return result
9 changes: 9 additions & 0 deletions hr_timesheet_hours_billed/readme/DESCRIPTION.rst
@@ -0,0 +1,9 @@
This module allows to specify billed amount of time in timesheet while still keeping original time tracked.
It might be useful if you want to invoice only part of the time logged in the timesheet.
It also implements „approval“ mechanism so only approved timesheets will be invoiced.

Additional field "Hours Billed" and "Approved" "Approved by" and "Approved on" are added to timesheet.
These fields are visible only for "Timesheets: Administrator" group
"Hours Billed" field is used to compute Delivered Quantity in related Sale Order line.Only timesheets with "Approved" enabled are counted.

By default "Hours Bulled" field values are populated from the "Hours Spent" field of the timesheet.
Empty file.
5 changes: 5 additions & 0 deletions hr_timesheet_hours_billed/readme/USAGE.rst
@@ -0,0 +1,5 @@
To use this module you need to:

#. Go to Timesheets, there you can see new rows called 'Hours Billed', 'Approved', 'Approved by', 'Approved date'.
#. Modify 'Approved' field. Choose yes, then field 'Approved by' will be filled with the current user, field 'Approved date' will be filled with the current date.
#. Field 'Hours Billed' is used to calcualte 'Delivered' field in sale order line, only approved timesheets will be invoiced.
1 change: 1 addition & 0 deletions hr_timesheet_hours_billed/tests/__init__.py
@@ -0,0 +1 @@
from . import test_hours_billed
117 changes: 117 additions & 0 deletions hr_timesheet_hours_billed/tests/test_hours_billed.py
@@ -0,0 +1,117 @@
# Copyright 2023-nowdays Cetmix OU (https://cetmix.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo.tests import tagged

from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet


@tagged("-at_install", "post_install")
class TestCommonHourBilled(TestCommonSaleTimesheet):
# @classmethod
def setUp(self):

super().setUp()
self.task_rate_task = self.env["project.task"].create(
{
"name": "Task",
"project_id": self.project_task_rate.id,
"sale_line_id": self.so.order_line[0].id,
}
)

def test_compute_hours_billed(self):
# create some timesheet on this task
timesheet1 = self.env["account.analytic.line"].create(
{
"name": "Test Line",
"project_id": self.project_task_rate.id,
"task_id": self.task_rate_task.id,
"unit_amount": 5,
"employee_id": self.employee_manager.id,
"approved": True,
}
)

# Check on creation if unit_amount_billed not set it must be equal unit_amount
self.assertEqual(
timesheet1.unit_amount,
timesheet1.unit_amount_billed,
"Hours Billed and Hours Spent should be the same",
)
# Create some timesheet on this task
timesheet2 = self.env["account.analytic.line"].create(
{
"name": "Test Line",
"project_id": self.project_task_rate.id,
"task_id": self.task_rate_task.id,
"unit_amount": 5,
"unit_amount_billed": 10,
"employee_id": self.employee_manager.id,
}
)
# Check on creation if unit_amount_billed set it shouldn't be equal unit_amount
self.assertNotEqual(
timesheet2.unit_amount,
timesheet2.unit_amount_billed,
"Hours Billed and Hours Spent should be different",
)
# Check about recompute qty_delivered after changing unit_amount_billed
before_change_amount_billed = self.so.order_line[0].qty_delivered
timesheet1.write({"unit_amount_billed": 20.0})

self.assertNotEqual(
before_change_amount_billed,
self.so.order_line[0].qty_delivered,
"qty_deliverede must be recompute, after changing unit_amount_billed",
)

# Check about recompute qty_delivered after changing approved
before_change_approved = self.so.order_line[0].qty_delivered
timesheet1.write({"approved": False})

self.assertNotEqual(
before_change_approved,
self.so.order_line[0].qty_delivered,
"qty_deliverede must be recompute, after changing approved",
)

def test_compute_approved_user_id(self):
# Create some timesheet on this task
timesheet3 = self.env["account.analytic.line"].create(
{
"name": "Test Line",
"project_id": self.project_task_rate.id,
"task_id": self.task_rate_task.id,
"unit_amount": 5,
"employee_id": self.employee_manager.id,
}
)
uid_before_change_approved = timesheet3.approved_user_id

timesheet3.write({"approved": True})
# Check about changing approved_user_id after changing state of approved to "yes"
self.assertNotEqual(
uid_before_change_approved,
timesheet3.approved_user_id,
"approved_user_id must be different",
)

def test_compute_approved_date(self):
# Create some timesheet on this task
timesheet4 = self.env["account.analytic.line"].create(
{
"name": "Test Line",
"project_id": self.project_task_rate.id,
"task_id": self.task_rate_task.id,
"unit_amount": 5,
"employee_id": self.employee_manager.id,
}
)
# Check about changing approved_date after changing state of approved to "yes"
date_before_change_approved = timesheet4.approved_date
timesheet4.write({"approved": True})
self.assertNotEqual(
date_before_change_approved,
timesheet4.approved_date,
"approved_date must be different",
)
67 changes: 67 additions & 0 deletions hr_timesheet_hours_billed/views/account_analytic_line_view.xml
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="hr_timesheet_line_tree" model="ir.ui.view">
<field name="name">Hours Billed</field>
<field name="model">account.analytic.line</field>
<field name="inherit_id" ref="hr_timesheet.hr_timesheet_line_tree" />
<field name="arch" type="xml">
<field name="unit_amount" position="after">
<field
name="unit_amount_billed"
optional="show"
groups="hr_timesheet.group_timesheet_manager"
widget="timesheet_uom"
/>
<field
name="approved"
optional="show"
groups="hr_timesheet.group_timesheet_manager"
/>
<field
name="approved_user_id"
optional="hide"
groups="hr_timesheet.group_timesheet_manager"
/>
<field
name="approved_date"
optional="hide"
groups="hr_timesheet.group_timesheet_manager"
/>
</field>
</field>
</record>

<record model="ir.actions.server" id="action_approve_records">
<field name="name">Approve</field>
<field
name="model_id"
ref="hr_timesheet_hours_billed.model_account_analytic_line"
/>
<field
name="binding_model_id"
ref="hr_timesheet_hours_billed.model_account_analytic_line"
/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = records.action_approve_records()
</field>
</record>

<record model="ir.actions.server" id="action_decline_records">
<field name="name">Decline</field>
<field
name="model_id"
ref="hr_timesheet_hours_billed.model_account_analytic_line"
/>
<field
name="binding_model_id"
ref="hr_timesheet_hours_billed.model_account_analytic_line"
/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = records.action_decline_records()
</field>
</record>
</odoo>
6 changes: 6 additions & 0 deletions setup/hr_timesheet_hours_billed/setup.py
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

0 comments on commit 81b8fae

Please sign in to comment.