Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[16.0][ADD] hr_timesheet_hours_billed #570

Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Empty file.
1 change: 1 addition & 0 deletions hr_timesheet_hours_billed/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
17 changes: 17 additions & 0 deletions hr_timesheet_hours_billed/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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": "Cetmix, Odoo Community Association (OCA)",
"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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import account_analytic_line
from . import sale_order_line
41 changes: 41 additions & 0 deletions hr_timesheet_hours_billed/models/account_analytic_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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:
if not record.approved:
continue
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})
81 changes: 81 additions & 0 deletions hr_timesheet_hours_billed/models/sale_order_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds more like a comment. A relevant info would be that you added some more field to depends

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to write after this text something like
"Added analytic_line_ids.so_line, analytic_line_ids.unit_amount_billed, ..."?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):
"""Retrieve delivered quantity by line
: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"]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't this check be part of the domain?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think not, because this is contains tuple, so this will be hard to check it from domain, but I can check approved in domain

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"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here as it seems non approved lines and lines w/o uom are totally ignored here

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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_hours_billed
121 changes: 121 additions & 0 deletions hr_timesheet_hours_billed/tests/test_hours_billed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# 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 setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))

def setUp(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this to setUpClass and disable tracking unless you need it for functional purposes


    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))


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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

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