[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:
Odoo's Mergebot
2019-12-19 17:58:35 +01:00
committed by GitHub
co-authored by Lucas Lefèvre Yannick Tivisse
43 changed files with 1407 additions and 158 deletions
@@ -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>
+1
View File
@@ -4,3 +4,4 @@
from . import controllers
from . import models
from . import report
from . import wizard
+1
View File
@@ -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
View File
@@ -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
+74 -13
View File
@@ -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()
+71 -9
View File
@@ -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')
+65
View File
@@ -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>
+115
View File
@@ -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;
}
}
+19 -3
View File
@@ -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,
+7 -1
View File
@@ -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>
+79 -8
View File
@@ -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 &amp;&amp; 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 &amp;&amp; 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>
+4
View File
@@ -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>
+1 -1
View File
@@ -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
+2
View File
@@ -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">
+20 -23
View File
@@ -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)
]
+5
View File
@@ -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
View File
@@ -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
+32 -1
View File
@@ -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), '&amp;', ('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"
+2
View File
@@ -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>