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)