[IMP] project_timesheet_holidays : generate timesheet entries for global time off

Generate timesheet entries for each employee when a  global time off is created
if module_project_timesheet_holidays is true.

This only impacts the employees with the corresponding resource calendar
The entries are linked to the project and task set in the settings.

The number of hours timesheeted corresponds to the timeframe during which both the global time off
and the working hours of the employee overlap (same behavior as for regular time off).

If the global time off is modified or deleted, the timesheet entry is adapted accordingly.

Task-2352428
Closes #67021

Co-authored-by: Laurent Stukkens (LTU) <ltu@odoo.com>
Co-authored-by: jdoutreloux <jud@odoo.com>
This commit is contained in:
jdoutreloux
2021-08-31 19:54:18 +00:00
committed by Xavier BOL (xbo)
co-authored by Laurent Stukkens
parent 34ea4030b1
commit 96ff372034
7 changed files with 317 additions and 1 deletions
@@ -5,3 +5,5 @@ from . import res_company # has to be before hr_holidays to create needed column
from . import account_analytic
from . import hr_holidays
from . import res_config_settings
from . import resource_calendar_leaves
from . import hr_employee
@@ -9,6 +9,7 @@ class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
holiday_id = fields.Many2one("hr.leave", string='Leave Request')
global_leave_id = fields.Many2one("resource.calendar.leaves", string="Global Time Off", ondelete='cascade')
@api.ondelete(at_uninstall=False)
def _unlink_except_linked_leave(self):
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class Employee(models.Model):
_inherit = 'hr.employee'
@api.model
def create(self, vals):
result = super(Employee, self).create(vals)
# We need to create timesheet entries for the global time off that are already created
# and are planned for after this employee creation date
# First we look for the global time off that are already planned after today
today = fields.Datetime.today()
global_leaves = result.resource_calendar_id.global_leave_ids.filtered(lambda l: l.date_from >= today)
work_hours_data = global_leaves._work_time_per_day()
vals_list = []
for global_time_off in global_leaves:
for index, (day_date, work_hours_count) in enumerate(work_hours_data[global_time_off.id]):
vals_list.append(
global_time_off._timesheet_prepare_line_values(
index,
result,
work_hours_data[global_time_off.id],
day_date,
work_hours_count
)
)
self.env['account.analytic.line'].sudo().create(vals_list)
return result
@@ -87,7 +87,7 @@ class Holidays(models.Model):
def _timesheet_prepare_line_values(self, index, work_hours_data, day_date, work_hours_count):
self.ensure_one()
return {
'name': "%s (%s/%s)" % (self.holiday_status_id.name or '', index + 1, len(work_hours_data)),
'name': _("Time Off (%s/%s)", index + 1, len(work_hours_data)),
'project_id': self.holiday_status_id.timesheet_project_id.id,
'task_id': self.holiday_status_id.timesheet_task_id.id,
'account_id': self.holiday_status_id.timesheet_project_id.analytic_account_id.id,
@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from pytz import timezone, utc
from odoo import api, fields, models, _
class ResourceCalendarLeaves(models.Model):
_inherit = "resource.calendar.leaves"
timesheet_ids = fields.One2many('account.analytic.line', 'global_leave_id', string="Analytic Lines")
def _work_time_per_day(self):
""" Get work time per day based on the calendar and its attendances
1) Gets all calendars with their characteristics (i.e.
(a) the leaves in it,
(b) the resources which have a leave,
(c) the oldest and
(d) the latest leave dates
) for leaves in self.
2) Search the attendances based on the characteristics retrieved for each calendar.
The attendances found are the ones between the date_from of the oldest leave
and the date_to of the most recent leave.
3) Create a dict as result of this method containing:
{
leave: {
max(date_start of work hours, date_start of the leave):
the duration in days of the work including the leave
}
}
"""
leaves_read_group = self.env['resource.calendar.leaves'].read_group(
[('id', 'in', self.ids)],
['calendar_id', 'ids:array_agg(id)', 'resource_ids:array_agg(resource_id)', 'min_date_from:min(date_from)', 'max_date_to:max(date_to)'],
['calendar_id'],
)
# dict of keys: calendar_id
# and values : { 'date_from': datetime, 'date_to': datetime, resources: self.env['resource.resource'] }
cal_attendance_intervals_dict = {
res['calendar_id'][0]: {
'date_from': utc.localize(res['min_date_from']),
'date_to': utc.localize(res['max_date_to']),
'resources': self.env['resource.resource'].browse(res['resource_ids'] if res['resource_ids'] and res['resource_ids'][0] else []),
'leaves': self.env['resource.calendar.leaves'].browse(res['ids']),
} for res in leaves_read_group
}
# to easily find the calendar with its id.
calendars_dict = {calendar.id: calendar for calendar in self.calendar_id}
# dict of keys: leave.id
# and values: a dict of keys: date
# and values: number of days
results = defaultdict(lambda: defaultdict(float))
for calendar_id, cal_attendance_intervals_params_entry in cal_attendance_intervals_dict.items():
calendar = calendars_dict[calendar_id]
work_hours_intervals = calendar._attendance_intervals_batch(
cal_attendance_intervals_params_entry['date_from'],
cal_attendance_intervals_params_entry['date_to'],
cal_attendance_intervals_params_entry['resources'],
tz=timezone(calendar.tz)
)
for leave in cal_attendance_intervals_params_entry['leaves']:
work_hours_data = work_hours_intervals[leave.resource_id.id]
for date_from, date_to, dummy in work_hours_data:
if date_to > utc.localize(leave.date_from) and date_from < utc.localize(leave.date_to):
tmp_start = max(date_from, utc.localize(leave.date_from))
tmp_end = min(date_to, utc.localize(leave.date_to))
results[leave.id][tmp_start.date()] += (tmp_end - tmp_start).total_seconds() / 3600
results[leave.id] = sorted(results[leave.id].items())
return results
def _timesheet_create_lines(self):
""" Create timesheet leaves for each employee using the same calendar containing in self.calendar_id
If the employee has already a time off in the same day then no timesheet should be created.
"""
work_hours_data = self._work_time_per_day()
employees_groups = self.env['hr.employee'].read_group(
[('resource_calendar_id', 'in', self.calendar_id.ids)],
['resource_calendar_id', 'ids:array_agg(id)'],
['resource_calendar_id'])
mapped_employee = {
employee['resource_calendar_id'][0]: self.env['hr.employee'].browse(employee['ids'])
for employee in employees_groups
}
employee_ids_set = set()
employee_ids_set.update(*[line['ids'] for line in employees_groups])
min_date = max_date = None
for values in work_hours_data.values():
for d, dummy in values:
if not min_date and not max_date:
min_date = max_date = d
elif d < min_date:
min_date = d
elif d > max_date:
max_date = d
holidays_read_group = self.env['hr.leave'].read_group([
('employee_id', 'in', list(employee_ids_set)),
('date_from', '<=', min_date),
('date_to', '>=', max_date),
('state', 'not in', ('cancel', 'refuse')),
], ['date_from_list:array_agg(date_from)', 'date_to_list:array_agg(date_to)', 'employee_id'], ['employee_id'])
holidays_by_employee = {
line['employee_id'][0]: [
(date_from.date(), date_to.date()) for date_from, date_to in zip(line['date_from_list'], line['date_to_list'])
] for line in holidays_read_group
}
vals_list = []
for leave in self:
for employee in mapped_employee.get(leave.calendar_id.id, self.env['hr.employee']):
holidays = holidays_by_employee.get(employee.id)
work_hours_list = work_hours_data[leave.id]
for index, (day_date, work_hours_count) in enumerate(work_hours_list):
if not holidays or all(not (date_from <= day_date and date_to >= day_date) for date_from, date_to in holidays):
vals_list.append(
leave._timesheet_prepare_line_values(
index,
employee,
work_hours_list,
day_date,
work_hours_count
)
)
return self.env['account.analytic.line'].sudo().create(vals_list)
def _timesheet_prepare_line_values(self, index, employee_id, work_hours_data, day_date, work_hours_count):
self.ensure_one()
return {
'name': _("Time Off (%s/%s)", index + 1, len(work_hours_data)),
'project_id': employee_id.company_id.internal_project_id.id,
'task_id': employee_id.company_id.leave_timesheet_task_id.id,
'account_id': employee_id.company_id.internal_project_id.analytic_account_id.id,
'unit_amount': work_hours_count,
'user_id': employee_id.user_id.id,
'date': day_date,
'global_leave_id': self.id,
'employee_id': employee_id.id,
'company_id': employee_id.company_id.id,
}
@api.model_create_multi
def create(self, vals_list):
results = super(ResourceCalendarLeaves, self).create(vals_list)
results_with_leave_timesheet = results.filtered(lambda r: not r.resource_id.id and r.calendar_id.company_id.internal_project_id and r.calendar_id.company_id.leave_timesheet_task_id)
results_with_leave_timesheet and results_with_leave_timesheet._timesheet_create_lines()
return results
def write(self, vals):
start_dates = self.mapped('date_from')
end_dates = self.mapped('date_to')
result = super(ResourceCalendarLeaves, self).write(vals)
date_from, date_to = vals.get('date_from'), vals.get('date_to')
if date_from or date_to:
if any(start_date != date_from or end_date != date_to for start_date, end_date in zip(start_dates, end_dates)):
timesheets = self.mapped('timesheet_ids')
if timesheets:
timesheets.write({'global_leave_id': False})
timesheets.unlink()
self._timesheet_create_lines()
return result
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_timesheet_holidays
from . import test_timesheet_global_time_off
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import datetime
from odoo import Command
from odoo.tests import common
class TestTimesheetGlobalTimeOff(common.TransactionCase):
def setUp(self):
super(TestTimesheetGlobalTimeOff, self).setUp()
# Creates 1 test company and a calendar for employees that
# work part time. Then creates an employee per calendar (one
# for the standard calendar and one for the one we created)
self.test_company = self.env['res.company'].create({
'name': 'My Test Company',
})
attendance_ids = [
Command.create({'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
Command.create({'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
Command.create({'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
Command.create({'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'})
]
self.part_time_calendar = self.env['resource.calendar'].create({
'name': 'Part Time Calendar',
'company_id': self.test_company.id,
'hours_per_day': 6,
'attendance_ids': attendance_ids,
})
self.full_time_employee = self.env['hr.employee'].create({
'name': 'John Doe',
'company_id': self.test_company.id,
'resource_calendar_id': self.test_company.resource_calendar_id.id,
})
self.full_time_employee_2 = self.env['hr.employee'].create({
'name': 'John Smith',
'company_id': self.test_company.id,
'resource_calendar_id': self.test_company.resource_calendar_id.id,
})
self.part_time_employee = self.env['hr.employee'].create({
'name': 'Jane Doe',
'company_id': self.test_company.id,
'resource_calendar_id': self.part_time_calendar.id,
})
# This tests that timesheets are created for every employee with the same calendar
# when a global time off is created.
# This also tests that timesheets are deleted when global time off is deleted.
def test_timesheet_creation_and_deletion_for_time_off(self):
leave_start_datetime = datetime(2021, 1, 4, 7, 0, 0, 0) # This is a monday
leave_end_datetime = datetime(2021, 1, 8, 18, 0, 0, 0) # This is a friday
global_time_off = self.env['resource.calendar.leaves'].create({
'name': 'Test',
'calendar_id': self.test_company.resource_calendar_id.id,
'date_from': leave_start_datetime,
'date_to': leave_end_datetime,
})
# 5 Timesheets should have been created for full_time_employee and full_time_employee_2
# but none for part_time_employee
leave_task = self.test_company.leave_timesheet_task_id
timesheets_by_employee = defaultdict(lambda: self.env['account.analytic.line'])
for timesheet in leave_task.timesheet_ids:
timesheets_by_employee[timesheet.employee_id] |= timesheet
self.assertFalse(timesheets_by_employee.get(self.part_time_employee, False))
self.assertEqual(len(timesheets_by_employee.get(self.full_time_employee)), 5)
self.assertEqual(len(timesheets_by_employee.get(self.full_time_employee_2)), 5)
# The standard calendar is for 8 hours/day from 8 to 12 and from 13 to 17.
# So we need to check that the timesheets don't have more than 8 hours per day.
self.assertEqual(leave_task.effective_hours, 80)
# Now we delete the global time off. The timesheets should be deleted too.
global_time_off.unlink()
self.assertFalse(leave_task.timesheet_ids.ids)
# This tests that no timesheet are created for days when the employee is not supposed to work
def test_no_timesheet_on_off_days(self):
leave_start_datetime = datetime(2021, 1, 4, 7, 0, 0, 0) # This is a monday
leave_end_datetime = datetime(2021, 1, 8, 18, 0, 0, 0) # This is a friday
day_off = datetime(2021, 1, 6, 0, 0, 0) # part_time_employee does not work on wednesday
self.env['resource.calendar.leaves'].create({
'name': 'Test',
'calendar_id': self.part_time_calendar.id,
'date_from': leave_start_datetime,
'date_to': leave_end_datetime,
})
# The total number of hours for the timesheet created should be equal to the
# hours_per_day of the calendar
leave_task = self.test_company.leave_timesheet_task_id
self.assertEqual(leave_task.effective_hours, 4 * self.part_time_calendar.hours_per_day)
# No timesheet should have been created on the day off
timesheet = self.env['account.analytic.line'].search([('date', '=', day_off), ('task_id', '=', leave_task.id)])
self.assertFalse(timesheet.id)