[MERGE] hr_timesheet: Improve Timer, Access Rights and Validation
Access rights
=============
Purpose
-------
Currently, the lowest access right level for Timesheets allows a user
to see the timesheets from all employees. Some companies do not want
to have this information shared.
As we already have three levels of access rights, creating a new one
would be too much. Instead, we are using this opportunity to
clean/simplify the current access right levels.
Specification
-------------
Remove the current access rights and replace them with the following:
- 'See own timesheets' the user can:
- read/write/create/delete his own timesheets only
- cannot timesheet on projects whose Visibility is set on
'Invited employees' if the user is not a follower
- see the 'My Timesheets' and the 'Timesheet App' menus
- 'See all timesheets' the user can:
- read/write/create/delete all timesheets
- cannot see the Timesheets of Projects whose Visibility is set
on 'Invited employees' if the user is not a follower
- see the 'My Timesheets' + 'All Timesheets' + 'Timesheet App' +
'To Validate' menus
- validate the timesheets of whom he is the 'Timesheet
Responsible' or the 'Manager'
- 'Administrator' the user can:
- read/write/create/delete all timesheets
- see all menus
- validate the Timesheets of all employees
- Write tests
New Validation Process
======================
Purpose
-------
Currently, timesheets are validated by employees as a whole. However,
as a manager, it is difficult to evaluate the timesheets of an
employee for projects you know nothing about. Therefore, it would make
more sense for a manager to validate the timesheets of his projects
and to leave the rest to the other managers.
In addition, having a date on the employees leaves room for error and
performance issues, so we are introducing a new checkbox at the
timesheet level.
Specification
-------------
Remove the 'Timesheets Validation Date' field on the employee
- replace it with a 'Timesheet Validated' checkbox (hidden field) on
the timesheet entry
- By default, this field is set to false
- Once the manager validates the timesheets, switch the field to
true
- Filtering on 'Validated' timesheets should display timesheets for
which 'Timesheet Validated' is true
- Filtering on 'Non-Validated' timesheets should display timesheets
for which 'Timesheet Validated' is false
Rename the title of the validation menus into:
- To Validate > Last Weeks -> Timesheets from Last Week to Validate
- To Validate > Last Month -> Timesheets from Last Month to Validate
Grid view: display projects on which the user timesheeted in the
past 30 days (group_expand), or the employees who timesheeted in the
past 30 days for the 'To Validate' menus)
Open the 'To Validate' menus on the oldest non-validated timesheet
you can find.
Clicking on 'Validate' should validate all the timesheets displayed
on the screen. List all the elements displayed in the view based on
the group by applied in the 'Validate the timesheets' modal.
When validating timesheets, bypass timesheets that were already
validated in the past in order to avoid performance issues
Timer list
==========
Purpose
-------
Have an overview of the timesheets you need to do during the day.
Easily track time spent on tasks using the timer instead of having to
write down somewhere at what time you started an activity. Allow more
flexibility if an activity if interrupted by another one (so you
don't need to do mental arithmetic to determine how much time the
first activity took substracted by the time spent on the second
activity).
Specification
-------------
- Move the time from fsm to hr_timesheet.
- Add fa-play-circle and fa-stop-circle icons in the 'My Timesheets'
menu:
- on the left of the 'Duration' field in the list view
- on the top right corner of the kanban card in the kanban view
- on the right of the first column in the grid view
- Display these icons
- for non-validated timesheets only
- if the Encoding Unit is Hours
- Clicking on the play button should start and display the timer and
switch to the stop icon
- Clicking on the stop button should halt and stop displaying the
timer and switch to the play icon
- While the timer is running, the 'Duration' field should not
be editable
- Only one timer can run at the same time. If the user clicks on the
start button of timesheet B while the timer is running for
timesheet A, timesheet B should start and timesheet A should stop.
Time selection when invoicing
=============================
Purpose
-------
Allow selecting a timeframe to invoice when the user is invoicing on
a periodical basis. For instance, the user invoices each time at the
end of the month. For some reason (he got sick, there was a bank
holiday...), he only invoices of the 4th of the following month.
Now the timesheets that are about to be invoiced include some that
were recorded in the meantime.
Specification
-------------
- Add an 'Invoice timesheets up to mm/dd/yyyy' field on the Invoice
Creation modal
- display this field only when creating an Invoice for a Service
whose Invoicing Policy is on 'Timesheets on tasks'
- not required
- Set today's date by default
- if this field is set, the quantity to invoice should only
include the not yet invoiced (and validated) timesheets up to
this date (included)
- if this field is not set, include all not yet invoiced (and
validated) timesheets without distinction.
- Add the following tooltip: Only timesheets not yet invoiced
(and validated, if applicable) up to this date included will be
invoiced. If no date is indicated, all timesheets not yet
invoiced (and validated, if applicable) will be invoiced
without distinction.
Miscellaneous
=============
Purpose
-------
Small usability improvements.
- Move 'Create SO from Task' to sale_timesheet -> Means Enterprise to
Community).
- Add the pivot view to the 'All Timesheets' menu
- Add the 'Today' button on the grid view of all menus (see the
Reporting > Timesheets by Project menu for reference)
closes odoo/odoo#40495
Taskid: 2088586
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
Co-authored-by: Lucas Lefèvre <lul@odoo.com>
Co-authored-by: Yannick Tivisse <yti@odoo.com>
This commit is contained in:
co-authored by
Lucas Lefèvre
Yannick Tivisse
commit
7dfc79252d
@@ -54,7 +54,6 @@ class AccountAnalyticTag(models.Model):
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = 'account.analytic.line'
|
||||
_description = 'Analytic Line'
|
||||
_order = 'date desc'
|
||||
|
||||
product_id = fields.Many2one('product.product', string='Product', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
|
||||
general_account_id = fields.Many2one('account.account', string='Financial Account', ondelete='restrict', readonly=True,
|
||||
|
||||
@@ -65,10 +65,6 @@
|
||||
<field name="model_id" ref="analytic.model_account_analytic_line"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('account.group_account_invoice'))]"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="1"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import report
|
||||
from . import wizard
|
||||
|
||||
@@ -34,6 +34,7 @@ up a management by affair.
|
||||
'report/report_timesheet_templates.xml',
|
||||
'views/hr_views.xml',
|
||||
'data/hr_timesheet_data.xml',
|
||||
'wizard/project_task_create_timesheet_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/hr_timesheet_demo.xml',
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- For a perfect onboarding, we want the tour of project to be consumed
|
||||
only by admin when installing hr_timesheet, to avoid confusing -->
|
||||
<record id="web_tour_project_consumed_by_admin" model="web_tour.tour">
|
||||
<field name="name">project_tour</field>
|
||||
<field name="user_id" ref='base.user_admin'/>
|
||||
</record>
|
||||
|
||||
<!-- Set the JS widget -->
|
||||
<record id="uom.product_uom_day" model="uom.uom">
|
||||
<field name="timesheet_widget">float_toggle</field>
|
||||
@@ -23,4 +15,12 @@
|
||||
name="_init_data_analytic_account"
|
||||
eval="[]"/>
|
||||
|
||||
<record id="ir_config_parameter_timesheet_rounding" model="ir.config_parameter">
|
||||
<field name="key">hr_timesheet.timesheet_rounding</field>
|
||||
<field name="value">15</field>
|
||||
</record>
|
||||
<record id="ir_config_parameter_timesheet_min_duration" model="ir.config_parameter">
|
||||
<field name="key">hr_timesheet.timesheet_min_duration</field>
|
||||
<field name="value">15</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- User Demo -->
|
||||
<record id="base.user_demo" model="res.users">
|
||||
<field name="groups_id" eval="[(4, ref('group_hr_timesheet_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Employee -->
|
||||
<record id="hr.employee_admin" model="hr.employee">
|
||||
<field name="timesheet_cost">100</field>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import timer_mixin
|
||||
from . import hr_employee
|
||||
from . import hr_timesheet
|
||||
from . import ir_http
|
||||
|
||||
@@ -5,11 +5,12 @@ from lxml import etree
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
from odoo.osv import expression
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = 'account.analytic.line'
|
||||
_name = 'account.analytic.line'
|
||||
_inherit = ['account.analytic.line', 'timer.mixin']
|
||||
|
||||
@api.model
|
||||
def default_get(self, field_list):
|
||||
@@ -20,15 +21,36 @@ class AccountAnalyticLine(models.Model):
|
||||
result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id'])], limit=1).id
|
||||
return result
|
||||
|
||||
def _domain_project_id(self):
|
||||
domain = [('allow_timesheets', '=', True)]
|
||||
if not self.user_has_groups('hr_timesheet.group_timesheet_manager'):
|
||||
return expression.AND([domain,
|
||||
['|', ('privacy_visibility', '!=', 'followers'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])]
|
||||
])
|
||||
return domain
|
||||
|
||||
def _domain_employee_id(self):
|
||||
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'):
|
||||
return [('user_id', '=', self.env.user.id)]
|
||||
return []
|
||||
|
||||
def _domain_task_id(self):
|
||||
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'):
|
||||
return ['|', ('privacy_visibility', '!=', 'followers'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])]
|
||||
return []
|
||||
|
||||
task_id = fields.Many2one(
|
||||
'project.task', 'Task', index=True,
|
||||
domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True), ('project_id', '=?', project_id)]"
|
||||
)
|
||||
project_id = fields.Many2one('project.project', 'Project', domain=[('allow_timesheets', '=', True)])
|
||||
project_id = fields.Many2one('project.project', 'Project', domain=_domain_project_id)
|
||||
|
||||
employee_id = fields.Many2one('hr.employee', "Employee", check_company=True)
|
||||
employee_id = fields.Many2one('hr.employee', "Employee", check_company=True, domain=_domain_employee_id)
|
||||
department_id = fields.Many2one('hr.department', "Department", compute='_compute_department_id', store=True, compute_sudo=True)
|
||||
encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id')
|
||||
display_timer = fields.Boolean(
|
||||
compute='_compute_display_timer',
|
||||
help="Technical field used to display the timer if the encoding unit is 'Hours'.")
|
||||
|
||||
def _compute_encoding_uom_id(self):
|
||||
for analytic_line in self:
|
||||
@@ -57,10 +79,6 @@ class AccountAnalyticLine(models.Model):
|
||||
for line in self:
|
||||
line.department_id = line.employee_id.department_id
|
||||
|
||||
# ----------------------------------------------------
|
||||
# ORM overrides
|
||||
# ----------------------------------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
default_user_id = self._default_user()
|
||||
@@ -81,6 +99,10 @@ class AccountAnalyticLine(models.Model):
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
# If it's a basic user then check if the timesheet is his own.
|
||||
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') and any(self.env.user.id != analytic_line.user_id.id for analytic_line in self):
|
||||
raise AccessError(_("You cannot access timesheets that are not yours."))
|
||||
|
||||
values = self._timesheet_preprocess(values)
|
||||
result = super(AccountAnalyticLine, self).write(values)
|
||||
# applied only for timesheet
|
||||
@@ -105,10 +127,6 @@ class AccountAnalyticLine(models.Model):
|
||||
node.set('string', _('Duration (%s)') % (re.sub(r'[\(\)]', '', encoding_uom.name or '')))
|
||||
return etree.tostring(doc, encoding='unicode')
|
||||
|
||||
# ----------------------------------------------------
|
||||
# Business Methods
|
||||
# ----------------------------------------------------
|
||||
|
||||
def _timesheet_get_portal_domain(self):
|
||||
return ['|', '&',
|
||||
('task_id.project_id.privacy_visibility', '=', 'portal'),
|
||||
@@ -177,3 +195,46 @@ class AccountAnalyticLine(models.Model):
|
||||
'amount': amount_converted,
|
||||
})
|
||||
return result
|
||||
|
||||
def _compute_display_timer(self):
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
for analytic_line in self:
|
||||
analytic_line.display_timer = analytic_line.encoding_uom_id == uom_hour
|
||||
|
||||
def action_timer_start(self):
|
||||
""" Start timer and search if another timer hasn't been launched.
|
||||
If yes, then stop the timer before launch this timer.
|
||||
"""
|
||||
if not self.timer_start and self.display_timer:
|
||||
self._stop_running_timers()
|
||||
super().action_timer_start()
|
||||
|
||||
def _stop_running_timers(self):
|
||||
""" Search if a timesheet has a timer activated and stop the timer.
|
||||
Check if a timer is activated for another timesheet
|
||||
if yes, then update unit_amount field and stop timer,
|
||||
otherwise, do nothing.
|
||||
"""
|
||||
analytic_line = self.search([('timer_start', '!=', False), ('user_id', '=', self.env.uid)])
|
||||
if analytic_line:
|
||||
analytic_line.action_timer_stop()
|
||||
|
||||
def action_timer_stop(self):
|
||||
""" Action stop the timer of the current timesheet.
|
||||
When the timer must be stopped, we must calculate the new
|
||||
unit_amount based on the timer and the previous value of
|
||||
unit_amount for the current timesheet.
|
||||
"""
|
||||
if self.timer_start and self.display_timer:
|
||||
minutes_spent = self._get_minutes_spent()
|
||||
if self.unit_amount == 0 and minutes_spent < 1:
|
||||
# Check if unit_amount equals 0 and minutes_spent is less than 1 minute,
|
||||
# if yes, then remove the timesheet
|
||||
self.unlink()
|
||||
else:
|
||||
if minutes_spent < 1:
|
||||
amount = self.unit_amount
|
||||
else:
|
||||
amount = self.unit_amount + minutes_spent * 60 / 3600
|
||||
self.write({'unit_amount': amount})
|
||||
super().action_timer_stop()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from math import ceil
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
@@ -16,6 +17,15 @@ class Project(models.Model):
|
||||
('partner_id', '=?', partner_id),
|
||||
]"""
|
||||
)
|
||||
allow_timesheet_timer = fields.Boolean('Timesheet Timer', default=False, help="Use a timer to record timesheets on tasks")
|
||||
|
||||
_sql_constraints = [
|
||||
('timer_only_when_timesheet', "CHECK((allow_timesheets = 'f' AND allow_timesheet_timer = 'f') OR (allow_timesheets = 't'))", 'The timesheet timer can only be activated on project allowing timesheets.'),
|
||||
]
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
return {'domain': {'analytic_account_id': [('partner_id', '=', self.partner_id.id)] if self.partner_id else []}}
|
||||
|
||||
@api.onchange('analytic_account_id')
|
||||
def _onchange_analytic_account(self):
|
||||
@@ -28,6 +38,11 @@ class Project(models.Model):
|
||||
if project.allow_timesheets and not project.analytic_account_id:
|
||||
raise ValidationError(_('To allow timesheet, your project %s should have an analytic account set.' % (project.name,)))
|
||||
|
||||
@api.onchange('allow_timesheets')
|
||||
def _onchange_allow_timesheets(self):
|
||||
if not self.allow_timesheets:
|
||||
self.allow_timesheet_timer = False
|
||||
|
||||
@api.model
|
||||
def name_create(self, name):
|
||||
""" Create a project with name_create should generate analytic account creation """
|
||||
@@ -55,19 +70,21 @@ class Project(models.Model):
|
||||
if not project.analytic_account_id and not values.get('analytic_account_id'):
|
||||
project._create_analytic_account()
|
||||
result = super(Project, self).write(values)
|
||||
if 'allow_timesheet_timer' in values and not values.get('allow_timesheet_timer'):
|
||||
self.with_context(active_test=False).mapped('task_ids').write({
|
||||
'timer_start': False,
|
||||
'timer_pause': False,
|
||||
})
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Business Methods
|
||||
# ---------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _init_data_analytic_account(self):
|
||||
self.search([('analytic_account_id', '=', False), ('allow_timesheets', '=', True)])._create_analytic_account()
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
_inherit = "project.task"
|
||||
_name = "project.task"
|
||||
_inherit = ["project.task", "timer.mixin"]
|
||||
|
||||
analytic_account_active = fields.Boolean("Analytic Account", related='project_id.analytic_account_id.active', readonly=True)
|
||||
allow_timesheets = fields.Boolean("Allow timesheets", related='project_id.allow_timesheets', help="Timesheets can be logged on this task.", readonly=True)
|
||||
@@ -78,6 +95,13 @@ class Task(models.Model):
|
||||
subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)")
|
||||
timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
|
||||
|
||||
timer_start = fields.Datetime("Timesheet Timer Start")
|
||||
timer_pause = fields.Datetime("Timesheet Timer Last Pause")
|
||||
# YTI FIXME: Those field seems quite useless
|
||||
timesheet_timer_first_start = fields.Datetime("Timesheet Timer First Use", readonly=True)
|
||||
timesheet_timer_last_stop = fields.Datetime("Timesheet Timer Last Use", readonly=True)
|
||||
display_timesheet_timer = fields.Boolean("Display Timesheet Time", compute='_compute_display_timesheet_timer')
|
||||
|
||||
@api.depends('timesheet_ids.unit_amount')
|
||||
def _compute_effective_hours(self):
|
||||
for task in self:
|
||||
@@ -110,14 +134,15 @@ class Task(models.Model):
|
||||
for task in self:
|
||||
task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ORM
|
||||
# ---------------------------------------------------------
|
||||
@api.depends('allow_timesheets', 'project_id.allow_timesheet_timer', 'analytic_account_active')
|
||||
def _compute_display_timesheet_timer(self):
|
||||
for task in self:
|
||||
task.display_timesheet_timer = task.allow_timesheets and task.project_id.allow_timesheet_timer and task.analytic_account_active
|
||||
|
||||
def write(self, values):
|
||||
# a timesheet must have an analytic account (and a project)
|
||||
if 'project_id' in values and self and not values.get('project_id'):
|
||||
raise UserError(_('This task must be part of a project because there are some timesheets linked to it.'))
|
||||
raise UserError(_('This task must be part of a project because there are some timesheets linked to it.'))
|
||||
return super(Task, self).write(values)
|
||||
|
||||
def name_get(self):
|
||||
@@ -137,3 +162,40 @@ class Task(models.Model):
|
||||
result = super(Task, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
|
||||
result['arch'] = self.env['account.analytic.line']._apply_timesheet_label(result['arch'])
|
||||
return result
|
||||
|
||||
def action_timer_start(self):
|
||||
self.ensure_one()
|
||||
if not self.timesheet_timer_first_start:
|
||||
self.write({'timesheet_timer_first_start': fields.Datetime.now()})
|
||||
super(Task, self).action_timer_start()
|
||||
|
||||
def action_timer_stop(self):
|
||||
self.ensure_one()
|
||||
if self.timer_start: # timer was either running or paused
|
||||
minutes_spent = self._get_minutes_spent()
|
||||
minutes_spent = self._timer_rounding(minutes_spent)
|
||||
return self._action_create_timesheet(minutes_spent * 60 / 3600)
|
||||
return False
|
||||
|
||||
def _timer_rounding(self, minutes_spent):
|
||||
minimum_duration = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_min_duration', 0))
|
||||
rounding = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_rounding', 0))
|
||||
minutes_spent = max(minimum_duration, minutes_spent)
|
||||
if rounding and ceil(minutes_spent % rounding) != 0:
|
||||
minutes_spent = ceil(minutes_spent / rounding) * rounding
|
||||
return minutes_spent
|
||||
|
||||
def _action_create_timesheet(self, time_spent):
|
||||
return {
|
||||
"name": _("Validate Spent Time"),
|
||||
"type": 'ir.actions.act_window',
|
||||
"res_model": 'project.task.create.timesheet',
|
||||
"views": [[False, "form"]],
|
||||
"target": 'new',
|
||||
"context": {
|
||||
**self.env.context,
|
||||
'active_id': self.id,
|
||||
'active_model': 'project.task',
|
||||
'default_time_spent': time_spent,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,3 +18,5 @@ class ResConfigSettings(models.TransientModel):
|
||||
related='company_id.timesheet_encode_uom_id', readonly=False,
|
||||
help="""This will set the unit of measure used to encode timesheet. This will simply provide tools
|
||||
and widgets to help the encoding. All reporting will still be expressed in hours (default value).""")
|
||||
timesheet_min_duration = fields.Integer('Minimal duration', default=15, config_parameter='hr_timesheet.timesheet_min_duration')
|
||||
timesheet_rounding = fields.Integer('Rounding up', default=15, config_parameter='hr_timesheet.timesheet_rounding')
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class TimerMixin(models.AbstractModel):
|
||||
_name = 'timer.mixin'
|
||||
_description = 'Timer Mixin'
|
||||
# YTI Note: This mixin is supposed to be extended to
|
||||
# any models
|
||||
|
||||
timer_start = fields.Datetime("Timer Start")
|
||||
timer_pause = fields.Datetime("Timer Last Pause")
|
||||
is_timer_running = fields.Boolean(compute="_compute_timer")
|
||||
|
||||
@api.depends('timer_start')
|
||||
def _compute_timer(self):
|
||||
for record in self:
|
||||
record.is_timer_running = bool(record.timer_start)
|
||||
|
||||
def action_timer_start(self):
|
||||
""" Action start the timer.
|
||||
Start timer and search if another timer hasn't been launched.
|
||||
If yes, then stop the timer before launch this timer.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.timer_start:
|
||||
self.write({'timer_start': fields.Datetime.now()})
|
||||
|
||||
def action_timer_stop(self):
|
||||
""" Stop the timer and return the spent minutes since it started
|
||||
:return minutes_spent if the timer is started,
|
||||
otherwise return False
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.timer_start:
|
||||
return False
|
||||
minutes_spent = self._get_minutes_spent()
|
||||
self.write({'timer_start': False, 'timer_pause': False})
|
||||
return minutes_spent
|
||||
|
||||
def _get_minutes_spent(self):
|
||||
start_time = self.timer_start
|
||||
stop_time = fields.Datetime.now()
|
||||
# timer was either running or paused
|
||||
if self.timer_pause:
|
||||
start_time += (stop_time - self.timer_pause)
|
||||
return (stop_time - start_time).total_seconds() / 60
|
||||
|
||||
def action_timer_pause(self):
|
||||
self.write({'timer_pause': fields.Datetime.now()})
|
||||
|
||||
def action_timer_resume(self):
|
||||
new_start = self.timer_start + (fields.Datetime.now() - self.timer_pause)
|
||||
self.write({'timer_start': new_start, 'timer_pause': False})
|
||||
|
||||
@api.model
|
||||
def get_server_time(self):
|
||||
""" Returns the server time.
|
||||
The timer widget needs the server time instead of the client time
|
||||
to avoid time desynchronization issues like the timer beginning at 0:00
|
||||
and not 23:59 and so on.
|
||||
"""
|
||||
return fields.Datetime.now()
|
||||
@@ -7,32 +7,14 @@
|
||||
</record>
|
||||
|
||||
<record id="group_hr_timesheet_user" model="res.groups">
|
||||
<field name="name">See all Timesheets</field>
|
||||
<field name="name">See own timesheets</field>
|
||||
<field name="category_id" ref="base.module_category_operations_timesheets"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="base.default_user" model="res.users">
|
||||
<field name="groups_id" eval="[(4,ref('group_hr_timesheet_user'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
|
||||
<data noupdate="0">
|
||||
<!-- This rule is in noupdate mode because it can be modify by other advanced modules -->
|
||||
<record id="timesheet_line_rule_user" model="ir.rule">
|
||||
<field name="name">account.analytic.line.timesheet.user</field>
|
||||
<field name="model_id" ref="analytic.model_account_analytic_line"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id), ('project_id', '!=', False)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_hr_timesheet_user'))]"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="1"/>
|
||||
<field name="perm_read" eval="0"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_timesheet.group_hr_timesheet_approver" model="res.groups">
|
||||
<field name="name">Team Approver</field>
|
||||
<record id="group_hr_timesheet_approver" model="res.groups">
|
||||
<field name="name">See all timesheets</field>
|
||||
<field name="category_id" ref="base.module_category_operations_timesheets"/>
|
||||
<field name="implied_ids" eval="[(4, ref('hr_timesheet.group_hr_timesheet_user'))]"/>
|
||||
</record>
|
||||
@@ -40,24 +22,46 @@
|
||||
<record id="group_timesheet_manager" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="category_id" ref="base.module_category_operations_timesheets"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_hr_timesheet_approver')), (4, ref('hr.group_hr_user'))]"/>
|
||||
<field name="implied_ids" eval="[(4, ref('hr_timesheet.group_hr_timesheet_approver')), (4, ref('hr.group_hr_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
|
||||
<data noupdate="1">
|
||||
<record id="base.default_user" model="res.users">
|
||||
<field name="groups_id" eval="[(4,ref('group_timesheet_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_line_rule_user" model="ir.rule">
|
||||
<field name="name">account.analytic.line.timesheet.user</field>
|
||||
<field name="model_id" ref="analytic.model_account_analytic_line"/>
|
||||
<field name="domain_force">[
|
||||
('user_id', '=', user.id),
|
||||
('project_id', '!=', False),
|
||||
'|', '|',
|
||||
('project_id.privacy_visibility', '!=', 'followers'),
|
||||
('project_id.message_partner_ids', 'in', [user.partner_id.id]),
|
||||
('task_id.message_partner_ids', 'in', [user.partner_id.id])
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('group_hr_timesheet_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_line_rule_approver" model="ir.rule">
|
||||
<field name="name">account.analytic.line.timesheet.approver</field>
|
||||
<field name="model_id" ref="analytic.model_account_analytic_line" />
|
||||
<field name="domain_force">[
|
||||
('project_id', '!=', False),
|
||||
'|',
|
||||
('project_id.privacy_visibility', '!=', 'followers'),
|
||||
('project_id.message_partner_ids', 'in', [user.partner_id.id])
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('hr_timesheet.group_hr_timesheet_approver'))]" />
|
||||
</record>
|
||||
|
||||
<record id="timesheet_line_rule_manager" model="ir.rule">
|
||||
<field name="name">account.analytic.line.timesheet.manager</field>
|
||||
<field name="model_id" ref="analytic.model_account_analytic_line"/>
|
||||
<field name="domain_force">[('project_id', '!=', False)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_timesheet_manager'))]"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="1"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
odoo.define('hr_timesheet.Timer', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This class improves the timer display using the
|
||||
* unit_amount field on account.analytic.line.
|
||||
*/
|
||||
class Timer {
|
||||
constructor(hours, minutes, seconds) {
|
||||
this.hours = hours;
|
||||
this.minutes = minutes;
|
||||
this.seconds = seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert float to time
|
||||
* @param {number} float
|
||||
*/
|
||||
static convertFloatToTime(float) {
|
||||
if (float === 0) {
|
||||
return new Timer(0, 0, 0);
|
||||
}
|
||||
|
||||
let minutes = float % 1;
|
||||
const hours = float - minutes;
|
||||
minutes *= 60;
|
||||
|
||||
return new Timer(hours, Math.round(minutes), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timer
|
||||
* @param {number} unit_amount
|
||||
* @param {String} timer_start
|
||||
* @param {String} serverTime
|
||||
*/
|
||||
static createTimer(unit_amount, timer_start, serverTime) {
|
||||
const timer = this.convertFloatToTime(unit_amount);
|
||||
|
||||
timer.addTime(
|
||||
moment.utc(
|
||||
moment.utc(serverTime)
|
||||
.diff(moment.utc(timer_start))
|
||||
).format("HH:mm:ss")
|
||||
);
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
addHours(hours) {
|
||||
this.hours += hours;
|
||||
}
|
||||
|
||||
addMinutes(minutes) {
|
||||
minutes += this.minutes;
|
||||
|
||||
this.minutes = minutes % 60;
|
||||
this.addHours(Math.floor(minutes / 60));
|
||||
}
|
||||
|
||||
addSeconds(seconds) {
|
||||
seconds += this.seconds;
|
||||
this.seconds = seconds % 60;
|
||||
this.addMinutes(Math.floor(seconds / 60));
|
||||
}
|
||||
|
||||
display2digits(number) {
|
||||
return number > 9 ? "" + number : "0" + number;
|
||||
}
|
||||
|
||||
addSecond() {
|
||||
this.seconds += 1;
|
||||
if (this.seconds === 60) {
|
||||
this.minutes += 1;
|
||||
this.seconds = 0;
|
||||
|
||||
if (this.minutes === 60) {
|
||||
this.hours += 1;
|
||||
this.minutes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addTime(time) {
|
||||
if (typeof time == 'string' && time.indexOf(':') !== -1) {
|
||||
let [hour, minute, second] = time.split(':');
|
||||
|
||||
hour = parseInt(hour);
|
||||
minute = parseInt(minute);
|
||||
second = parseInt(second);
|
||||
|
||||
this.addSeconds(second);
|
||||
this.addMinutes(minute);
|
||||
this.addHours(hour);
|
||||
}
|
||||
}
|
||||
|
||||
convertToFloat() {
|
||||
return (this.hours * 60 + this.minutes) * 60 / 3600;
|
||||
}
|
||||
|
||||
toString() {
|
||||
const time = {
|
||||
hours: this.display2digits(this.hours),
|
||||
minutes: this.display2digits(this.minutes),
|
||||
seconds: this.display2digits(this.seconds)
|
||||
};
|
||||
|
||||
return `${time.hours}:${time.minutes}:${time.seconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
return Timer;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
odoo.define('hr_timesheet.timer', function (require) {
|
||||
"use strict";
|
||||
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
var AbstractField = require('web.AbstractField');
|
||||
var Timer = require('hr_timesheet.Timer');
|
||||
|
||||
var TimerFieldWidget = AbstractField.extend({
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
isSet: function () {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_render: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._startTimeCounter();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
destroy: function () {
|
||||
this._super.apply(this, arguments);
|
||||
clearInterval(this.timer);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_startTimeCounter: async function () {
|
||||
if (this.record.data.timer_start) {
|
||||
const serverTime = this.record.data.timer_pause || await this._getServerTime();
|
||||
this.time = Timer.createTimer(0, this.record.data.timer_start, serverTime);
|
||||
this.$el.text(this.time.toString());
|
||||
this.timer = setInterval(() => {
|
||||
if (this.record.data.timer_pause) {
|
||||
clearInterval(this.timer);
|
||||
} else {
|
||||
this.time.addSecond();
|
||||
this.$el.text(this.time.toString());
|
||||
}
|
||||
}, 1000);
|
||||
} else if (!this.record.data.timer_pause){
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
},
|
||||
_getServerTime: function () {
|
||||
return this._rpc({
|
||||
model: 'account.analytic.line',
|
||||
method: 'get_server_time',
|
||||
args: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fieldRegistry.add('timesheet_timer', TimerFieldWidget);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
odoo.define('hr_timesheet.timer_toggle_button', function (require) {
|
||||
"use strict";
|
||||
|
||||
const fieldRegistry = require('web.field_registry');
|
||||
const { FieldToggleBoolean } = require("web.basic_fields");
|
||||
|
||||
/**
|
||||
* The TimerToggleButton is used to display correctly the button
|
||||
* to start or stop a timer for a timesheet in kanban, list and grid
|
||||
* views.
|
||||
*/
|
||||
const TimerToggleButton = FieldToggleBoolean.extend({
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_render: function () {
|
||||
// When the is_timer_running field is false, then the button is used to start the timer
|
||||
const title = this.value ? 'stop' : 'play';
|
||||
const name = this.value ? 'action_timer_stop' : 'action_timer_start';
|
||||
const label = this.value ? 'stop' : 'start';
|
||||
|
||||
this.$('i')
|
||||
.addClass('fa')
|
||||
.toggleClass('fa-stop-circle o-timer-stop-button', this.value)
|
||||
.toggleClass('fa-play-circle o-timer-play-button', !this.value)
|
||||
.attr('title', title);
|
||||
|
||||
this.$el.addClass('o-timer-button');
|
||||
this.$el.attr('title', title);
|
||||
this.$el.attr('name', name);
|
||||
this.$el.attr('aria-label', label);
|
||||
this.$el.attr('aria-pressed', this.value);
|
||||
this.$el.attr('type', 'button');
|
||||
this.$el.attr('role', 'button');
|
||||
},
|
||||
/**
|
||||
* Toggle the button
|
||||
*
|
||||
* When the user click on this button,
|
||||
* - the action "action_timer_start" is called
|
||||
* into the account.analytic.line model,
|
||||
* if the value of is_timer_running field is set on false.
|
||||
* - the action "action_timer_stop" is called
|
||||
* into the account.analytic.line model,
|
||||
* if the value of is_timer_running field is set on true.
|
||||
* Then we change the value of the is_timer_running.
|
||||
* @override
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onToggleButton: async function (event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const result = await this._rpc({
|
||||
model: 'account.analytic.line',
|
||||
method: this._getActionButton(),
|
||||
args: [this.res_id]
|
||||
});
|
||||
|
||||
this.trigger_up('timer_changed', {
|
||||
id: this.res_id,
|
||||
is_timer_running: !this.value
|
||||
});
|
||||
|
||||
this._setValue(!this.value);
|
||||
},
|
||||
_getActionButton: function () {
|
||||
return this.value ? 'action_timer_stop' : 'action_timer_start';
|
||||
}
|
||||
});
|
||||
|
||||
fieldRegistry.add('timer_toggle_button', TimerToggleButton);
|
||||
|
||||
return TimerToggleButton;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
odoo.define('hr_timesheet.timesheet_kanban_view', function (require) {
|
||||
"use strict";
|
||||
|
||||
const KanbanController = require('web.KanbanController');
|
||||
const KanbanView = require('web.KanbanView');
|
||||
const viewRegistry = require('web.view_registry');
|
||||
|
||||
/**
|
||||
* @override the KanbanController to add a trigger when a timer is launched or stopped
|
||||
*/
|
||||
const TimesheetKanbanController = KanbanController.extend({
|
||||
custom_events: _.extend({}, KanbanController.prototype.custom_events, {
|
||||
'timer_changed': '_onTimerChanged',
|
||||
}),
|
||||
/**
|
||||
* When a timer is launched or stopped, we reload the view to see the updating.
|
||||
* @param {Object} event
|
||||
*/
|
||||
_onTimerChanged: function (event) {
|
||||
this.reload();
|
||||
}
|
||||
});
|
||||
|
||||
const TimesheetKanbanView = KanbanView.extend({
|
||||
config: _.extend({}, KanbanView.prototype.config, {
|
||||
Controller: TimesheetKanbanController
|
||||
})
|
||||
});
|
||||
|
||||
viewRegistry.add('timesheet_kanban_view', TimesheetKanbanView);
|
||||
|
||||
return { TimesheetKanbanController, TimesheetKanbanView };
|
||||
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
odoo.define('hr_timesheet.timesheet_list_view', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ListController = require('web.ListController');
|
||||
var ListView = require('web.ListView');
|
||||
var viewRegistry = require('web.view_registry');
|
||||
|
||||
/**
|
||||
* @override the ListController to add a trigger when the timer is launched or stopped
|
||||
*/
|
||||
const TimesheetListController = ListController.extend({
|
||||
custom_events: _.extend({}, ListController.prototype.custom_events, {
|
||||
'timer_changed': '_onTimerChanged',
|
||||
}),
|
||||
/**
|
||||
* When a timer is launched or stopped, we reload the view to see the updating.
|
||||
* @param {Object} event
|
||||
*/
|
||||
_onTimerChanged: function (event) {
|
||||
this.reload();
|
||||
}
|
||||
});
|
||||
|
||||
const TimesheetListView = ListView.extend({
|
||||
config: _.extend({}, ListView.prototype.config, {
|
||||
Controller: TimesheetListController
|
||||
})
|
||||
});
|
||||
|
||||
viewRegistry.add('timesheet_tree', TimesheetListView);
|
||||
|
||||
return { TimesheetListController, TimesheetListView };
|
||||
|
||||
});
|
||||
@@ -1,24 +1,25 @@
|
||||
odoo.define('hr_timesheet.timesheet_uom', function (require) {
|
||||
'use strict';
|
||||
|
||||
var AbstractField = require('web.AbstractField');
|
||||
var basicFields = require('web.basic_fields');
|
||||
var fieldUtils = require('web.field_utils');
|
||||
const basicFields = require('web.basic_fields');
|
||||
const fieldUtils = require('web.field_utils');
|
||||
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
const fieldRegistry = require('web.field_registry');
|
||||
|
||||
const Timer = require('hr_timesheet.Timer');
|
||||
|
||||
// We need the field registry to be populated, as we bind the
|
||||
// timesheet_uom widget on existing field widgets.
|
||||
require('web._field_registry');
|
||||
|
||||
var session = require('web.session');
|
||||
const session = require('web.session');
|
||||
|
||||
/**
|
||||
* Extend the float factor widget to set default value for timesheet
|
||||
* use case. The 'factor' is forced to be the UoM timesheet
|
||||
* conversion from the session info.
|
||||
**/
|
||||
var FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({
|
||||
const FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({
|
||||
formatType: 'float_factor',
|
||||
/**
|
||||
* Override init to tweak options depending on the session_info
|
||||
@@ -44,7 +45,7 @@ var FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({
|
||||
* native widget, and the 'factor' is forced to be the UoM timesheet
|
||||
* conversion.
|
||||
**/
|
||||
var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({
|
||||
const FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({
|
||||
formatType: 'float_factor',
|
||||
/**
|
||||
* Override init to tweak options depending on the session_info
|
||||
@@ -72,6 +73,61 @@ var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Extend float time widget to add the using of a timer for duration
|
||||
* (unit_amount) field.
|
||||
*/
|
||||
const FieldTimesheetTime = basicFields.FieldFloatTime.extend({
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
if (session.timesheet_uom_factor) {
|
||||
this.nodeOptions.factor = session.timesheet_uom_factor;
|
||||
this.parseOptions.factor = session.timesheet_uom_factor;
|
||||
}
|
||||
},
|
||||
_render: async function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
// Check if the timer_start exists and it's not false
|
||||
// In other word, when user clicks on play button, this button
|
||||
// launches the "action_timer_start".
|
||||
if (this.recordData.timer_start) {
|
||||
const time = await this._rpc({
|
||||
model: this.model,
|
||||
method: 'get_server_time',
|
||||
args: []
|
||||
});
|
||||
this.time = Timer.createTimer(this.recordData.unit_amount, this.recordData.timer_start, time);
|
||||
return this._startTimeCounter();
|
||||
} else if (this.mode === "edit") {
|
||||
return this._renderEdit();
|
||||
} else if (this.mode === 'readonly') {
|
||||
return this._renderReadonly();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
destroy: function () {
|
||||
this._super.apply(this, arguments);
|
||||
clearTimeout(this.timer);
|
||||
},
|
||||
_startTimeCounter: function () {
|
||||
if (this.time) {
|
||||
this.timer = setInterval(() => {
|
||||
this.time.addSecond();
|
||||
this.$el.text(this.time.toString());
|
||||
this.$el.addClass('font-weight-bold text-danger');
|
||||
}, 1000);
|
||||
} else {
|
||||
clearTimeout(this.timer);
|
||||
this.$el.removeClass('font-weight-bold text-danger');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Binding depending on Company Preference
|
||||
*
|
||||
@@ -80,22 +136,27 @@ var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({
|
||||
* implementation (float_time, float_toggle, ...). The default
|
||||
* value will be 'float_factor'.
|
||||
**/
|
||||
var widgetName = 'timesheet_uom' in session ?
|
||||
const widgetName = 'timesheet_uom' in session ?
|
||||
session.timesheet_uom.timesheet_widget : 'float_factor';
|
||||
var FieldTimesheetUom = widgetName === 'float_toggle' ? FieldTimesheetToggle
|
||||
: (
|
||||
(
|
||||
fieldRegistry.get(widgetName) &&
|
||||
fieldRegistry.get(widgetName).extend({})
|
||||
) ||
|
||||
FieldTimesheetFactor
|
||||
);
|
||||
|
||||
let FieldTimesheetUom = null;
|
||||
|
||||
if (widgetName === 'float_toggle') {
|
||||
FieldTimesheetUom = FieldTimesheetToggle;
|
||||
} else if (widgetName === 'float_time') {
|
||||
FieldTimesheetUom = FieldTimesheetTime;
|
||||
} else {
|
||||
FieldTimesheetUom = (
|
||||
fieldRegistry.get(widgetName) &&
|
||||
fieldRegistry.get(widgetName).extend({})
|
||||
) || FieldTimesheetFactor;
|
||||
}
|
||||
|
||||
fieldRegistry.add('timesheet_uom', FieldTimesheetUom);
|
||||
|
||||
|
||||
// bind the formatter and parser method, and tweak the options
|
||||
var _tweak_options = function(options) {
|
||||
const _tweak_options = function(options) {
|
||||
if (!_.contains(options, 'factor')) {
|
||||
options.factor = session.timesheet_uom_factor;
|
||||
}
|
||||
@@ -104,15 +165,21 @@ var _tweak_options = function(options) {
|
||||
|
||||
fieldUtils.format.timesheet_uom = function(value, field, options) {
|
||||
options = _tweak_options(options || {});
|
||||
var formatter = fieldUtils.format[FieldTimesheetUom.prototype.formatType];
|
||||
const formatter = fieldUtils.format[FieldTimesheetUom.prototype.formatType];
|
||||
return formatter(value, field, options);
|
||||
};
|
||||
|
||||
fieldUtils.parse.timesheet_uom = function(value, field, options) {
|
||||
options = _tweak_options(options || {});
|
||||
var parser = fieldUtils.parse[FieldTimesheetUom.prototype.formatType];
|
||||
const parser = fieldUtils.parse[FieldTimesheetUom.prototype.formatType];
|
||||
return parser(value, field, options);
|
||||
};
|
||||
|
||||
return FieldTimesheetUom;
|
||||
return {
|
||||
FieldTimesheetUom,
|
||||
FieldTimesheetFactor,
|
||||
FieldTimesheetTime,
|
||||
FieldTimesheetToggle
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.o-timer-button {
|
||||
&:focus {
|
||||
border: none !important;
|
||||
outline: 0 !important;
|
||||
}
|
||||
|
||||
& > i {
|
||||
transition: 0.3s;
|
||||
|
||||
&.o-timer-play-button {
|
||||
color: $link-color !important;
|
||||
}
|
||||
|
||||
&.o-timer-stop-button {
|
||||
color: $red !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover > i.o-timer-play-button {
|
||||
color: $link-hover-color !important;
|
||||
}
|
||||
|
||||
&:hover > i.o-timer-stop-button {
|
||||
color: darken($red, 15%) !important;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class TestCommonTimesheet(TransactionCase):
|
||||
rule = self.env.ref('timesheet_grid.hr_timesheet_rule_approver_update', raise_if_not_found=False)
|
||||
if rule:
|
||||
rule.active = False
|
||||
|
||||
|
||||
# customer partner
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'Customer Task',
|
||||
@@ -85,6 +85,16 @@ class TestCommonTimesheet(TransactionCase):
|
||||
|
||||
class TestTimesheet(TestCommonTimesheet):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTimesheet, self).setUp()
|
||||
|
||||
# Crappy hack to disable the rule from timesheet grid, if it exists
|
||||
# The registry doesn't contain the field timesheet_manager_id.
|
||||
# but there is an ir.rule about it, crashing during its evaluation
|
||||
rule = self.env.ref('timesheet_grid.timesheet_line_rule_user_update-unlink', raise_if_not_found=False)
|
||||
if rule:
|
||||
rule.active = False
|
||||
|
||||
def test_log_timesheet(self):
|
||||
""" Test when log timesheet : check analytic account, user and employee are correctly set. """
|
||||
Timesheet = self.env['account.analytic.line']
|
||||
@@ -260,12 +270,18 @@ class TestTimesheet(TestCommonTimesheet):
|
||||
timesheets = timesheet_1 + timesheet_2
|
||||
|
||||
# increase unit_amount to trigger amount recomputation
|
||||
timesheets.sudo().write({
|
||||
with self.assertRaises(AccessError):
|
||||
# because the employee 1 is the sudo and he doesn't have the access right to update timesheet of employee 2
|
||||
timesheets.sudo().write({
|
||||
'unit_amount': 2,
|
||||
})
|
||||
|
||||
timesheets.with_user(self.user_manager).write({
|
||||
'unit_amount': 2,
|
||||
})
|
||||
|
||||
# since timesheet costs are different for both employees, we should get different amounts
|
||||
self.assertRecordValues(timesheets, [{
|
||||
self.assertRecordValues(timesheets.with_user(self.user_manager), [{
|
||||
'amount': -10.0,
|
||||
}, {
|
||||
'amount': -12.0,
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
<odoo>
|
||||
<template id="assets_backend" name="timesheet assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_uom.js"></script>
|
||||
<link type="text/scss" href="/hr_timesheet/static/src/scss/timer_button.scss" rel="stylesheet"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/task_with_hours.js"></script>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timer.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/Timer.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_uom.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timer_toggle_button.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_list_view.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_kanban_view.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
<field name="name">account.analytic.line.tree.hr_timesheet</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="top" string="Timesheet Activities">
|
||||
<tree editable="top" string="Timesheet Activities" js_class="timesheet_tree">
|
||||
<field name="date"/>
|
||||
<field name="name"/>
|
||||
<field name="project_id" required="1" context="{'form_view_ref': 'project.project_project_view_form_simplified',}"/>
|
||||
<field name="task_id" widget="task_with_hours" context="{'default_project_id': project_id}" domain="[('project_id', '=', project_id)]"/>
|
||||
<field name="unit_amount" widget="timesheet_uom" sum="Total"/>
|
||||
<field name="is_timer_running" string="Timer" widget="timer_toggle_button" readonly="1" attrs="{ 'invisible': [('display_timer', '=', False)] }" invisible="not context.get('my_timesheet_display_timer')" />
|
||||
<field name="unit_amount" widget="timesheet_uom" sum="Total" attrs="{'readonly': [('id', '!=', False), ('timer_start', '!=', False)]}"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="timer_start" invisible="1"/>
|
||||
<field name="display_timer" invisible="1"/>
|
||||
<field name="user_id" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -41,8 +45,6 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='date']" position="after">
|
||||
<field name="employee_id" required="1" options='{"no_open": True}'/>
|
||||
<field name="user_id" invisible="1"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
@@ -89,8 +91,9 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="amount"/>
|
||||
<field name="unit_amount" widget="timesheet_uom"/>
|
||||
<field name="unit_amount" widget="timesheet_uom" attrs="{'readonly': [('id', '!=', False), ('timer_start', '!=', False)]}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="timer_start" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
@@ -139,13 +142,15 @@
|
||||
<field name="name">account.analytic.line.kanban</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<kanban class="o_kanban_mobile" js_class="timesheet_kanban_view">
|
||||
<field name="date"/>
|
||||
<field name="user_id"/>
|
||||
<field name="name"/>
|
||||
<field name="project_id"/>
|
||||
<field name="task_id" context="{'default_project_id': project_id}" domain="[('project_id', '=', project_id)]"/>
|
||||
<field name="unit_amount"/>
|
||||
<field name="unit_amount" widget="timesheet_uom"/>
|
||||
<field name="timer_start" invisible="1"/>
|
||||
<field name="display_timer" invisible="1"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div t-attf-class="oe_kanban_global_click">
|
||||
@@ -161,6 +166,9 @@
|
||||
<span>
|
||||
<t t-esc="record.name.value"/>
|
||||
</span>
|
||||
<div class="float-right">
|
||||
<field name="is_timer_running" widget="timer_toggle_button" attrs="{ 'invisible': [('display_timer', '=', False)] }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,6 +196,7 @@
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="domain">[('project_id', '!=', False), ('user_id', '=', uid)]</field>
|
||||
<field name="context">{
|
||||
"my_timesheet_display_timer": True,
|
||||
"search_default_week":1,
|
||||
}</field>
|
||||
<field name="search_view_id" ref="hr_timesheet_line_search"/>
|
||||
@@ -239,6 +248,15 @@
|
||||
'search_default_groupby_project':1,
|
||||
'search_default_groupby_task':1,
|
||||
}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Record a new activity
|
||||
</p><p>
|
||||
You can register and track your workings hours by project every
|
||||
day. Every time spent on a project will become a cost and can be re-invoiced to
|
||||
customers if required.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_action_view_all_tree" model="ir.actions.act_window.view">
|
||||
@@ -255,10 +273,18 @@
|
||||
<field name="act_window_id" ref="timesheet_action_all"/>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_action_view_all_pivot" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="6"/>
|
||||
<field name="view_mode">pivot</field>
|
||||
<field name="view_id" ref="view_hr_timesheet_line_pivot"/>
|
||||
<field name="act_window_id" ref="timesheet_action_all"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="timesheet_menu_activity_all"
|
||||
name="All Timesheets"
|
||||
parent="menu_hr_time_tracking"
|
||||
action="timesheet_action_all"/>
|
||||
action="timesheet_action_all"
|
||||
groups="hr_timesheet.group_hr_timesheet_approver"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -59,14 +59,35 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_task_form2_inherited_hr_timesheet">
|
||||
<field name="name">project.task.form.inherited</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_form2" />
|
||||
<record id="project_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">project.view.form.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.project_invoice_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='legend_done']" position="after">
|
||||
<field name="analytic_account_active" invisible="1"/>
|
||||
<field name="allow_timesheets" invisible="1"/>
|
||||
<xpath expr="//div[@id='timesheet_settings']" position="after">
|
||||
<div class="col-lg-6 o_setting_box" attrs="{'invisible': [('allow_timesheets', '=', False)]}">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="allow_timesheet_timer"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="allow_timesheet_timer"/>
|
||||
<div class="text-muted">
|
||||
Track your time using a timer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_project_view_form_simplified_inherit" model="ir.ui.view">
|
||||
<field name="name">project.project.view.form.simplified.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.project_project_view_form_simplified"/>
|
||||
<field name="priority">32</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='allow_timesheets']" position="after">
|
||||
<field name="allow_timesheet_timer" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
@@ -74,10 +95,12 @@
|
||||
<record model="ir.ui.view" id="view_task_form2_inherited">
|
||||
<field name="name">project.task.form.inherited</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.view_task_form2_inherited_hr_timesheet" />
|
||||
<field name="inherit_id" ref="project.view_task_form2" />
|
||||
<field name="groups_id" eval="[(6,0, (ref('hr_timesheet.group_hr_timesheet_user'),))]"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook/page[@name='description_page']" position="after">
|
||||
<field name="analytic_account_active" invisible="1"/>
|
||||
<field name="allow_timesheets" invisible="1"/>
|
||||
<page string="Timesheets" id="timesheets_tab" attrs="{'invisible': [('allow_timesheets', '=', False)]}">
|
||||
<group>
|
||||
<group>
|
||||
@@ -221,5 +244,53 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_task_view_form" model="ir.ui.view">
|
||||
<field name="name">project.task.view.form.inherit.sale.timesheet.enterprise</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_form2"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position='inside'>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="display_timesheet_timer" invisible="1"/>
|
||||
<field name="timer_pause" invisible="1" />
|
||||
<button class="btn-primary" name="action_timer_start" type="object" string="Start"
|
||||
attrs="{'invisible': ['|', '|', ('total_hours_spent', '!=', 0), ('timer_start', '!=', False), ('display_timesheet_timer', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
<button class="btn-secondary" name="action_timer_start" type="object" string="Start"
|
||||
attrs="{'invisible': ['|', '|', ('total_hours_spent', '=', 0), ('timer_start', '!=', False), ('display_timesheet_timer', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
<button class="btn-primary btn-danger o_fsm_stop" name="action_timer_stop" type="object" string="Stop"
|
||||
attrs="{'invisible': ['|', ('timer_start', '=', False), ('display_timesheet_timer', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
<button class="btn-primary" name="action_timer_pause" type="object" string="Pause"
|
||||
attrs="{'invisible': ['|', '|', ('timer_pause', '!=', False), ('timer_start', '=', False), ('display_timesheet_timer', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
<button class="btn-primary btn-info" name="action_timer_resume" type="object" string="Resume"
|
||||
attrs="{'invisible': ['|', '|', ('timer_pause', '=', False), ('timer_start', '=', False), ('display_timesheet_timer', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='stage_id']" position="attributes">
|
||||
<attribute name="class">ml-2</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='stage_id']" position="before">
|
||||
<field name="timer_start" widget="timesheet_timer" class="text-danger ml-auto h2 ml-4 font-weight-bold" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Improve the task kanban in Project -->
|
||||
<record id="project_task_view_kanban" model="ir.ui.view">
|
||||
<field name="name">project.task.kanban.timer</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_kanban"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//span[@name='date']" position="before">
|
||||
<field name="display_timesheet_timer" invisible="1"/>
|
||||
<field name="timer_start" invisible="1"/>
|
||||
<field name="timer_pause" invisible="1"/>
|
||||
<t t-if="record.display_timesheet_timer && record.timer_pause.raw_value">
|
||||
<i class="fa fa-pause text-warning" title="Timer is Paused"></i>
|
||||
</t>
|
||||
<t t-elif="record.display_timesheet_timer && record.timer_start.raw_value">
|
||||
<i class="fa fa-play text-success" title="Timer is Running"></i>
|
||||
</t>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -72,6 +72,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<strong>Round timesheets</strong>
|
||||
<div class="o_row w-30">
|
||||
<span class="o_light_label"><label for="timesheet_min_duration"/><field name="timesheet_min_duration" class="col-lg-2"/> minutes</span>
|
||||
</div>
|
||||
<div class="o_row">
|
||||
<span class="o_light_label"><label for="timesheet_rounding"/><field name="timesheet_rounding" class="col-lg-2"/> minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div name="section_leaves" groups="base.group_no_one">
|
||||
<h2>Time Off</h2>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import project_task_create_timesheet
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from datetime import datetime
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ProjectTaskCreateTimesheet(models.TransientModel):
|
||||
_name = 'project.task.create.timesheet'
|
||||
_description = "Create Timesheet from task"
|
||||
|
||||
_sql_constraints = [('time_positive', 'CHECK(time_spent > 0)', 'The timesheet\'s time must be positive' )]
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
result = super(ProjectTaskCreateTimesheet, self).default_get(fields)
|
||||
active_model = self._context.get('active_model')
|
||||
if active_model != 'project.task':
|
||||
raise UserError(_("You can only apply this action from a task."))
|
||||
|
||||
active_id = self._context.get('active_id')
|
||||
if 'task_id' in fields and active_id:
|
||||
task_id = self.env['project.task'].browse(active_id)
|
||||
result['task_id'] = active_id
|
||||
result['description'] = task_id.name
|
||||
return result
|
||||
|
||||
time_spent = fields.Float('Time', precision_digits=2)
|
||||
description = fields.Char('Description')
|
||||
task_id = fields.Many2one('project.task', "Task", help="Task for which we are creating a sales order", required=True)
|
||||
|
||||
def save_timesheet(self):
|
||||
values = {
|
||||
'task_id': self.task_id.id,
|
||||
'project_id': self.task_id.project_id.id,
|
||||
'date': datetime.now(),
|
||||
'name': self.description,
|
||||
'user_id': self.env.uid,
|
||||
'unit_amount': self.time_spent,
|
||||
}
|
||||
self.task_id.write({
|
||||
'timer_start': False,
|
||||
'timer_pause': False,
|
||||
'timesheet_timer_last_stop': fields.datetime.now(),
|
||||
})
|
||||
return self.env['account.analytic.line'].create(values)
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_task_create_timesheet_view_form" model="ir.ui.view">
|
||||
<field name="name">project.task.create.timesheet.wizard.form</field>
|
||||
<field name="model">project.task.create.timesheet</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Save time">
|
||||
<group>
|
||||
<group>
|
||||
<field name="task_id" invisible="True"/>
|
||||
<label for="time_spent"/>
|
||||
<div class="o_row" >
|
||||
<field name="time_spent" widget="float_time" required="True"/>
|
||||
hours
|
||||
</div>
|
||||
<field name="description" widget="text" required="True"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Save" type="object" name="save_timesheet" class="btn btn-primary"/>
|
||||
<button string="Cancel" special="cancel" type="object" class="btn btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -572,7 +572,7 @@ class SaleOrder(models.Model):
|
||||
action['context'] = context
|
||||
return action
|
||||
|
||||
def _create_invoices(self, grouped=False, final=False):
|
||||
def _create_invoices(self, grouped=False, final=False, date=None):
|
||||
"""
|
||||
Create the invoice associated to the SO.
|
||||
:param grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by
|
||||
|
||||
@@ -29,6 +29,8 @@ have real delivered quantities in sales orders.
|
||||
'data/sale_timesheet_filters.xml',
|
||||
'wizard/project_create_sale_order_views.xml',
|
||||
'wizard/project_create_invoice_views.xml',
|
||||
'wizard/sale_make_invoice_advance_views.xml',
|
||||
'wizard/project_task_create_sale_order_views.xml'
|
||||
],
|
||||
'demo': [
|
||||
'data/sale_service_demo.xml',
|
||||
|
||||
@@ -477,10 +477,10 @@
|
||||
<field name="sale_line_id" ref="sale_timesheet.sale_line_13"/>
|
||||
<field name="sale_order_id" ref="sale_timesheet.sale_order_1"/>
|
||||
<field name="project_id" search="[('sale_order_id', '=', ref('sale_timesheet.sale_order_1'))]" />
|
||||
<field name="stage_id" ref="project_from_sale_order_1_in_progress"/>
|
||||
<field name="stage_id" ref="project_from_sale_order_1_in_progress"/>
|
||||
<field name="sale_line_id" ref="sale_timesheet.sale_line_13" />
|
||||
<field name="sale_order_id" ref="sale_timesheet.sale_order_1" />
|
||||
<field name="partner_id" ref="base.res_partner_2" />
|
||||
<field name="partner_id" ref="base.res_partner_2" />
|
||||
</record>
|
||||
|
||||
<record id="project_task_4" model="project.task">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools.float_utils import float_round, float_is_zero
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
@@ -43,30 +43,29 @@ class AccountMove(models.Model):
|
||||
}
|
||||
}
|
||||
|
||||
def _link_timesheets_to_invoice(self, date=None):
|
||||
""" Search timesheets and link this timesheets to the invoice
|
||||
|
||||
When we create an invoice from a sale order, we need to
|
||||
link the timesheets in this sale order to the invoice.
|
||||
Then, we can know which timesheets are invoiced in the sale order.
|
||||
|
||||
:param date: All timesheets on task before or equals this date
|
||||
in the sale order are invoiced.
|
||||
"""
|
||||
for line in self.filtered(lambda i: i.type == 'out_invoice' and i.state == 'draft').invoice_line_ids:
|
||||
sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet')
|
||||
if sale_line_delivery:
|
||||
domain = line._timesheet_domain_get_invoiced_lines(sale_line_delivery)
|
||||
if date:
|
||||
domain = expression.AND([domain, [('date', '<=', date)]])
|
||||
timesheets = self.env['account.analytic.line'].sudo().search(domain)
|
||||
timesheets.write({'timesheet_invoice_id': line.move_id.id})
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# OVERRIDE
|
||||
# Link the timesheet from the SO lines to the corresponding draft invoice.
|
||||
# NOTE: Only the timesheets linked to an Sale Line with a product invoiced on delivered quantity
|
||||
# are concerned, since in ordered quantity, the timesheet quantity is not invoiced, but is simply
|
||||
# to compute the delivered one (for reporting).
|
||||
lines = super(AccountMoveLine, self).create(vals_list)
|
||||
lines_to_process = lines.filtered(lambda line: line.move_id.type == 'out_invoice'
|
||||
and line.move_id.state == 'draft')
|
||||
for line in lines_to_process:
|
||||
sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet')
|
||||
if sale_line_delivery:
|
||||
domain = self._timesheet_domain_get_invoiced_lines(sale_line_delivery)
|
||||
timesheets = self.env['account.analytic.line'].search(domain).sudo()
|
||||
timesheets.write({
|
||||
'timesheet_invoice_id': line.move_id.id,
|
||||
})
|
||||
return lines
|
||||
|
||||
@api.model
|
||||
def _timesheet_domain_get_invoiced_lines(self, sale_line_delivery):
|
||||
""" Get the domain for the timesheet to link to the created invoice
|
||||
@@ -74,9 +73,7 @@ class AccountMoveLine(models.Model):
|
||||
:return a normalized domain
|
||||
"""
|
||||
return [
|
||||
'&',
|
||||
('so_line', 'in', sale_line_delivery.ids),
|
||||
'&',
|
||||
('timesheet_invoice_id', '=', False),
|
||||
('project_id', '!=', False)
|
||||
]
|
||||
|
||||
@@ -113,3 +113,8 @@ class ProductProduct(models.Model):
|
||||
self.project_template_id = False
|
||||
elif self.service_tracking in ['task_in_project', 'project_only']:
|
||||
self.project_id = False
|
||||
|
||||
def _is_delivered_timesheet(self):
|
||||
""" Check if the product is a delivered timesheet """
|
||||
self.ensure_one()
|
||||
return self.type == 'service' and self.service_policy == 'delivered_timesheet'
|
||||
|
||||
@@ -25,6 +25,7 @@ class Project(models.Model):
|
||||
sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False,
|
||||
help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked."
|
||||
"By extension, it defines the rate at which an employee's time on the project is billed.")
|
||||
allow_billable = fields.Boolean("Bill from Tasks")
|
||||
|
||||
_sql_constraints = [
|
||||
('sale_order_required_if_sale_line', "CHECK((sale_line_id IS NOT NULL AND sale_order_id IS NOT NULL) OR (sale_line_id IS NULL))", 'The Project should be linked to a Sale Order to select an Sale Order Items.'),
|
||||
@@ -50,6 +51,15 @@ class Project(models.Model):
|
||||
if self.billable_type == 'no':
|
||||
self.sale_line_employee_ids = False
|
||||
|
||||
@api.onchange('allow_billable')
|
||||
def _onchange_allow_billable(self):
|
||||
""" In order to keep task billable type as 'task_rate' using sale_timesheet usual flow.
|
||||
(see _compute_billable_type method in sale_timesheet)
|
||||
"""
|
||||
if self.allow_billable:
|
||||
self.sale_order_id = False
|
||||
self.sale_line_employee_ids = False
|
||||
|
||||
@api.constrains('sale_line_id', 'billable_type')
|
||||
def _check_sale_line_type(self):
|
||||
for project in self:
|
||||
@@ -134,6 +144,7 @@ class ProjectTask(models.Model):
|
||||
('no', 'No Billable')
|
||||
], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True)
|
||||
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
|
||||
allow_billable = fields.Boolean(related="project_id.allow_billable")
|
||||
|
||||
@api.depends('sale_line_id', 'project_id', 'billable_type')
|
||||
def _compute_sale_order_id(self):
|
||||
@@ -228,3 +239,17 @@ class ProjectTask(models.Model):
|
||||
if partner:
|
||||
return partner
|
||||
return super(ProjectTask, self).rating_get_partner_id()
|
||||
|
||||
def action_make_billable(self):
|
||||
return {
|
||||
"name": _("Create Sales Order"),
|
||||
"type": 'ir.actions.act_window',
|
||||
"res_model": 'project.task.create.sale.order',
|
||||
"views": [[False, "form"]],
|
||||
"target": 'new',
|
||||
"context": {
|
||||
'active_id': self.id,
|
||||
'active_model': 'project.task',
|
||||
'form_view_initial_mode': 'edit',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@ class Project(models.Model):
|
||||
aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id'])
|
||||
employee_ids.extend(list(map(lambda x: x['employee_id'][0], aal_employee_ids)))
|
||||
|
||||
employees = self.env['hr.employee'].sudo().browse(employee_ids)
|
||||
# Retrieve the employees for which the current user can see theirs timesheets
|
||||
employees = self.env['hr.employee'].sudo().browse(employee_ids).filtered_domain(self.env['account.analytic.line']._domain_employee_id())
|
||||
repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
|
||||
# repartition data, without timesheet on cancelled so
|
||||
repartition_data = self.env['account.analytic.line'].read_group(repartition_domain + ['|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')], ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
|
||||
@@ -146,12 +147,12 @@ class Project(models.Model):
|
||||
#
|
||||
# Table grouped by SO / SOL / Employees
|
||||
#
|
||||
timesheet_forecast_table_rows = self._table_get_line_values()
|
||||
timesheet_forecast_table_rows = self._table_get_line_values(employees)
|
||||
if timesheet_forecast_table_rows:
|
||||
values['timesheet_forecast_table'] = timesheet_forecast_table_rows
|
||||
return values
|
||||
|
||||
def _table_get_line_values(self):
|
||||
def _table_get_line_values(self, employees=None):
|
||||
""" return the header and the rows informations of the table """
|
||||
if not self:
|
||||
return False
|
||||
@@ -193,9 +194,11 @@ class Project(models.Model):
|
||||
if not is_milestone:
|
||||
rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
|
||||
|
||||
rows_sale_line_all_data = {}
|
||||
if not employees:
|
||||
employees = self.env['hr.employee'].sudo().search(self.env['account.analytic.line']._domain_employee_id())
|
||||
for row_key, row_employee in rows_employee.items():
|
||||
sale_line_id = row_key[1]
|
||||
sale_order_id = row_key[0]
|
||||
sale_order_id, sale_line_id, employee_id = row_key
|
||||
# sale line row
|
||||
sale_line_row_key = (sale_order_id, sale_line_id)
|
||||
if sale_line_row_key not in rows_sale_line:
|
||||
@@ -205,35 +208,26 @@ class Project(models.Model):
|
||||
if not is_milestone:
|
||||
rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
|
||||
|
||||
for index in range(len(rows_employee[row_key])):
|
||||
if index != 0:
|
||||
rows_sale_line[sale_line_row_key][index] += rows_employee[row_key][index]
|
||||
if not rows_sale_line[sale_line_row_key][0].get('is_milestone'):
|
||||
rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line[sale_line_row_key][5]
|
||||
else:
|
||||
rows_sale_line[sale_line_row_key][-1] = 0
|
||||
if sale_line_row_key not in rows_sale_line_all_data:
|
||||
rows_sale_line_all_data[sale_line_row_key] = [0] * len(row_employee)
|
||||
for index in range(1, len(row_employee)):
|
||||
if employee_id in employees.ids:
|
||||
rows_sale_line[sale_line_row_key][index] += row_employee[index]
|
||||
rows_sale_line_all_data[sale_line_row_key][index] += row_employee[index]
|
||||
if not rows_sale_line[sale_line_row_key][0].get('is_milestone'):
|
||||
rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line_all_data[sale_line_row_key][5]
|
||||
else:
|
||||
rows_sale_line[sale_line_row_key][-1] = 0
|
||||
|
||||
rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
|
||||
rows_sale_order_done_sold = {key : dict(sold=0.0, done=0.0) for key in set(map_sol_so.values()) | set([None])} # SO id -> {'sold':0.0, 'done': 0.0}
|
||||
for row_key, row_sale_line in rows_sale_line.items():
|
||||
sale_order_id = row_key[0]
|
||||
# sale order row
|
||||
if sale_order_id not in rows_sale_order:
|
||||
rows_sale_order[sale_order_id] = [{'label': map_so_names.get(sale_order_id, _('No Sales Order')), 'canceled': map_so_cancel.get(sale_order_id, False), 'res_id': sale_order_id, 'res_model': 'sale.order', 'type': 'sale_order'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
|
||||
|
||||
for index in range(len(rows_sale_line[row_key])):
|
||||
if index != 0:
|
||||
rows_sale_order[sale_order_id][index] += rows_sale_line[row_key][index]
|
||||
|
||||
# do not sum the milestone SO line for sold and done (for remaining computation)
|
||||
if not rows_sale_line[row_key][0].get('is_milestone'):
|
||||
rows_sale_order_done_sold[sale_order_id]['sold'] += rows_sale_line[row_key][-2]
|
||||
rows_sale_order_done_sold[sale_order_id]['done'] += rows_sale_line[row_key][5]
|
||||
|
||||
# remaining computation of SO row, as Sold - Done (timesheet total)
|
||||
for sale_order_id, done_sold_vals in rows_sale_order_done_sold.items():
|
||||
if sale_order_id in rows_sale_order:
|
||||
rows_sale_order[sale_order_id][-1] = done_sold_vals['sold'] - done_sold_vals['done']
|
||||
for index in range(1, len(row_sale_line)):
|
||||
rows_sale_order[sale_order_id][index] += row_sale_line[index]
|
||||
|
||||
# group rows SO, SOL and their related employee rows.
|
||||
timesheet_forecast_table_rows = []
|
||||
@@ -243,7 +237,7 @@ class Project(models.Model):
|
||||
if sale_order_id == sale_line_row_key[0]:
|
||||
timesheet_forecast_table_rows.append(sale_line_row)
|
||||
for employee_row_key, employee_row in rows_employee.items():
|
||||
if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1]:
|
||||
if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1] and employee_row_key[2] in employees.ids:
|
||||
timesheet_forecast_table_rows.append(employee_row)
|
||||
|
||||
# complete table data
|
||||
|
||||
@@ -66,7 +66,8 @@ class SaleOrder(models.Model):
|
||||
def _compute_timesheet_total_duration(self):
|
||||
for sale_order in self:
|
||||
duration_list = []
|
||||
for timesheet in sale_order.timesheet_ids:
|
||||
timesheets = sale_order.timesheet_ids if self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') else sale_order.timesheet_ids.filtered(lambda t: t.user_id.id == self.env.uid)
|
||||
for timesheet in timesheets:
|
||||
timesheet_uom = timesheet.product_uom_id or timesheet.company_id.project_time_mode_id
|
||||
if timesheet_uom != sale_order.timesheet_encode_uom_id and timesheet_uom.category_id == sale_order.timesheet_encode_uom_id.category_id:
|
||||
duration_list.append(timesheet_uom._compute_quantity(timesheet.unit_amount, sale_order.timesheet_encode_uom_id))
|
||||
@@ -74,6 +75,7 @@ class SaleOrder(models.Model):
|
||||
duration_list.append(timesheet.unit_amount)
|
||||
sale_order.timesheet_total_duration = sum(duration_list)
|
||||
|
||||
|
||||
@api.onchange('project_id')
|
||||
def _onchange_project_id(self):
|
||||
""" Set the SO analytic account to the selected project's analytic account """
|
||||
@@ -148,6 +150,15 @@ class SaleOrder(models.Model):
|
||||
action = {'type': 'ir.actions.act_window_close'}
|
||||
return action
|
||||
|
||||
def _create_invoices(self, grouped=False, final=False, date=None):
|
||||
""" Override the _create_invoice method in sale.order model in sale module
|
||||
Add new parameter in this method, to invoice sale.order with a date. This date is used in sale_make_invoice_advance_inv into this module.
|
||||
:param date
|
||||
:return {account.move}: the invoices created
|
||||
"""
|
||||
moves = super(SaleOrder, self)._create_invoices(grouped, final)
|
||||
moves._link_timesheets_to_invoice(date)
|
||||
return moves
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
@@ -389,3 +400,23 @@ class SaleOrderLine(models.Model):
|
||||
project = map_so_project[so_line.order_id.id]
|
||||
if not so_line.task_id:
|
||||
so_line._timesheet_create_task(project=project)
|
||||
|
||||
def _recompute_qty_to_invoice(self, date):
|
||||
""" Recompute the qty_to_invoice field for product containing timesheets
|
||||
|
||||
Search the existed timesheets up the given date in parameter.
|
||||
Retrieve the unit_amount of this timesheet and then recompute
|
||||
the qty_to_invoice for each current product.
|
||||
|
||||
:param date: date to search timesheets before this date.
|
||||
"""
|
||||
lines_by_timesheet = self.filtered(lambda sol: sol.product_id._is_delivered_timesheet())
|
||||
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
|
||||
domain = expression.AND([domain, [
|
||||
('date', '<=', date),
|
||||
('timesheet_invoice_id', '=', False)]])
|
||||
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
|
||||
|
||||
for line in lines_by_timesheet:
|
||||
if line.product_id._is_delivered_timesheet():
|
||||
line.qty_to_invoice = mapping.get(line.id, 0.0)
|
||||
|
||||
@@ -14,5 +14,14 @@
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
<!-- Override this rule because in hr_timesheet,
|
||||
the lowest access right can only see own timesheets (model: account.analytic.line)
|
||||
and this ir.rule accept all account.analytic.line in its domain.
|
||||
Therefore, we need to override this rule to change the domain, and then the
|
||||
rules for the account.analytic.line defined in timesheet will be apply.
|
||||
-->
|
||||
<record id="account.account_analytic_line_rule_billing_user" model="ir.rule">
|
||||
<field name="domain_force">[('project_id', '=', False)]</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo.tools import float_is_zero
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
@@ -21,6 +23,8 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
|
||||
# set up
|
||||
cls.setUpEmployees()
|
||||
cls.setUpServiceProducts()
|
||||
cls.setUpAdditionalAccounts()
|
||||
cls.setUpAccountJournal()
|
||||
|
||||
def test_timesheet_order(self):
|
||||
""" Test timesheet invoicing with 'invoice on order' timetracked products
|
||||
@@ -379,3 +383,138 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
|
||||
|
||||
self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, even after invoice validation")
|
||||
self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, even after invoice validation")
|
||||
|
||||
def test_timesheet_invoice(self):
|
||||
""" Test to create invoices for the sale order with timesheets
|
||||
|
||||
1) create sale order
|
||||
2) try to create an invoice for the timesheets 10 days before
|
||||
3) create invoice for the timesheets 6 days before
|
||||
4) create invoice for the timesheets 4 days before
|
||||
5) create invoice for the timesheets from today
|
||||
"""
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_customer_usd.id,
|
||||
'partner_invoice_id': self.partner_customer_usd.id,
|
||||
'partner_shipping_id': self.partner_customer_usd.id,
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
})
|
||||
so_line_deliver_global_project = self.env['sale.order.line'].create({
|
||||
'name': self.product_delivery_timesheet2.name,
|
||||
'product_id': self.product_delivery_timesheet2.id,
|
||||
'product_uom_qty': 50,
|
||||
'product_uom': self.product_delivery_timesheet2.uom_id.id,
|
||||
'price_unit': self.product_delivery_timesheet2.list_price,
|
||||
'order_id': sale_order.id,
|
||||
})
|
||||
so_line_deliver_task_project = self.env['sale.order.line'].create({
|
||||
'name': self.product_delivery_timesheet3.name,
|
||||
'product_id': self.product_delivery_timesheet3.id,
|
||||
'product_uom_qty': 20,
|
||||
'product_uom': self.product_delivery_timesheet3.uom_id.id,
|
||||
'price_unit': self.product_delivery_timesheet3.list_price,
|
||||
'order_id': sale_order.id,
|
||||
})
|
||||
so_line_deliver_global_project.product_id_change()
|
||||
so_line_deliver_task_project.product_id_change()
|
||||
|
||||
# confirm SO
|
||||
sale_order.action_confirm()
|
||||
task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
|
||||
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
|
||||
project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
|
||||
|
||||
timesheet1 = self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line',
|
||||
'project_id': task_serv1.project_id.id,
|
||||
'task_id': task_serv1.id,
|
||||
'unit_amount': 10,
|
||||
'employee_id': self.employee_manager.id,
|
||||
'date': date.today() - timedelta(days=6)
|
||||
})
|
||||
|
||||
timesheet2 = self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line 2',
|
||||
'project_id': task_serv1.project_id.id,
|
||||
'task_id': task_serv1.id,
|
||||
'unit_amount': 20,
|
||||
'employee_id': self.employee_manager.id,
|
||||
'date': date.today() - timedelta(days=1)
|
||||
})
|
||||
|
||||
timesheet3 = self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line 3',
|
||||
'project_id': task_serv1.project_id.id,
|
||||
'task_id': task_serv1.id,
|
||||
'unit_amount': 10,
|
||||
'employee_id': self.employee_manager.id,
|
||||
'date': date.today() - timedelta(days=5)
|
||||
})
|
||||
|
||||
timesheet4 = self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line 4',
|
||||
'project_id': task_serv2.project_id.id,
|
||||
'task_id': task_serv2.id,
|
||||
'unit_amount': 30,
|
||||
'employee_id': self.employee_manager.id
|
||||
})
|
||||
self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice')
|
||||
self.assertEqual(so_line_deliver_task_project.invoice_status, 'to invoice')
|
||||
self.assertEqual(sale_order.invoice_status, 'to invoice')
|
||||
|
||||
# Context for sale.advance.payment.inv wizard
|
||||
self.context = {
|
||||
'active_model': 'sale.order',
|
||||
'active_ids': [sale_order.id],
|
||||
'active_id': sale_order.id,
|
||||
'default_journal_id': self.journal_sale.id
|
||||
}
|
||||
|
||||
# invoice SO
|
||||
wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({
|
||||
'advance_payment_method': 'delivered',
|
||||
'date_invoice_timesheet': date.today() - timedelta(days=10)
|
||||
})
|
||||
|
||||
self.assertTrue(wizard.invoicing_timesheet_enabled, 'The "date_invoice_timesheet" field should be visible in the wizard because a product in sale order has service_policy to "Timesheet on Task"')
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
wizard.create_invoices()
|
||||
|
||||
self.assertFalse(sale_order.invoice_ids, 'Normally, no invoice will be created created because the timesheet logged is after the date defined in date_invoice_timesheet field')
|
||||
|
||||
wizard.write({
|
||||
'date_invoice_timesheet': date.today() - timedelta(days=6)
|
||||
})
|
||||
wizard.create_invoices()
|
||||
|
||||
self.assertTrue(sale_order.invoice_ids, 'One invoice should be created because the timesheet logged is before or equal to the date defined in wizard')
|
||||
|
||||
invoice = sale_order.invoice_ids[0]
|
||||
self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount)
|
||||
|
||||
# validate invoice
|
||||
invoice.post()
|
||||
|
||||
wizard.write({
|
||||
'date_invoice_timesheet': date.today() - timedelta(days=4)
|
||||
})
|
||||
wizard.create_invoices()
|
||||
|
||||
self.assertEqual(len(sale_order.invoice_ids), 2)
|
||||
invoice2 = sale_order.invoice_ids[-1]
|
||||
|
||||
self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet3.unit_amount, "The last invoice done should have the quantity of the timesheet 3, because the date this timesheet is the only one before the 'date_invoice_timesheet' field in the wizard.")
|
||||
|
||||
wizard.write({
|
||||
'date_invoice_timesheet': date.today()
|
||||
})
|
||||
|
||||
wizard.create_invoices()
|
||||
|
||||
self.assertEqual(len(sale_order.invoice_ids), 3)
|
||||
invoice3 = sale_order.invoice_ids[-1]
|
||||
|
||||
# Check if all timesheets have been invoiced
|
||||
self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet2.unit_amount + timesheet3.unit_amount)
|
||||
self.assertEqual(so_line_deliver_task_project.qty_invoiced, + timesheet4.unit_amount)
|
||||
|
||||
@@ -28,6 +28,29 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">project.view.form.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.project_invoice_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='timesheet_settings']/.." position="after">
|
||||
<div class="row mt16 o_settings_container">
|
||||
<div class="col-lg-6 o_setting_box" id="allow_billable_container" attrs="{'invisible': [('billable_type', '=', 'employee_rate')]}">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="allow_billable"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="allow_billable"/>
|
||||
<div class="text-muted" id="allow_billable_setting">
|
||||
Invoice your time and material from tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_project_view_tree_inherit_sale_timesheet" model="ir.ui.view">
|
||||
<field name="name">project.project.tree.inherit.sale.timesheet</field>
|
||||
<field name="model">project.project</field>
|
||||
@@ -61,6 +84,10 @@
|
||||
<field name="groups_id" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="inherit_id" ref="project.view_task_form2"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position='inside'>
|
||||
<field name="allow_billable" invisible="1"/>
|
||||
<button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': ['|', '|', '|', '|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), ('billable_type', '=', 'employee_rate'), ('partner_id', '=', False), ('sale_order_id', '!=', False), '&', ('display_timesheet_timer', '=', True), ('timer_start', '!=', False)]}" groups="sales_team.group_sale_salesman"/>
|
||||
</xpath>
|
||||
<div name="button_box" position="inside">
|
||||
<button class="d-none d-md-inline oe_stat_button"
|
||||
type="object" name="action_view_so" icon="fa-dollar"
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
|
||||
from . import project_create_sale_order
|
||||
from . import project_create_invoice
|
||||
from . import sale_make_invoice_advance
|
||||
from . import project_task_create_sale_order
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ProjectTaskCreateSalesOrder(models.TransientModel):
|
||||
_name = 'project.task.create.sale.order'
|
||||
_description = "Create SO from task"
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
result = super(ProjectTaskCreateSalesOrder, self).default_get(fields)
|
||||
|
||||
active_model = self._context.get('active_model')
|
||||
if active_model != 'project.task':
|
||||
raise UserError(_("You can only apply this action from a task."))
|
||||
|
||||
active_id = self._context.get('active_id')
|
||||
if 'task_id' in fields and active_id:
|
||||
task = self.env['project.task'].browse(active_id)
|
||||
if task.billable_type != 'no':
|
||||
raise UserError(_("The task is already billable."))
|
||||
result['task_id'] = active_id
|
||||
result['partner_id'] = task.partner_id.id
|
||||
return result
|
||||
|
||||
task_id = fields.Many2one('project.task', "Task", domain=[('sale_line_id', '=', False)], help="Task for which we are creating a sales order", required=True)
|
||||
partner_id = fields.Many2one('res.partner', string="Customer", required=True, help="Customer of the sales order")
|
||||
product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')], string="Service", help="Product of the sales order item. Must be a service invoiced based on timesheets on tasks. The existing timesheet will be linked to this product.", required=True)
|
||||
price_unit = fields.Float("Unit Price", help="Unit price of the sales order item.")
|
||||
currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id', readonly=False)
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.price_unit = self.product_id.lst_price
|
||||
else:
|
||||
self.price_unit = 0.0
|
||||
|
||||
def action_create_sale_order(self):
|
||||
sale_order = self._prepare_sale_order()
|
||||
sale_order.action_confirm()
|
||||
view_form_id = self.env.ref('sale.view_order_form').id
|
||||
action = self.env.ref('sale.action_orders').read()[0]
|
||||
action.update({
|
||||
'views': [(view_form_id, 'form')],
|
||||
'view_mode': 'form',
|
||||
'name': sale_order.name,
|
||||
'res_id': sale_order.id,
|
||||
})
|
||||
return action
|
||||
|
||||
def _prepare_sale_order(self):
|
||||
# if task linked to SO line, then we consider it as billable.
|
||||
if self.task_id.sale_line_id:
|
||||
raise UserError(_("The task is already linked to a sales order item."))
|
||||
|
||||
timesheet_with_so_line = self.env['account.analytic.line'].search_count([('task_id', '=', self.task_id.id), ('so_line', '!=', False), ('project_id', '!=', False)])
|
||||
if timesheet_with_so_line:
|
||||
raise UserError(_('The sales order cannot be created because some timesheets of this task are already linked to another sales order.'))
|
||||
|
||||
# create SO
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_id.id,
|
||||
'analytic_account_id': self.task_id.project_id.analytic_account_id.id,
|
||||
})
|
||||
sale_order.onchange_partner_id()
|
||||
sale_order.onchange_partner_shipping_id()
|
||||
|
||||
sale_order_line = self.env['sale.order.line'].create({
|
||||
'order_id': sale_order.id,
|
||||
'product_id': self.product_id.id,
|
||||
'price_unit': self.price_unit,
|
||||
'project_id': self.task_id.project_id.id, # prevent to re-create a project on confirmation
|
||||
'task_id': self.task_id.id,
|
||||
'product_uom_qty': self.task_id.total_hours_spent,
|
||||
})
|
||||
|
||||
# link task to SOL
|
||||
self.task_id.write({
|
||||
'sale_line_id': sale_order_line.id,
|
||||
'partner_id': sale_order.partner_id.id,
|
||||
'email_from': sale_order.partner_id.email,
|
||||
})
|
||||
|
||||
# assign SOL to timesheets
|
||||
self.env['account.analytic.line'].search([('task_id', '=', self.task_id.id), ('so_line', '=', False)]).write({
|
||||
'so_line': sale_order_line.id
|
||||
})
|
||||
|
||||
return sale_order
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_task_create_sale_order_view_form" model="ir.ui.view">
|
||||
<field name="name">project.task.create.sale.order.wizard.form</field>
|
||||
<field name="model">project.task.create.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create a Sales Order">
|
||||
<group>
|
||||
<group>
|
||||
<field name="task_id" readonly="1"/>
|
||||
<field name="partner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="product_id" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet'}"/>
|
||||
<field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'required': True}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight"/>
|
||||
<button string="Cancel" special="cancel" type="object" class="btn btn-secondary oe_inline"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_task_action_multi_create_sale_order" model="ir.actions.act_window">
|
||||
<field name="name">Create a Sales Order</field>
|
||||
<field name="res_model">project.task.create.sale.order</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="project_task_create_sale_order_view_form"/>
|
||||
<field name="target">new</field>
|
||||
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,45 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SaleAdvancePaymentInv(models.TransientModel):
|
||||
_inherit = "sale.advance.payment.inv"
|
||||
|
||||
@api.model
|
||||
def _default_invoicing_timesheet_enabled(self):
|
||||
if 'active_id' not in self._context and 'active_ids' not in self._context:
|
||||
return False
|
||||
sale_orders = self.env['sale.order'].browse(self._context.get('active_id') or self._context.get('active_ids'))
|
||||
order_lines = sale_orders.mapped('order_line').filtered(lambda sol: sol.invoice_status == 'to invoice')
|
||||
product_ids = order_lines.mapped('product_id').filtered(lambda p: p._is_delivered_timesheet())
|
||||
return bool(product_ids)
|
||||
|
||||
date_invoice_timesheet = fields.Date(
|
||||
string='Invoice Timesheets Up To This Date',
|
||||
default=fields.Date.today,
|
||||
required=False,
|
||||
help="Only timesheets not yet invoiced (and validated, if applicable) up to this date included will be invoiced. If no date is indicated, all timesheets not yet invoiced (and validated, if applicable) will be invoiced without distinction.")
|
||||
invoicing_timesheet_enabled = fields.Boolean(default=_default_invoicing_timesheet_enabled)
|
||||
|
||||
def create_invoices(self):
|
||||
""" Override method from sale/wizard/sale_make_invoice_advance.py
|
||||
|
||||
When the user want to invoice the timesheets to the SO
|
||||
up to a specific date then we need to recompute the
|
||||
qty_to_invoice for each product_id in sale.order.line,
|
||||
before creating the invoice.
|
||||
"""
|
||||
sale_orders = self.env['sale.order'].browse(
|
||||
self._context.get('active_ids', [])
|
||||
)
|
||||
|
||||
if self.advance_payment_method == 'delivered' and self.invoicing_timesheet_enabled:
|
||||
if self.date_invoice_timesheet:
|
||||
sale_orders.mapped('order_line')._recompute_qty_to_invoice(self.date_invoice_timesheet)
|
||||
|
||||
sale_orders._create_invoices(final=self.deduct_down_payments, date=self.date_invoice_timesheet)
|
||||
|
||||
if self._context.get('open_invoices', False):
|
||||
return sale_orders.action_view_invoice()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
return super(SaleAdvancePaymentInv, self).create_invoices()
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="sale_advance_payment_inv_timesheet_view_form" model="ir.ui.view">
|
||||
<field name="name">sale_timesheet.sale.advance.payment.inv.view.form</field>
|
||||
<field name="model">sale.advance.payment.inv</field>
|
||||
<field name="inherit_id" ref="sale.view_sale_advance_payment_inv"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='deposit_taxes_id']" position="after">
|
||||
<field name="date_invoice_timesheet" class="oe_inline" string="Invoice Timesheets up to" data-toggle="tooltip" data-placement="bottom"
|
||||
title="Only timesheets not yet invoiced (and validated, if applicable) up to this date included will be invoiced. If no date is indicated, all timesheets not yet invoiced (and validated, if applicable) will be invoiced without distinction."
|
||||
attrs="{
|
||||
'invisible': [
|
||||
('invoicing_timesheet_enabled', '=', False),
|
||||
]
|
||||
}"
|
||||
/>
|
||||
<field name="invoicing_timesheet_enabled" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user