[FIX]l10n_fr_hr_holidays:fix bug in part-time employee duleaves duration calculation

This commit is contained in:
2026-06-25 18:55:22 +02:00
parent 2f73c1b3a7
commit ecba73e832
5 changed files with 352 additions and 26 deletions

View File

@@ -26,7 +26,7 @@ Credits
Contributors Contributors
------------ ------------
* `Elabore <mailto:contacnt@elabore.coop>` * `Elabore <mailto:contact@elabore.coop>`
Funders Funders
------- -------

View File

@@ -3,7 +3,7 @@
{ {
"name": "France - Time Off", "name": "France - Time Off",
"version": "16.0.1.0.1", "version": "16.0.1.1.0",
"category": "Human Resources/Time Off", "category": "Human Resources/Time Off",
"countries": ["fr"], "countries": ["fr"],
"summary": "Management of leaves for part-time workers in France", "summary": "Management of leaves for part-time workers in France",

View File

@@ -175,9 +175,14 @@ class HrLeave(models.Model):
#@overwrite #@overwrite
def _get_number_of_days_batch(self, date_from, date_to, employee_ids): 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, 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, In France, part-time employees' leave is counted in company working days
not only on their own working days. Handles half-day requests and rounds according to French rules. (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) employee = self.env['hr.employee'].browse(employee_ids)
# Force the company in the domain, as we are likely in a compute_sudo context # 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() calendar = self._get_calendar()
result = employee._get_work_days_data_batch(date_from, date_to, calendar=calendar, domain=domain) result = employee._get_work_days_data_batch(date_from, date_to, calendar=calendar, domain=domain)
for employee_id in result: for employee_id in result:
# For non-French context: a half-day leave always counts as 0.5 day if self._l10n_fr_leave_applies():
if self.request_unit_half and result[employee_id]['hours'] > 0 and not 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 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 return result
def _round_to_nearest_half(self, x): def _round_to_nearest_half(self, x):

View File

@@ -1,8 +1,11 @@
# -*- coding:utf-8 -*- # -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from collections import defaultdict from collections import defaultdict
from datetime import datetime, time
from odoo import models
class ResourceCalendar(models.Model): class ResourceCalendar(models.Model):
_inherit = 'resource.calendar' _inherit = 'resource.calendar'
@@ -24,3 +27,16 @@ class ResourceCalendar(models.Model):
for attendance in self.attendance_ids: for attendance in self.attendance_ids:
working_days[attendance.week_type][attendance.dayofweek] = True working_days[attendance.week_type][attendance.dayofweek] = True
return working_days 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}

View File

@@ -15,7 +15,13 @@ class TestFrenchLeaves(TransactionCase):
'country_id': country_fr.id, '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', 'name': 'Camille',
'gender': 'other', 'gender': 'other',
'birthday': '1973-03-29', 'birthday': '1973-03-29',
@@ -32,14 +38,29 @@ class TestFrenchLeaves(TransactionCase):
'l10n_fr_reference_leave_type': cls.time_off_type.id, 'l10n_fr_reference_leave_type': cls.time_off_type.id,
}) })
cls.base_calendar = cls.env['resource.calendar'].create({ def _set_employee_calendar(self, calendar):
'name': 'default 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): def test_no_differences(self):
# Base case that should not have a different behaviour # Base case that should not have a different behaviour
self.company.resource_calendar_id = self.base_calendar 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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -57,6 +78,7 @@ class TestFrenchLeaves(TransactionCase):
def test_end_of_week(self): def test_end_of_week(self):
employee_calendar = self.env['resource.calendar'].create({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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': '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.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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -85,6 +107,7 @@ class TestFrenchLeaves(TransactionCase):
def test_start_of_week(self): def test_start_of_week(self):
employee_calendar = self.env['resource.calendar'].create({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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'}), (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.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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -114,6 +137,7 @@ class TestFrenchLeaves(TransactionCase):
def test_last_day_half(self): def test_last_day_half(self):
employee_calendar = self.env['resource.calendar'].create({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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'}), (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.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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -150,6 +174,7 @@ class TestFrenchLeaves(TransactionCase):
def test_full_time_am_day_half(self): def test_full_time_am_day_half(self):
employee_calendar = self.env['resource.calendar'].create({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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': '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.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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -184,6 +209,7 @@ class TestFrenchLeaves(TransactionCase):
def test_am_day_half(self): def test_am_day_half(self):
employee_calendar = self.env['resource.calendar'].create({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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'}), (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.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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -214,6 +240,7 @@ class TestFrenchLeaves(TransactionCase):
def test_calendar_with_holes(self): def test_calendar_with_holes(self):
employee_calendar = self.env['resource.calendar'].create({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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': '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.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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -242,6 +269,7 @@ class TestFrenchLeaves(TransactionCase):
def test_calendar_end_week_hole(self): def test_calendar_end_week_hole(self):
employee_calendar = self.env['resource.calendar'].create({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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': '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.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({ leave = self.env['hr.leave'].create({
'name': 'Test', 'name': 'Test',
@@ -268,6 +296,7 @@ class TestFrenchLeaves(TransactionCase):
def test_2_weeks_calendar(self): def test_2_weeks_calendar(self):
company_calendar = self.env['resource.calendar'].create({ company_calendar = self.env['resource.calendar'].create({
'name': 'Company Calendar', 'name': 'Company Calendar',
'company_id': self.company.id,
'two_weeks_calendar': True, 'two_weeks_calendar': True,
'attendance_ids': [ '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 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({ employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar', 'name': 'Employee Calendar',
'company_id': self.company.id,
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), (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': '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.company.resource_calendar_id = company_calendar
self.employee.resource_calendar_id = employee_calendar self._set_employee_calendar(employee_calendar)
# Week type 0 # Week type 0
leave = self.env['hr.leave'].create({ leave = self.env['hr.leave'].create({
@@ -351,9 +381,241 @@ class TestFrenchLeaves(TransactionCase):
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'request_date_from': '2021-09-13', 'request_date_from': '2021-09-13',
'request_date_to': '2021-09-22', '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, 'resource_calendar_id': self.employee.resource_calendar_id.id,
}) })
leave._compute_date_from_to() leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.') self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.')
leave.unlink() 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()