16.0-fix-public-holidays #14

Open
laetitiadacosta wants to merge 5 commits from 16.0-fix-public-holidays into 16.0
3 changed files with 131 additions and 63 deletions
Showing only changes of commit 958d0c4118 - Show all commits

View File

@@ -1,7 +1,9 @@
import logging import logging
import pytz
from odoo import api, fields, models from odoo import api, fields, models
from datetime import timedelta from datetime import timedelta
from pytz import utc
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -162,7 +164,40 @@ class HrEmployeeStats(models.Model):
stat.is_public_holiday = False stat.is_public_holiday = False
continue continue
stat.dayofweek = int(stat.date.strftime("%u")) - 1 stat.dayofweek = int(stat.date.strftime("%u")) - 1
stat.is_public_holiday = bool(stat.sheet_id.employee_id._get_public_holidays(stat.date, stat.date - timedelta(days=1))) stat.is_public_holiday = stat._is_public_holiday_accordig_to_employe_tz()
def _convert_to_employee_tz(self, date):
"""Convert a UTC datetime to the employee's timezone datetime."""
self.ensure_one()
if not date:
return None
employee_tz = pytz.timezone(self.employee_id.tz or "UTC")
if date.tzinfo is None:
dt = pytz.utc.localize(date)
return dt.astimezone(employee_tz)
def _is_public_holiday_accordig_to_employe_tz(self):
self.ensure_one()
if not self.date or not self.employee_id:
return False
#get public holidays for the employee
public_holidays = self.employee_id._get_public_holidays(
self.date, self.date
)
if not public_holidays:
return False
ph = public_holidays[0]
# Convert public holiday to the employee timezone
ph_datetime_from_tz = self._convert_to_employee_tz(ph.date_from)
ph_datetime_to_tz = self._convert_to_employee_tz(ph.date_to)
# Convert datetime to date
ph_date_from = ph_datetime_from_tz.date()
ph_date_to = ph_datetime_to_tz.date()
# Check if the stat date falls within the public holiday range after conversion in employee tz
if ph_date_from <= self.date <= ph_date_to:
return True
else:
return False
def _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours): def _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours):
self.ensure_one() self.ensure_one()

View File

@@ -110,8 +110,8 @@ class HrTimesheetSheet(models.Model):
("state", "in", ("open", "close")), ("state", "in", ("open", "close")),
("date_start", "<=", self.date_end), ("date_start", "<=", self.date_end),
"|", "|",
("date_end", "=", False), # pas de date de fin OU ("date_end", "=", False), # no end date OR
("date_end", ">=", self.date_start), # date de fin après le début ("date_end", ">=", self.date_start), # end date after start
], ],
) )
return contracts return contracts

View File

