From ecba73e83278979568d10a246a3a78b885612791 Mon Sep 17 00:00:00 2001 From: Laetitia Da Costa Date: Thu, 25 Jun 2026 18:55:22 +0200 Subject: [PATCH] [FIX]l10n_fr_hr_holidays:fix bug in part-time employee duleaves duration calculation --- l10n_fr_hr_holidays/README.rst | 2 +- l10n_fr_hr_holidays/__manifest__.py | 2 +- l10n_fr_hr_holidays/models/hr_leave.py | 64 +++- .../models/resource_calendar.py | 18 +- .../tests/test_french_leaves.py | 292 +++++++++++++++++- 5 files changed, 352 insertions(+), 26 deletions(-) diff --git a/l10n_fr_hr_holidays/README.rst b/l10n_fr_hr_holidays/README.rst index 6dc6005..65bf929 100644 --- a/l10n_fr_hr_holidays/README.rst +++ b/l10n_fr_hr_holidays/README.rst @@ -26,7 +26,7 @@ Credits Contributors ------------ -* `Elabore ` +* `Elabore ` Funders ------- diff --git a/l10n_fr_hr_holidays/__manifest__.py b/l10n_fr_hr_holidays/__manifest__.py index 309a0bb..4974fd0 100644 --- a/l10n_fr_hr_holidays/__manifest__.py +++ b/l10n_fr_hr_holidays/__manifest__.py @@ -3,7 +3,7 @@ { "name": "France - Time Off", - "version": "16.0.1.0.1", + "version": "16.0.1.1.0", "category": "Human Resources/Time Off", "countries": ["fr"], "summary": "Management of leaves for part-time workers in France", diff --git a/l10n_fr_hr_holidays/models/hr_leave.py b/l10n_fr_hr_holidays/models/hr_leave.py index 36f1c74..442dfa5 100644 --- a/l10n_fr_hr_holidays/models/hr_leave.py +++ b/l10n_fr_hr_holidays/models/hr_leave.py @@ -175,9 +175,14 @@ class HrLeave(models.Model): #@overwrite def _get_number_of_days_batch(self, date_from, date_to, employee_ids): """ - Returns a dict with the number of legal leave days for each employee, - based on the company's calendar. In France, part-time employees accrue and take leave on company working days, - not only on their own working days. Handles half-day requests and rounds according to French rules. + Returns a dict with the number of legal leave days for each employee. + In France, part-time employees' leave is counted in company working days + (jours ouvrables) from the first day of absence to the return day + (exclusive). The absence period is determined by the employee's calendar, + but the day count uses the company's calendar. Public holidays and other + global leaves registered on the company calendar are excluded. + For half-day requests, the first day counts as 0.5 if the employee + works the other half of that day (returns same day), or 1 otherwise. """ employee = self.env['hr.employee'].browse(employee_ids) # Force the company in the domain, as we are likely in a compute_sudo context @@ -188,12 +193,55 @@ class HrLeave(models.Model): calendar = self._get_calendar() result = employee._get_work_days_data_batch(date_from, date_to, calendar=calendar, domain=domain) for employee_id in result: - # For non-French context: a half-day leave always counts as 0.5 day - if self.request_unit_half and result[employee_id]['hours'] > 0 and not self._l10n_fr_leave_applies(): + if self._l10n_fr_leave_applies(): + company_calendar = calendar + employee_calendar = self.resource_calendar_id + first_day = date_from.date() + last_date = date_to.date() + + # _get_fr_date_from_to returns date_to with different semantics: + # - AM half-day, employee doesn't work PM: date_to = return day + # (must be excluded from the count) + # - AM half-day, employee works PM: date_to = same day (leave day) + # - General case (full-day, PM half-day): date_to = last leave day + # (day before the return, must be included) + # Only the first case has date_to as the return day. It is + # detected by: AM half-day, date_to strictly after date_from. + if (self.request_unit_half and self.request_date_from_period == 'am' + and last_date > first_day): + last_date -= relativedelta(days=1) + + # Get the set of dates where the company actually works between + # first_day and last_date, excluding public holidays (global leaves). + # Don't pass the company_id-filtered domain here: the leave is + # already matched by calendar_id in _leave_intervals_batch, and + # the company_id filter would exclude holidays created on the + # french company when self.env.company is the main company. + working_dates = company_calendar._get_working_dates(first_day, last_date) + + if self.request_unit_half: + # Check if the employee works the other half of the first day + dayofweek = str(first_day.weekday()) + other_period = 'afternoon' if self.request_date_from_period == 'am' else 'morning' + other_half = employee_calendar.attendance_ids.filtered( + lambda a: a.dayofweek == dayofweek and a.day_period == other_period + ) + first_day_count = 0.5 if (other_half and first_day in working_dates) else (1.0 if first_day in working_dates else 0.0) + days_count = first_day_count + current_date = first_day + relativedelta(days=1) + else: + days_count = 0 + current_date = first_day + + while current_date <= last_date: + if current_date in working_dates: + days_count += 1 + current_date += relativedelta(days=1) + + result[employee_id]['days'] = float(days_count) + elif self.request_unit_half and result[employee_id]['hours'] > 0: + # Non-French context: a half-day leave always counts as 0.5 day result[employee_id]['days'] = 0.5 - # For French context: round the number of days to the nearest half-day (legal rule) - elif self.request_unit_half and result[employee_id]['hours'] > 0 and self._l10n_fr_leave_applies(): - result[employee_id]['days'] = self._round_to_nearest_half(result[employee_id]['days']) return result def _round_to_nearest_half(self, x): diff --git a/l10n_fr_hr_holidays/models/resource_calendar.py b/l10n_fr_hr_holidays/models/resource_calendar.py index 84ec2a3..460b2a1 100644 --- a/l10n_fr_hr_holidays/models/resource_calendar.py +++ b/l10n_fr_hr_holidays/models/resource_calendar.py @@ -1,8 +1,11 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import models from collections import defaultdict +from datetime import datetime, time + +from odoo import models + class ResourceCalendar(models.Model): _inherit = 'resource.calendar' @@ -24,3 +27,16 @@ class ResourceCalendar(models.Model): for attendance in self.attendance_ids: working_days[attendance.week_type][attendance.dayofweek] = True return working_days + + def _get_working_dates(self, from_date, to_date, domain=None): + """Return a set of dates where the company actually works between + from_date and to_date (inclusive), excluding public holidays + and other global leaves registered on the calendar. + """ + self.ensure_one() + from pytz import timezone + tz = timezone(self.tz or 'UTC') + from_dt = tz.localize(datetime.combine(from_date, time.min)) + to_dt = tz.localize(datetime.combine(to_date, time(23, 59, 59))) + intervals = self._work_intervals_batch(from_dt, to_dt, domain=domain)[False] + return {start.astimezone(tz).date() for start, stop, meta in intervals} diff --git a/l10n_fr_hr_holidays/tests/test_french_leaves.py b/l10n_fr_hr_holidays/tests/test_french_leaves.py index 0b0e8e8..8d65cd6 100644 --- a/l10n_fr_hr_holidays/tests/test_french_leaves.py +++ b/l10n_fr_hr_holidays/tests/test_french_leaves.py @@ -15,7 +15,13 @@ class TestFrenchLeaves(TransactionCase): 'country_id': country_fr.id, }) - cls.employee = cls.env['hr.employee'].create({ + cls.base_calendar = cls.env['resource.calendar'].create({ + 'name': 'default calendar', + 'company_id': cls.company.id, + }) + cls.company.resource_calendar_id = cls.base_calendar + + cls.employee = cls.env['hr.employee'].with_company(cls.company).create({ 'name': 'Camille', 'gender': 'other', 'birthday': '1973-03-29', @@ -32,14 +38,29 @@ class TestFrenchLeaves(TransactionCase): 'l10n_fr_reference_leave_type': cls.time_off_type.id, }) - cls.base_calendar = cls.env['resource.calendar'].create({ - 'name': 'default calendar', - }) + def _set_employee_calendar(self, calendar): + """Set employee resource calendar and update hr.employee.calendar + planning record if hr_employee_calendar_planning is installed. + The planning module overrides resource_calendar_id on hr.leave via + _compute_resource_calendar_id, so we must keep the planning record + in sync with the test's intended employee calendar. + """ + self.employee.resource_calendar_id = calendar + if 'hr.employee.calendar' in self.env: + planning = self.env['hr.employee.calendar'].search( + [('employee_id', '=', self.employee.id)], limit=1) + if planning: + planning.calendar_id = calendar + else: + self.env['hr.employee.calendar'].create({ + 'employee_id': self.employee.id, + 'calendar_id': calendar.id, + }) 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 + self._set_employee_calendar(self.base_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -57,6 +78,7 @@ class TestFrenchLeaves(TransactionCase): def test_end_of_week(self): employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, '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'}), @@ -67,7 +89,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = self.base_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -85,6 +107,7 @@ class TestFrenchLeaves(TransactionCase): def test_start_of_week(self): employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, 'attendance_ids': [ (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'}), @@ -95,7 +118,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = self.base_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -106,7 +129,7 @@ class TestFrenchLeaves(TransactionCase): 'employee_company_id': self.company.id, 'resource_calendar_id': self.employee.resource_calendar_id.id, }) - + leave._compute_date_from_to() self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.') leave.unlink() @@ -114,6 +137,7 @@ class TestFrenchLeaves(TransactionCase): def test_last_day_half(self): employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, 'attendance_ids': [ (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'}), @@ -124,7 +148,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = self.base_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -150,6 +174,7 @@ class TestFrenchLeaves(TransactionCase): def test_full_time_am_day_half(self): employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, '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'}), @@ -163,7 +188,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = self.base_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -184,6 +209,7 @@ class TestFrenchLeaves(TransactionCase): def test_am_day_half(self): employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, 'attendance_ids': [ (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'}), @@ -193,7 +219,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = self.base_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -214,6 +240,7 @@ class TestFrenchLeaves(TransactionCase): def test_calendar_with_holes(self): employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, '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'}), @@ -224,7 +251,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = self.base_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -242,6 +269,7 @@ class TestFrenchLeaves(TransactionCase): def test_calendar_end_week_hole(self): employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, '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'}), @@ -250,7 +278,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = self.base_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) leave = self.env['hr.leave'].create({ 'name': 'Test', @@ -268,6 +296,7 @@ class TestFrenchLeaves(TransactionCase): def test_2_weeks_calendar(self): company_calendar = self.env['resource.calendar'].create({ 'name': 'Company Calendar', + 'company_id': self.company.id, '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'}), @@ -291,6 +320,7 @@ class TestFrenchLeaves(TransactionCase): }) employee_calendar = self.env['resource.calendar'].create({ 'name': 'Employee Calendar', + 'company_id': self.company.id, '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'}), @@ -301,7 +331,7 @@ class TestFrenchLeaves(TransactionCase): ], }) self.company.resource_calendar_id = company_calendar - self.employee.resource_calendar_id = employee_calendar + self._set_employee_calendar(employee_calendar) # Week type 0 leave = self.env['hr.leave'].create({ @@ -351,9 +381,241 @@ class TestFrenchLeaves(TransactionCase): 'employee_id': self.employee.id, 'request_date_from': '2021-09-13', 'request_date_to': '2021-09-22', - 'company_id': self.company.id, + 'employee_company_id': self.company.id, 'resource_calendar_id': self.employee.resource_calendar_id.id, }) leave._compute_date_from_to() self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.') leave.unlink() + + def test_part_time_different_hours(self): + # Regression test: when the employee's working hours differ from the + # company's on the boundary day, the hourly-ratio computation used to + # return a fractional number of days (e.g. 12.86 instead of 13). + # French legal rule: paid leaves are counted in company working days + # (jours ouvrables) as full days. + # Company works 6 days/week (Mon-Sat), 9-12 + 13-17 = 7h/day. + company_calendar = self.env['resource.calendar'].create({ + 'name': 'Company Calendar 6d/week', + 'company_id': self.company.id, + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, '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': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Saturday Morning', 'dayofweek': '5', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Saturday Afternoon', 'dayofweek': '5', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + # Employee works Mon, Tue, Thu (part-time), 8-12 + 13-16 = 7h/day but + # with shifted hours vs the company, so the boundary day is fractional + # under the old hourly-ratio computation. + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar Part Time', + 'company_id': self.company.id, + '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': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = company_calendar + self._set_employee_calendar(employee_calendar) + self.employee.tz = 'Europe/Paris' + + # Leave from 12/01/2026 (Monday) to 26/01/2026 (Monday) inclusive. + # Period: 15 calendar days, 2 Sundays -> 13 company working days. + # Dates must be in the past: default_get sets date_to to today if + # request_date_to is not in defaults, and the SQL constraint + # CHECK(date_from <= date_to) fails if date_from > today during + # the intermediate flush in _compute_date_from_to. + leave = self.env['hr.leave'].create({ + 'name': 'Test part-time bug', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'request_date_from': '2026-01-12', + 'request_date_to': '2026-01-26', + 'employee_company_id': self.company.id, + 'resource_calendar_id': self.employee.resource_calendar_id.id, + }) + leave._compute_date_from_to() + self.assertEqual(leave.number_of_days, 13.0, + 'The number of days should be 13 (jours ouvrables), not 12.86.') + leave.unlink() + + def test_part_time_half_day_different_hours(self): + # Regression test for French part-time half-day leave counting. + # Employee works: Mon, Tue, Wed(AM only), Thu, Fri(AM only). + # Company works 6 days/week (Mon-Sat), 9-17h. + # French rule: count company working days (jours ouvrables) from + # first day of absence to return day (exclusive). + company_calendar = self.env['resource.calendar'].create({ + 'name': 'Company Calendar 6d/week', + 'company_id': self.company.id, + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, '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': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Saturday Morning', 'dayofweek': '5', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Saturday Afternoon', 'dayofweek': '5', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar Part Time', + 'company_id': self.company.id, + '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': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + ], + }) + self.company.resource_calendar_id = company_calendar + self._set_employee_calendar(employee_calendar) + self.employee.tz = 'Europe/Paris' + + # Case 1: Wednesday AM half-day. + # Employee doesn't work Wed PM -> return = Thu. + # Company working days from Wed to Thu (exclusive) = Wed = 1 day. + leave = self.env['hr.leave'].create({ + 'name': 'Test Wed AM', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'request_date_from': '2021-09-08', + 'request_date_to': '2021-09-08', + 'request_unit_half': True, + 'request_date_from_period': 'am', + 'employee_company_id': self.company.id, + 'resource_calendar_id': self.employee.resource_calendar_id.id, + }) + leave._compute_date_from_to() + self.assertEqual(leave.number_of_days, 1.0, + 'Wed AM half-day should count as 1 jour ouvrable.') + leave.unlink() + + # Case 2: Thursday + Friday full days. + # Return = Monday. Company working days: Thu, Fri, Sat = 3 days. + leave = self.env['hr.leave'].create({ + 'name': 'Test Thu+Fri', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'request_date_from': '2021-09-09', + 'request_date_to': '2021-09-10', + 'employee_company_id': self.company.id, + 'resource_calendar_id': self.employee.resource_calendar_id.id, + }) + leave._compute_date_from_to() + self.assertEqual(leave.number_of_days, 3.0, + 'Thu+Fri full days should count as 3 jours ouvrables (Thu, Fri, Sat).') + leave.unlink() + + # Case 3: Friday AM half-day. + # Employee doesn't work Fri PM -> return = Monday. + # Company working days from Fri to Mon (exclusive) = Fri, Sat = 2 days. + leave = self.env['hr.leave'].create({ + 'name': 'Test Fri AM', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'request_date_from': '2021-09-10', + 'request_date_to': '2021-09-10', + 'request_unit_half': True, + 'request_date_from_period': 'am', + 'employee_company_id': self.company.id, + 'resource_calendar_id': self.employee.resource_calendar_id.id, + }) + leave._compute_date_from_to() + self.assertEqual(leave.number_of_days, 2.0, + 'Fri AM half-day should count as 2 jours ouvrables (Fri, Sat).') + leave.unlink() + + def test_public_holiday_exclusion(self): + # Regression test: public holidays (global leaves on the company + # calendar) must be excluded from the jours ouvrables count. + # Company works 6 days/week (Mon-Sat). Public holiday on 14/07/2020. + # Leave 13/07/2020 (Mon) -> 19/07/2020 (Sun). + # Company working days without holiday: 13, 14, 15, 16, 17, 18 = 6 + # With 14/07 being a public holiday: 13, 15, 16, 17, 18 = 5 + company_calendar = self.env['resource.calendar'].create({ + 'name': 'Company Calendar 6d/week', + 'company_id': self.company.id, + 'tz': 'Europe/Paris', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, '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': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (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': 9, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Saturday Morning', 'dayofweek': '5', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Saturday Afternoon', 'dayofweek': '5', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + employee_calendar = self.env['resource.calendar'].create({ + 'name': 'Employee Calendar Part Time', + 'company_id': self.company.id, + 'tz': 'Europe/Paris', + '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': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + ], + }) + self.company.resource_calendar_id = company_calendar + self._set_employee_calendar(employee_calendar) + self.employee.tz = 'Europe/Paris' + + # Create a public holiday on 14/07/2020 (Tuesday) on the company calendar + self.env['resource.calendar.leaves'].create({ + 'name': 'Bastille Day', + 'calendar_id': company_calendar.id, + 'date_from': '2020-07-14 00:00:00', + 'date_to': '2020-07-14 23:59:59', + }) + + # Leave from 13/07/2020 (Monday) to 19/07/2020 (Sunday). + # Employee works Mon, Tue, Thu, Fri. Leave covers Mon-Sun. + # Return = Monday 20/07. date_to extended to Sunday 19/07 (non-working). + # Company working days (Mon-Sat) excluding 14/07 holiday: + # 13(Mon), 15(Wed), 16(Thu), 17(Fri), 18(Sat) = 5 days + leave = self.env['hr.leave'].create({ + 'name': 'Test public holiday', + 'holiday_status_id': self.time_off_type.id, + 'employee_id': self.employee.id, + 'request_date_from': '2020-07-13', + 'request_date_to': '2020-07-19', + 'employee_company_id': self.company.id, + 'resource_calendar_id': self.employee.resource_calendar_id.id, + }) + leave._compute_date_from_to() + self.assertEqual(leave.number_of_days, 5.0, + 'The number of days should be 5 (14/07 excluded as public holiday).') + leave.unlink()