-
-
Notifications
You must be signed in to change notification settings - Fork 334
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
Changes from 3 commits
22ca978
24ef59c
2e2b08f
7f62ba5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import models |
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", | ||
], | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
from . import account_analytic_line | ||
from . import sale_order_line |
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}) |
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: | ||
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"]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can't this check be part of the domain? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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. |
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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import test_hours_billed |
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
|
||
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", | ||
) |
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> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
../../../../hr_timesheet_hours_billed |
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, | ||
) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, ..."?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@simahawk?