@@ -1,7 +1,9 @@
from odoo.tests.common import TransactionCase from datetime import date, datetime, timedelta
from odoo.tests import tagged
from datetime import date, timedelta
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.fields import Date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
class TestHrEmployeeStatsRecovery(TransactionCase): class TestHrEmployeeStatsRecovery(TransactionCase):
@@ -19,6 +21,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
self.employee = self.env['hr.employee'].create({ self.employee = self.env['hr.employee'].create({
'name': 'Camille', 'name': 'Camille',
'user_id': self.user.id, 'user_id': self.user.id,
'tz': 'Europe/Paris',
}) })
self.base_calendar = self.env['resource.calendar'].create({ self.base_calendar = self.env['resource.calendar'].create({
'name': 'Default Calendar', 'name': 'Default Calendar',
@@ -35,11 +38,13 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
], ],
}) })
self.base_calendar.hours_per_day = 7
self.employee.resource_calendar_id = self.base_calendar self.employee.resource_calendar_id = self.base_calendar
self.env.company.recovery_type_id = self.recovery_type
self.env.company.coef = 25 self.env.company.coef = 25
def _create_timesheet_sheet(self, start_date): def _create_timesheet_sheet(self, start_date):
#Crée une feuille de temps pour la semaine du lundi au dimanche # Create a timesheet for the week (Monday to Sunday)
return self.env['hr_timesheet.sheet'].create({ return self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date_start': start_date, 'date_start': start_date,
@@ -47,7 +52,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
}) })
def _create_stats(self, start_date, nb_days, unit_amount): def _create_stats(self, start_date, nb_days, unit_amount):
# Crée des temps du lundi au vendredi (ou nb_days) # Create timesheet lines from Monday to Friday (or nb_days)
for i in range(nb_days): for i in range(nb_days):
self.env['account.analytic.line'].create({ self.env['account.analytic.line'].create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
@@ -56,16 +61,19 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
'account_id': 1, 'account_id': 1,
'name': 'Work Entry', 'name': 'Work Entry',
}) })
# Génère les hr_employee_stats pour chaque jour de la période # Generate hr.employee.stats for each day of the period
stat = self.env['hr.employee.stats'].create({ stat = self.env['hr.employee.stats'].create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date': start_date + timedelta(days=i), 'date': start_date + timedelta(days=i),
}) })
stat._compute_dayofweek()
stat._compute_hours() stat._compute_hours()
yield stat yield stat
def test_invalide_recovery_type(self): def test_invalide_recovery_type(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière start_date = date.today() - timedelta(
days=date.today().weekday() + 7
) # monday of last week
self.recovery_type.request_unit = 'day' self.recovery_type.request_unit = 'day'
self.recovery_type.allows_negative = False self.recovery_type.allows_negative = False
timesheet_sheet = self._create_timesheet_sheet(start_date) timesheet_sheet = self._create_timesheet_sheet(start_date)
@@ -74,56 +82,58 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
def test_no_recovery_hours(self): def test_no_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
timesheet_sheet = self._create_timesheet_sheet(start_date) timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 5, 7): #créer 5 stats de 7h chacune for stat in self._create_stats(start_date, 5, 7): # create 5 stats of 7h each
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour # Compare calculated recovery hours and the calendar which plans 7h per day
self.assertEqual(stat.total_hours, 7, "total_hours should be 7",) # l'employé a travaillé 7h chaque jour self.assertEqual(stat.total_hours, 7, "total_hours should be 7",) # the employee worked 7h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, 0, "gap_hours should be 0",) # pas de différence entre les heures travaillées et les heures planifiées self.assertEqual(stat.gap_hours, 0, "gap_hours should be 0",) # no difference between worked and planned hours
# La feuille de temps sur cette période doit compter 0h de déficit et 0h de récupération # The timesheet for this period should count 0 gap hours and 0 recovery hours
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 0, "timesheet_sheet_gap_hours should be 0",) self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 0, "timesheet_sheet_gap_hours should be 0",)
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 0, "timesheet_sheet_recovery_hours should be 0",) self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 0, "timesheet_sheet_recovery_hours should be 0",)
def test_positive_recovery_hours(self): def test_positive_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière start_date = date.today() - timedelta(
days=date.today().weekday() + 7
) # monday of last week
timesheet_sheet = self._create_timesheet_sheet(start_date) timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 5, 8): #créer 5 stats de 8h chacune for stat in self._create_stats(start_date, 5, 8): # create 5 stats of 8h each
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour # Compare calculated recovery hours and the calendar which plans 7h per day
self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 8h chaque jour self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # the employee worked 8h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de plus que prévu self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # the employee worked one hour more than planned
# La feuille de temps doit compter 5h d'heure sup soit 6,25h de récupération avec la majoration de 25% # The timesheet should count 5 overtime hours = 6.25h recovery with 25% coefficient
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 5, "timesheet_sheet_gap_hours should be 5",) self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 5, "timesheet_sheet_gap_hours should be 5",)
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 6.25, "timesheet_sheet_recovery_hours should be 6,25",) self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 6.25, "timesheet_sheet_recovery_hours should be 6,25",)
# générer l'allocation de récupération depuis la feuille de temps # generate the recovery allocation from the timesheet
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)]) recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
self.assertEqual(recovery_allocation.number_of_days,0.78125, "The recovery allocation should be for 6.25h/8h = 0.78125 day") self.assertEqual(recovery_allocation.number_of_days,0.8928571428571429, "The recovery allocation should be for 6.25h/7h = 0.8928571428571429 day")
def test_negative_recovery_hours(self): def test_negative_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
timesheet_sheet = self._create_timesheet_sheet(start_date) timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 5, 6): #créer 5 stats de 6h chacune for stat in self._create_stats(start_date, 5, 6): # create 5 stats of 6h each
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour # Compare calculated recovery hours and the calendar which plans 7h per day
self.assertEqual(stat.total_hours, 6, "total_hours should be 6",) # l'employé a travaillé 6h chaque jour self.assertEqual(stat.total_hours, 6, "total_hours should be 6",) # the employee worked 6h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # l'employée a travaillé une heure de moins que prévu self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # the employee worked one hour less than planned
# La feuille de temps doit compter -5h de déficit et -5h de récupération # The timesheet should count -5 gap hours and -5 recovery hours
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, -5, "timesheet_sheet_gap_hours should be -5",) # l'employé a travaillé -5h au total sur la semaine self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, -5, "timesheet_sheet_gap_hours should be -5",) # total gap hours for the week
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, -5, "timesheet_sheet_recovery_hours should be -5",) # -5h sera le montant de l'allocation de récupération (pas de coef appliqué pour les déficites d'heures) self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, -5, "timesheet_sheet_recovery_hours should be -5",) # -5h will be the recovery allocation (no coef applied for deficits)
# générer l'allocation de récupération depuis la feuille de temps # generate the recovery allocation from the timesheet
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)]) recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
self.assertEqual(recovery_allocation.number_of_days, -0.625, "The recovery allocation should be for -5/8 hours = ") self.assertEqual(recovery_allocation.number_of_days, -0.7142857142857143, "The recovery allocation should be for -5/7 hours = 0.7142857142857143 days")
def test_recovery_hours_part_time_employee(self): def test_recovery_hours_part_time_employee(self):
part_time_calendar = self.env['resource.calendar'].create({ part_time_calendar = self.env['resource.calendar'].create({
@@ -140,19 +150,18 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
], ],
}) })
self.employee.resource_calendar_id = part_time_calendar.id self.employee.resource_calendar_id = part_time_calendar.id
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date) timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 4, 8): #créer 4 stats de 8h chacune for stat in self._create_stats(start_date, 4, 8): # create 4 stats of 8h each
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour pendant 4 jours # Compare calculated recovery hours and the calendar which plans 7h per day for 4 days
self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 6h chaque jour self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # the employee worked 8h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de moins que prévu self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # the employee worked one hour more than planned
# La feuille de temps doit compter 4h d'heure sup soit 5h de récupération avec la majoration de 25% # The timesheet should count 4 overtime hours = 5 recovery hours with 25% coefficient
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",) # l'employé a travaillé supplémentaire 4h au total sur la semaine self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",) # total extra hours for the week
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) # 5h sera le montant de l'allocation de récupération (coef de 25% de majoration) self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) # 5h will be the recovery allocation (25% coef)
def test_recovery_hours_change_contract(self): def test_recovery_hours_change_contract(self):
part_time_calendar = self.env['resource.calendar'].create({ part_time_calendar = self.env['resource.calendar'].create({
@@ -172,8 +181,8 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
self.env['hr.contract'].create({ self.env['hr.contract'].create({
'name': 'Contract 1', 'name': 'Contract 1',
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date_start': date.today() - timedelta(days=300), # date de début factice 'date_start': date.today() - timedelta(days=300), # fake start date
'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # date de fin le mercredi de la semaine dernière 'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # end date: wednesday of last week
'resource_calendar_id': self.base_calendar.id, 'resource_calendar_id': self.base_calendar.id,
'wage': 2000, 'wage': 2000,
'state': 'close', 'state': 'close',
@@ -182,16 +191,16 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
'name': 'Contract 2', 'name': 'Contract 2',
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'state': 'open', 'state': 'open',
'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # date de début le jeudi de la semaine dernière 'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # start date: thursday of last week
'date_end': False, 'date_end': False,
'resource_calendar_id': self.base_calendar.id, 'resource_calendar_id': self.base_calendar.id,
'wage': 1500, 'wage': 1500,
}) })
self.employee.resource_calendar_id = part_time_calendar.id self.employee.resource_calendar_id = part_time_calendar.id
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
#create a timesheet with period including the change of contract # create a timesheet with period including the change of contract
timesheet_sheet = self._create_timesheet_sheet(start_date) timesheet_sheet = self._create_timesheet_sheet(start_date)
#the create of recovery allocation should raise an error # the creation of the recovery allocation should raise an error
with self.assertRaises(UserError): with self.assertRaises(UserError):
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
@@ -242,9 +251,33 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)]) recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
def test_public_holiday(self):
# create a public holiday
self.env["resource.calendar.leaves"].create(
{
"name": "1 mai 2025",
"date_from": datetime(2025,4,30,22,0,0),
"date_to": datetime(2025,5,1,21,0,0),
}
)
#create 5 stats of 7h each including the public holiday on 1st may
stats = self._create_stats(Date.to_date("2025-04-28"), 5, 7)
for stat in stats:
stat._compute_dayofweek()
stat._compute_hours()
#create 1 timesheet sheet from monday to friday including the public holiday on 1st may
timesheet_sheet = self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id,
'date_start': "2025-04-28",
'date_end': "2025-05-04",
})
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 7, "timesheet_sheet_gap_hours should be 7",)
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 8.75, "timesheet_sheet_recovery_hours should be 8,75",)
timesheet_sheet.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)])
self.assertEqual(len(recovery_allocation), 1, "Il doit y avoir une allocation de récupération générée")
self.assertEqual(recovery_allocation.number_of_days,1.25, "The recovery allocation should be 1,25 days")