From b23fea1b962a335ad94e65d8b971bd2ca995ab79 Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Fri, 1 Oct 2021 10:26:10 +0000 Subject: [PATCH] [ADD] l10n_fr_hr_holidays: add french time off for part timers Adds 2 new modules: l10n_fr_hr_holidays l10n_fr_hr_work_entry_holidays This commit adds modules allowing to compute accurately the number of days off taken by a french part-timer. Both modules will automatically install when hr_holidays is installed and at least one of the companies is located in France In France, when taking a time off using a different calendar from the company's calendar you have to count the days as if you were taking time off in the company calendar. For example let's say you have an employee working only monday through wednesday, if that employee were to take a time off ending with wednesday afternoon, the time off would count up until friday (if the company calendar if monday->friday), so that a whole week is always 5 days regardless of the employee calendar. This is also the case for 'holes' in the employee calendar, for example a calendar where one would work monday, wednesday and friday only. l10n_fr_work_entry_holidays: By default time off only generate work entries for intersections between time off and attendance period, so that when taking a time off for a whole month you wouldn't get work entries on the weekend if you normally don't work that day. But since l10n_fr_hr_holidays computes the number of days it would normally cost with french rules. We also have to make sure that the work entries are generated appropriately. Essentially this modules adds the logic required to fill the holes needed using the company calendar instead of the employee one. OTHER: Fixes a redundant call to super function in hr.leave._get_number_of_days, improving its performance a bit task-2602897 closes odoo/odoo#82637 Related: odoo/enterprise#25805 Signed-off-by: Yannick Tivisse (yti) Co-authored-by: Bertrand Dossogne --- .../base_setup/models/res_config_settings.py | 1 + addons/hr_holidays/__init__.py | 10 + addons/hr_holidays/__manifest__.py | 1 + addons/hr_holidays/models/hr_leave.py | 1 + addons/l10n_fr_hr_holidays/__init__.py | 4 + addons/l10n_fr_hr_holidays/__manifest__.py | 19 ++ .../data/l10n_fr_hr_holidays_demo.xml | 54 ++++ addons/l10n_fr_hr_holidays/models/__init__.py | 7 + addons/l10n_fr_hr_holidays/models/hr_leave.py | 128 ++++++++ .../l10n_fr_hr_holidays/models/res_company.py | 19 ++ .../models/res_config_settings.py | 12 + .../models/resource_calendar.py | 30 ++ addons/l10n_fr_hr_holidays/tests/__init__.py | 4 + .../tests/test_french_leaves.py | 292 ++++++++++++++++++ .../views/res_config_settings_views.xml | 29 ++ .../__init__.py | 4 + .../__manifest__.py | 15 + .../models/__init__.py | 5 + .../models/hr_contract.py | 70 +++++ .../models/hr_work_entry.py | 32 ++ .../tests/__init__.py | 4 + .../tests/test_french_work_entries.py | 89 ++++++ 22 files changed, 830 insertions(+) create mode 100644 addons/l10n_fr_hr_holidays/__init__.py create mode 100644 addons/l10n_fr_hr_holidays/__manifest__.py create mode 100644 addons/l10n_fr_hr_holidays/data/l10n_fr_hr_holidays_demo.xml create mode 100644 addons/l10n_fr_hr_holidays/models/__init__.py create mode 100644 addons/l10n_fr_hr_holidays/models/hr_leave.py create mode 100644 addons/l10n_fr_hr_holidays/models/res_company.py create mode 100644 addons/l10n_fr_hr_holidays/models/res_config_settings.py create mode 100644 addons/l10n_fr_hr_holidays/models/resource_calendar.py create mode 100644 addons/l10n_fr_hr_holidays/tests/__init__.py create mode 100644 addons/l10n_fr_hr_holidays/tests/test_french_leaves.py create mode 100644 addons/l10n_fr_hr_holidays/views/res_config_settings_views.xml create mode 100644 addons/l10n_fr_hr_work_entry_holidays/__init__.py create mode 100644 addons/l10n_fr_hr_work_entry_holidays/__manifest__.py create mode 100644 addons/l10n_fr_hr_work_entry_holidays/models/__init__.py create mode 100644 addons/l10n_fr_hr_work_entry_holidays/models/hr_contract.py create mode 100644 addons/l10n_fr_hr_work_entry_holidays/models/hr_work_entry.py create mode 100644 addons/l10n_fr_hr_work_entry_holidays/tests/__init__.py create mode 100644 addons/l10n_fr_hr_work_entry_holidays/tests/test_french_work_entries.py diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 068ea21e437..b2b98a91988 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -45,6 +45,7 @@ class ResConfigSettings(models.TransientModel): language_count = fields.Integer('Number of Languages', compute="_compute_language_count") company_name = fields.Char(related="company_id.display_name", string="Company Name") company_informations = fields.Text(compute="_compute_company_informations") + company_country_code = fields.Char(related="company_id.country_id.code", string="Company Country Code", readonly=True) profiling_enabled_until = fields.Datetime("Profiling enabled until", config_parameter='base.profiling_enabled_until') module_product_images = fields.Boolean("Get product pictures using barcode") diff --git a/addons/hr_holidays/__init__.py b/addons/hr_holidays/__init__.py index d26fccf560c..fea4e90c2dd 100644 --- a/addons/hr_holidays/__init__.py +++ b/addons/hr_holidays/__init__.py @@ -6,3 +6,13 @@ from . import models from . import populate from . import report from . import wizard + +from odoo import api, SUPERUSER_ID + +def _hr_holiday_post_init(env): + french_companies = env['res.company'].search_count([('partner_id.country_id.code', '=', 'FR')]) + if french_companies: + env['ir.module.module'].search([ + ('name', '=', 'l10n_fr_hr_work_entry_holidays'), + ('state', '=', 'uninstalled') + ]).sudo().button_install() diff --git a/addons/hr_holidays/__manifest__.py b/addons/hr_holidays/__manifest__.py index 34631ea36a7..6a335eed202 100644 --- a/addons/hr_holidays/__manifest__.py +++ b/addons/hr_holidays/__manifest__.py @@ -82,5 +82,6 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p '/hr_holidays/static/tests/tours/**/*' ], }, + 'post_init_hook': '_hr_holiday_post_init', 'license': 'LGPL-3', } diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index d6858f3a7ad..a873d92e701 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -748,6 +748,7 @@ class HolidaysRequest(models.Model): def _get_number_of_days(self, date_from, date_to, employee): """ Returns a float equals to the timedelta between two dates given as string.""" + self.ensure_one() if employee.resource_calendar_id: return self._get_number_of_days_batch(date_from, date_to, employee)[employee.id] diff --git a/addons/l10n_fr_hr_holidays/__init__.py b/addons/l10n_fr_hr_holidays/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_fr_hr_holidays/__manifest__.py b/addons/l10n_fr_hr_holidays/__manifest__.py new file mode 100644 index 00000000000..5edc2a3891f --- /dev/null +++ b/addons/l10n_fr_hr_holidays/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'France - Time Off', + 'version': '1.0', + 'category': 'Human Resources/Time Off', + 'icon': '/l10n_fr/static/description/icon.png', + 'summary': 'Management of leaves for part-time workers in France', + 'depends': ['hr_holidays', 'l10n_fr'], + 'auto_install': True, + 'license': 'LGPL-3', + 'data': [ + 'views/res_config_settings_views.xml', + ], + 'demo': [ + 'data/l10n_fr_hr_holidays_demo.xml', + ], +} diff --git a/addons/l10n_fr_hr_holidays/data/l10n_fr_hr_holidays_demo.xml b/addons/l10n_fr_hr_holidays/data/l10n_fr_hr_holidays_demo.xml new file mode 100644 index 00000000000..4d0b1067a23 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/data/l10n_fr_hr_holidays_demo.xml @@ -0,0 +1,54 @@ + + + + Part time + + 9 + + + + + + + Mitchell Admin + + + + + + + Paid Time Off + + yes + no + both + officer + + + 2 + True + + + + + + + Paid Time Off allocation + validate + + 20 + + + + + + diff --git a/addons/l10n_fr_hr_holidays/models/__init__.py b/addons/l10n_fr_hr_holidays/models/__init__.py new file mode 100644 index 00000000000..a77dcd6c2c3 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_leave +from . import res_company +from . import resource_calendar +from . import res_config_settings diff --git a/addons/l10n_fr_hr_holidays/models/hr_leave.py b/addons/l10n_fr_hr_holidays/models/hr_leave.py new file mode 100644 index 00000000000..9156a49279f --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/hr_leave.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, api + +class HrLeave(models.Model): + _inherit = 'hr.leave' + + l10n_fr_date_to_changed = fields.Boolean() + + def _get_fr_date_to(self, vals): + assert 'employee_id' in vals or len(self) == 1 + employee = self.env['hr.employee'].browse(vals['employee_id']).sudo() if 'employee_id' in vals else self.employee_id + employee_calendar = employee.resource_calendar_id + company_calendar = employee.company_id.resource_calendar_id + leave_type_id = vals['holiday_status_id'] if 'holiday_status_id' in vals else self.holiday_status_id.id + if employee.company_id.country_id.code == 'FR' and employee_calendar != company_calendar and leave_type_id: + reference_leave_type = employee.company_id._get_fr_reference_leave_type() + if reference_leave_type.id == leave_type_id: + return self._get_fr_new_date_to(vals['date_to'], employee_calendar, company_calendar) + return vals['date_to'] + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals['date_to'] = self._get_fr_date_to(vals) # returns the initial date_to if no change needed + return super().create(vals_list) + + def write(self, vals): + if 'date_to' not in vals: + return super().write(vals) + if 'employee_id' in vals: + vals['date_to'] = self._get_fr_date_to(vals) # returns the initial date_to if no change needed + return super().write(vals) + else: + # Different employees could share different calendars + for leave in self: + vals_copy = vals.copy() + vals_copy['date_to'] = leave._get_fr_date_to(vals_copy) + super(HrLeave, leave).write(vals_copy) + + def _get_fr_new_date_to(self, date_to, employee_calendar, company_calendar): + date_target = date_to + if isinstance(date_to, str): + date_target = datetime.fromisoformat(date_to) + new_date_to = date_target + date_target += relativedelta(days=1) + while not employee_calendar._works_on_date(date_target): + if company_calendar._works_on_date(date_target): + new_date_to = date_target + date_target += relativedelta(days=1) + + return new_date_to + + def _get_fr_number_of_days(self, employee, date_from, date_to, employee_calendar, company_calendar): + self.ensure_one() + + self.l10n_fr_date_to_changed = False + # We can fill the holes using the company calendar as default + # What we need to compute is how much we will need to push date_to in order to account for the lost days + # This gets even more complicated in two_weeks_calendars + + if self.request_unit_half and self.request_date_from_period == 'am': + # In normal workflows request_unit_half implies that date_from and date_to are the same + # request_unit_half allows us to choose between `am` and `pm` + # In a case where we work from mon-wed and request a half day in the morning + # we do not want to push date_to since the next work attendance is actually in the afternoon + date_from_weektype = str(self.env['resource.calendar.attendance'].get_week_type(date_from)) + date_from_dayofweek = str(date_from.weekday()) + # Fetch the attendances we care about + attendance_ids = employee_calendar.attendance_ids.filtered(lambda a: + a.dayofweek == date_from_dayofweek + and a.day_period != "lunch" + and (not employee_calendar.two_weeks_calendar or a.week_type == date_from_weektype)) + if len(attendance_ids) == 2: + # The employee took the morning off on a day where he works the afternoon aswell + attendance = attendance_ids[0] if attendance_ids[0].day_period == 'morning' else attendance_ids[1] + return {'days': 0.5, 'hours': attendance.hour_to - attendance.hour_from} + # Check calendars for working days until we find the right target, start at date_to + 1 day + # Postpone date_target until the next working day + date_start = date_from + date_target = date_to + relativedelta(days=1) + counter = 0 + while not employee_calendar._works_on_date(date_start): + date_start += relativedelta(days=1) + while not employee_calendar._works_on_date(date_target): + date_target += relativedelta(days=1) + counter += 1 + # Allow up to 14 days for two weeks calendars to avoid infinite loop. + if counter > 14: + # Default behaviour + result = employee._get_work_days_data_batch(date_start, date_to, calendar=employee_calendar)[employee.id] + if self.request_unit_half and result['hours'] > 0: + result['days'] = 0.5 + return result + date_target = datetime.combine(date_target.date(), datetime.min.time()) + self.l10n_fr_date_to_changed = True + return employee._get_work_days_data_batch(date_start, date_target, calendar=company_calendar)[employee.id] + + def _get_number_of_days(self, date_from, date_to, employee): + """ + In french time off laws, if an employee has a part time contract, when taking time off + before one of his off day (compared to the company's calendar) it should also count the time + between the time off and the next calendar work day/company off day (weekends). + + For example take an employee working mon-wed in a company where the regular calendar is mon-fri. + If the employee were to take a time off ending on wednesday, the legal duration would count until friday. + + Returns a dict containing two keys: 'days' and 'hours' with the value being the duration for the requested time period. + """ + basic_amount = super()._get_number_of_days(date_from, date_to, employee) + + if not employee or not (basic_amount['days'] or basic_amount['hours']): + return basic_amount + employee = employee.sudo() + company = employee.company_id + if company.country_id.code != 'FR' or not company.resource_calendar_id: + return basic_amount + calendar = self._get_calendar() + if not calendar or not calendar != company.resource_calendar_id: + return basic_amount + if self.holiday_status_id != employee.company_id._get_fr_reference_leave_type(): + return basic_amount + + return self._get_fr_number_of_days(employee, date_from, date_to, calendar, company.resource_calendar_id) diff --git a/addons/l10n_fr_hr_holidays/models/res_company.py b/addons/l10n_fr_hr_holidays/models/res_company.py new file mode 100644 index 00000000000..dfe6017d5a5 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/res_company.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, _ +from odoo.exceptions import ValidationError + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_fr_reference_leave_type = fields.Many2one( + 'hr.leave.type', + string='Company Paid Time Off Type') + + def _get_fr_reference_leave_type(self): + self.ensure_one() + if not self.l10n_fr_reference_leave_type: + raise ValidationError(_("You must first define a reference time off type for the company.")) + return self.l10n_fr_reference_leave_type diff --git a/addons/l10n_fr_hr_holidays/models/res_config_settings.py b/addons/l10n_fr_hr_holidays/models/res_config_settings.py new file mode 100644 index 00000000000..e7b36abbf6e --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/res_config_settings.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + l10n_fr_reference_leave_type = fields.Many2one( + 'hr.leave.type', + related='company_id.l10n_fr_reference_leave_type', + readonly=False) diff --git a/addons/l10n_fr_hr_holidays/models/resource_calendar.py b/addons/l10n_fr_hr_holidays/models/resource_calendar.py new file mode 100644 index 00000000000..4dc1f57e409 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/resource_calendar.py @@ -0,0 +1,30 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.tools import ormcache + +from collections import defaultdict + + +class ResourceCalendar(models.Model): + _inherit = 'resource.calendar' + + def _works_on_date(self, date): + self.ensure_one() + + working_days = self._get_working_hours() + dayofweek = str(date.weekday()) + if self.two_weeks_calendar: + weektype = str(self.env['resource.calendar.attendance'].get_week_type(date)) + return working_days[weektype][dayofweek] + return working_days[False][dayofweek] + + @ormcache('self.id') + def _get_working_hours(self): + self.ensure_one() + + working_days = defaultdict(lambda: defaultdict(lambda: False)) + for attendance in self.attendance_ids: + working_days[attendance.week_type][attendance.dayofweek] = True + return working_days diff --git a/addons/l10n_fr_hr_holidays/tests/__init__.py b/addons/l10n_fr_hr_holidays/tests/__init__.py new file mode 100644 index 00000000000..60abb90f795 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_french_leaves diff --git a/addons/l10n_fr_hr_holidays/tests/test_french_leaves.py b/addons/l10n_fr_hr_holidays/tests/test_french_leaves.py new file mode 100644 index 00000000000..e4fb02e35c1 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/tests/test_french_leaves.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import time + +from odoo.tests.common import TransactionCase, tagged + +_logger = logging.getLogger(__name__) + +@tagged('post_install_l10n', 'post_install', '-at_install', 'french_leaves') +class TestFrenchLeaves(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + country_fr = cls.env.ref('base.fr') + cls.company = cls.env['res.company'].create({ + 'name': 'French Company', + 'country_id': country_fr.id, + }) + + cls.employee = cls.env['hr.employee'].create({ + 'name': 'Louis', + 'gender': 'other', + 'birthday': '1973-03-29', + 'country_id': country_fr.id, + 'company_id': cls.company.id, + }) + + cls.time_off_type = cls.env['hr.leave.type'].create({ + 'name': 'Time Off', + 'requires_allocation': 'no', + }) + cls.company.write({ + 'l10n_fr_reference_leave_type': cls.time_off_type.id, + }) + + cls.base_calendar = cls.env['resource.calendar'].create({ + 'name': 'default calendar', + }) + + def test_no_differences(self): + # Base case that should not have a different behaviour + self.company.resource_calendar_id = self.base_calendar + self.employee.resource_calendar_id = self.base_calendar + + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-06', + 'date_to': '2021-09-10 23:59:59', + }) + self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.') + + def test_end_of_week(self): + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = self.base_calendar + self.employee.resource_calendar_id = employee_calendar + + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-06', + 'date_to': '2021-09-08 23:59:59', + }) + self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.') + + def test_start_of_week(self): + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = self.base_calendar + self.employee.resource_calendar_id = employee_calendar + + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-08', + 'date_to': '2021-09-10 23:59:59', + }) + self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.') + + def test_last_day_half(self): + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = self.base_calendar + self.employee.resource_calendar_id = employee_calendar + + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-10', + 'date_to': '2021-09-10 12:00:00', + 'request_date_from': '2021-09-10', + 'request_date_to': '2021-09-10 12:00:00', + 'request_unit_half': True, + 'request_date_from_period': 'am', + }) + # Since the employee works on the afternoon, the date_to is not post-poned + self.assertEqual(leave.number_of_days, 0.5, 'The number of days should be equal to 0.5.') + leave.request_date_from_period = 'pm' + # This however should push the date_to + self.assertEqual(leave.number_of_days, 2.5, 'The number of days should be equal to 2.5.') + + def test_calendar_with_holes(self): + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = self.base_calendar + self.employee.resource_calendar_id = employee_calendar + + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-06', + 'date_to': '2021-09-10 23:59:59', + }) + self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.') + + def test_calendar_end_week_hole(self): + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = self.base_calendar + self.employee.resource_calendar_id = employee_calendar + + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-06', + 'date_to': '2021-09-08 23:59:59', + }) + self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.') + + def test_2_weeks_calendar(self): + company_calendar = self.env['resource.calendar'].create({ + 'name': 'Company Calendar', + 'two_weeks_calendar': True, + 'attendance_ids': [ + (0, 0, {'week_type': '0', 'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '0', 'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '0', 'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'week_type': '0', 'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '0', 'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '0', 'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'week_type': '0', 'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '0', 'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '0', 'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'week_type': '0', 'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '0', 'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '0', 'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'week_type': '0', 'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '0', 'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '0', 'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + + (0, 0, {'week_type': '1', 'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '1', 'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '1', 'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'week_type': '1', 'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '1', 'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '1', 'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'week_type': '1', 'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'week_type': '1', 'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'week_type': '1', 'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = company_calendar + self.employee.resource_calendar_id = employee_calendar + + # Week type 0 + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-06', + 'date_to': '2021-09-08 23:59:59', + }) + self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.') + leave.unlink() + + # Week type 1 + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-13', + 'date_to': '2021-09-15 23:59:59', + }) + self.assertEqual(leave.number_of_days, 3, 'The number of days should be equal to 3.') + leave.unlink() + + # Both ending with week type 1 + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-06', + 'date_to': '2021-09-15 23:59:59', + }) + self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.') + leave.unlink() + + # Both ending with week type 0 + with self.assertQueryCount(118): + start_time = time.time() + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-13', + 'date_to': '2021-09-22 23:59:59', + }) + # --- 0.11486363410949707 seconds --- + _logger.info("French Leave Creation: --- %s seconds ---", time.time() - start_time) + self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.') + leave.unlink() diff --git a/addons/l10n_fr_hr_holidays/views/res_config_settings_views.xml b/addons/l10n_fr_hr_holidays/views/res_config_settings_views.xml new file mode 100644 index 00000000000..b46922dad1d --- /dev/null +++ b/addons/l10n_fr_hr_holidays/views/res_config_settings_views.xml @@ -0,0 +1,29 @@ + + + + res.config.settings.view.form.inherit.hr + res.config.settings + + + + + + + + + + + + + + + + diff --git a/addons/l10n_fr_hr_work_entry_holidays/__init__.py b/addons/l10n_fr_hr_work_entry_holidays/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_fr_hr_work_entry_holidays/__manifest__.py b/addons/l10n_fr_hr_work_entry_holidays/__manifest__.py new file mode 100644 index 00000000000..5a86035971c --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/__manifest__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'France - Work Entries Time Off', + 'version': '1.0', + 'icon': '/l10n_fr/static/description/icon.png', + 'summary': 'Management of leaves for part-time workers in France', + 'depends': [ + 'l10n_fr_hr_holidays', + 'hr_work_entry_holidays', + ], + 'auto_install': True, + 'license': 'LGPL-3', +} diff --git a/addons/l10n_fr_hr_work_entry_holidays/models/__init__.py b/addons/l10n_fr_hr_work_entry_holidays/models/__init__.py new file mode 100644 index 00000000000..df52f76ea4e --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_contract +from . import hr_work_entry diff --git a/addons/l10n_fr_hr_work_entry_holidays/models/hr_contract.py b/addons/l10n_fr_hr_work_entry_holidays/models/hr_contract.py new file mode 100644 index 00000000000..b13bb5e793b --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/models/hr_contract.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +import pytz + +from odoo import models +from odoo.addons.resource.models.utils import datetime_to_string + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + def _get_contract_work_entries_values(self, date_start, date_stop): + # Add the work entries difference for french payroll + # Work entries by default are not generated on days the employee does not work + # So we have to fill the gaps with work entries for those periods + result = super()._get_contract_work_entries_values(date_start, date_stop) + fr_contracts = self.filtered(lambda c: c.company_id.country_id.code == 'FR' and c.resource_calendar_id != c.company_id.resource_calendar_id) + if not fr_contracts: + return result + start_dt = pytz.utc.localize(date_start) if not date_start.tzinfo else date_start + end_dt = pytz.utc.localize(date_stop) if not date_stop.tzinfo else date_stop + # l10n_fr_date_to_changed is False when no adjustment had to be done + all_leaves = self.env['hr.leave'].search([ + ('employee_id', 'in', fr_contracts.employee_id.ids), + ('state', '=', 'validate'), + ('date_from', '<=', datetime_to_string(end_dt)), + ('date_to', '>=', datetime_to_string(start_dt)), + ('l10n_fr_date_to_changed', '=', True), + ]) + leaves_per_employee = defaultdict(lambda: self.env['hr.leave']) + for leave in all_leaves: + leaves_per_employee[leave.employee_id] |= leave + for contract in fr_contracts: + employee = contract.employee_id + employee_calendar = contract.resource_calendar_id + company = contract.company_id + company_calendar = company.resource_calendar_id + resource = employee.resource_id + tz = pytz.timezone(employee_calendar.tz) + + for leave in leaves_per_employee[employee]: + leave_start_dt = max(start_dt, leave.date_from.astimezone(tz)) + leave_end_dt_fr = min(end_dt, leave.date_to.astimezone(tz)) + + # Compute the attendances for the company calendar and the employee calendar + # and then compute and keep the difference between those two + company_attendances = company_calendar._attendance_intervals_batch( + leave_start_dt, leave_end_dt_fr, resources=resource, tz=tz, + )[resource.id] + # Dates on which work entries should already be generated + employee_dates = set() + for vals in result: + employee_dates.add(vals['date_start'].date()) + employee_dates.add(vals['date_stop'].date()) + leave_work_entry_type = leave.holiday_status_id.work_entry_type_id + result += [{ + 'name': '%s%s' % (leave_work_entry_type.name + ': ' if leave_work_entry_type else "", employee.name), + 'date_start': interval[0].astimezone(pytz.utc).replace(tzinfo=None), + 'date_stop': interval[1].astimezone(pytz.utc).replace(tzinfo=None), + 'work_entry_type_id': leave_work_entry_type.id, + 'employee_id': employee.id, + 'company_id': contract.company_id.id, + 'state': 'draft', + 'contract_id': contract.id, + 'leave_id': leave.id, + } for interval in company_attendances if interval[0].date() not in employee_dates] + + return result diff --git a/addons/l10n_fr_hr_work_entry_holidays/models/hr_work_entry.py b/addons/l10n_fr_hr_work_entry_holidays/models/hr_work_entry.py new file mode 100644 index 00000000000..4e430596422 --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/models/hr_work_entry.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +class HrWorkEntry(models.Model): + _inherit = 'hr.work.entry' + + def _filter_french_part_time_entries(self): + french_part_time_work_entries = self.filtered(lambda w: + w.company_id.country_id.code == 'FR' + and w.employee_id.resource_calendar_id != w.company_id.resource_calendar_id) + return french_part_time_work_entries + + def _mark_leaves_outside_schedule(self): + french_part_time_work_entries = self._filter_french_part_time_entries() + if not french_part_time_work_entries: + return super()._mark_leaves_outside_schedule() + other_work_entries = self - french_part_time_work_entries + if other_work_entries: + return other_work_entries._mark_leaves_outside_schedule() + return False + + def _get_duration_batch(self): + res = super()._get_duration_batch() + french_part_time_work_entries = self._filter_french_part_time_entries() + if not french_part_time_work_entries: + return res + for entry in french_part_time_work_entries: + if entry.id in res and res[entry.id] == 0: + res[entry.id] = (entry.date_stop - entry.date_start).seconds/3600 + return res diff --git a/addons/l10n_fr_hr_work_entry_holidays/tests/__init__.py b/addons/l10n_fr_hr_work_entry_holidays/tests/__init__.py new file mode 100644 index 00000000000..3ff31f67443 --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_french_work_entries diff --git a/addons/l10n_fr_hr_work_entry_holidays/tests/test_french_work_entries.py b/addons/l10n_fr_hr_work_entry_holidays/tests/test_french_work_entries.py new file mode 100644 index 00000000000..3940b05c9fd --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/tests/test_french_work_entries.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import time + +from datetime import datetime +from odoo.tests.common import TransactionCase, tagged + +_logger = logging.getLogger(__name__) + +@tagged('post_install_l10n', 'post_install', '-at_install', 'french_work_entries') +class TestFrenchWorkEntries(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + country_fr = cls.env.ref('base.fr') + cls.company = cls.env['res.company'].create({ + 'name': 'French Company', + 'country_id': country_fr.id, + }) + + cls.employee = cls.env['hr.employee'].create({ + 'name': 'Louis', + 'gender': 'other', + 'birthday': '1973-03-29', + 'country_id': country_fr.id, + 'company_id': cls.company.id, + }) + + cls.employee_contract = cls.env['hr.contract'].create({ + 'date_start': '2020-01-01', + 'date_end': '2023-01-01', + 'name': 'Louis\'s contract', + 'wage': 2, + 'employee_id': cls.employee.id, + 'company_id': cls.company.id, + }) + + cls.time_off_type = cls.env['hr.leave.type'].create({ + 'name': 'Time Off', + 'requires_allocation': 'no', + }) + cls.company.write({ + 'l10n_fr_reference_leave_type': cls.time_off_type.id, + }) + + def test_fill_gaps(self): + company_calendar = self.env['resource.calendar'].create({ + 'name': 'Company Calendar', + }) + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = company_calendar + self.employee.resource_calendar_id = employee_calendar + self.employee_contract.resource_calendar_id = employee_calendar + + # Get the create values for a week of work entries, it should only give us 4 entries ((am+pm) * 2) + work_entry_create_vals = self.employee_contract._get_contract_work_entries_values(datetime(2021, 9, 6), datetime(2021, 9, 10, 23, 59, 59)) + self.assertEqual(len(work_entry_create_vals), 4, 'Should have generated 4 work entries.') + + leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'date_from': '2021-09-06', + 'date_to': '2021-09-08 23:59:59', # This should fill the gap up to the 10th + }) + leave.action_validate() + + # Since the gaps have been filled, we should now get 10 work entries + with self.assertQueryCount(45): + start_time = time.time() + work_entry_create_vals = self.employee_contract._get_contract_work_entries_values(datetime(2021, 9, 6), datetime(2021, 9, 10, 23, 59, 59)) + # --- 0.11486363410949707 seconds --- + _logger.info("Get Contract Work Entries: --- %s seconds ---", time.time() - start_time) + self.assertEqual(len(work_entry_create_vals), 10, 'Should have generated 10 work entries.') + + # Make sure that the gap filling does not go past the requested date + work_entry_create_vals = self.employee_contract._get_contract_work_entries_values(datetime(2021, 9, 6), datetime(2021, 9, 9, 23, 59, 59)) + self.assertEqual(len(work_entry_create_vals), 8, 'Should have generated 8 work entries.')