From 96ff372034ec1e18dd4c6d7a383b8b50590d033d Mon Sep 17 00:00:00 2001 From: jdoutreloux Date: Wed, 13 Jan 2021 16:10:46 +0000 Subject: [PATCH] [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) Co-authored-by: jdoutreloux --- .../models/__init__.py | 2 + .../models/account_analytic.py | 1 + .../models/hr_employee.py | 35 ++++ .../models/hr_holidays.py | 2 +- .../models/resource_calendar_leaves.py | 165 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_timesheet_global_time_off.py | 112 ++++++++++++ 7 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 addons/project_timesheet_holidays/models/hr_employee.py create mode 100644 addons/project_timesheet_holidays/models/resource_calendar_leaves.py create mode 100644 addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py diff --git a/addons/project_timesheet_holidays/models/__init__.py b/addons/project_timesheet_holidays/models/__init__.py index 4dee358245f..0147ac7d436 100644 --- a/addons/project_timesheet_holidays/models/__init__.py +++ b/addons/project_timesheet_holidays/models/__init__.py @@ -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 diff --git a/addons/project_timesheet_holidays/models/account_analytic.py b/addons/project_timesheet_holidays/models/account_analytic.py index dab13a7c470..259ed33d4ae 100644 --- a/addons/project_timesheet_holidays/models/account_analytic.py +++ b/addons/project_timesheet_holidays/models/account_analytic.py @@ -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): diff --git a/addons/project_timesheet_holidays/models/hr_employee.py b/addons/project_timesheet_holidays/models/hr_employee.py new file mode 100644 index 00000000000..da1cb562353 --- /dev/null +++ b/addons/project_timesheet_holidays/models/hr_employee.py @@ -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 diff --git a/addons/project_timesheet_holidays/models/hr_holidays.py b/addons/project_timesheet_holidays/models/hr_holidays.py index ef37e44d871..be9d7ae9be4 100644 --- a/addons/project_timesheet_holidays/models/hr_holidays.py +++ b/addons/project_timesheet_holidays/models/hr_holidays.py @@ -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, diff --git a/addons/project_timesheet_holidays/models/resource_calendar_leaves.py b/addons/project_timesheet_holidays/models/resource_calendar_leaves.py new file mode 100644 index 00000000000..2e949d29c8e --- /dev/null +++ b/addons/project_timesheet_holidays/models/resource_calendar_leaves.py @@ -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 diff --git a/addons/project_timesheet_holidays/tests/__init__.py b/addons/project_timesheet_holidays/tests/__init__.py index a506a2840df..bd77f885744 100644 --- a/addons/project_timesheet_holidays/tests/__init__.py +++ b/addons/project_timesheet_holidays/tests/__init__.py @@ -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 diff --git a/addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py b/addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py new file mode 100644 index 00000000000..d717d53d109 --- /dev/null +++ b/addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py @@ -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)