From 958d0c4118b475a0f98efb0da3c85f30a0726020 Mon Sep 17 00:00:00 2001 From: Laetitia Da Costa Date: Tue, 4 Nov 2025 16:00:56 +0100 Subject: [PATCH] [FIX]hr_employee_stats_sheet:convert public holidays in employee tz --- .../models/hr_employee_stats.py | 37 ++++- .../models/hr_timesheet_sheet.py | 4 +- .../tests/test_employee_stats.py | 153 +++++++++++------- 3 files changed, 131 insertions(+), 63 deletions(-) diff --git a/hr_employee_stats_sheet/models/hr_employee_stats.py b/hr_employee_stats_sheet/models/hr_employee_stats.py index bf1ede0..ae170a2 100644 --- a/hr_employee_stats_sheet/models/hr_employee_stats.py +++ b/hr_employee_stats_sheet/models/hr_employee_stats.py @@ -1,7 +1,9 @@ import logging +import pytz from odoo import api, fields, models from datetime import timedelta +from pytz import utc _logger = logging.getLogger(__name__) @@ -162,8 +164,41 @@ class HrEmployeeStats(models.Model): stat.is_public_holiday = False continue 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): self.ensure_one() balance = ( diff --git a/hr_employee_stats_sheet/models/hr_timesheet_sheet.py b/hr_employee_stats_sheet/models/hr_timesheet_sheet.py index 867a0e1..1b22761 100644 --- a/hr_employee_stats_sheet/models/hr_timesheet_sheet.py +++ b/hr_employee_stats_sheet/models/hr_timesheet_sheet.py @@ -110,8 +110,8 @@ class HrTimesheetSheet(models.Model): ("state", "in", ("open", "close")), ("date_start", "<=", self.date_end), "|", - ("date_end", "=", False), # pas de date de fin OU - ("date_end", ">=", self.date_start), # date de fin après le début + ("date_end", "=", False), # no end date OR + ("date_end", ">=", self.date_start), # end date after start ], ) return contracts diff --git a/hr_employee_stats_sheet/tests/test_employee_stats.py b/hr_employee_stats_sheet/tests/test_employee_stats.py index 2594b3b..4ca14a6 100644 --- a/hr_employee_stats_sheet/tests/test_employee_stats.py +++ b/hr_employee_stats_sheet/tests/test_employee_stats.py @@ -1,7 +1,9 @@ -from odoo.tests.common import TransactionCase -from odoo.tests import tagged -from datetime import date, timedelta +from datetime import date, datetime, timedelta + 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") class TestHrEmployeeStatsRecovery(TransactionCase): @@ -19,6 +21,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase): self.employee = self.env['hr.employee'].create({ 'name': 'Camille', 'user_id': self.user.id, + 'tz': 'Europe/Paris', }) self.base_calendar = self.env['resource.calendar'].create({ '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'}), ], }) + self.base_calendar.hours_per_day = 7 self.employee.resource_calendar_id = self.base_calendar + self.env.company.recovery_type_id = self.recovery_type self.env.company.coef = 25 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({ 'employee_id': self.employee.id, 'date_start': start_date, @@ -47,7 +52,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase): }) 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): self.env['account.analytic.line'].create({ 'employee_id': self.employee.id, @@ -56,16 +61,19 @@ class TestHrEmployeeStatsRecovery(TransactionCase): 'account_id': 1, '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({ 'employee_id': self.employee.id, 'date': start_date + timedelta(days=i), }) + stat._compute_dayofweek() stat._compute_hours() yield stat 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.allows_negative = False timesheet_sheet = self._create_timesheet_sheet(start_date) @@ -74,56 +82,58 @@ class TestHrEmployeeStatsRecovery(TransactionCase): timesheet_sheet.action_generate_recovery_allocation() 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) - for stat in self._create_stats(start_date, 5, 7): #créer 5 stats de 7h chacune - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour - self.assertEqual(stat.total_hours, 7, "total_hours should be 7",) # l'employé a travaillé 7h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - 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_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.gap_hours, 0, "gap_hours should be 0",) # pas de différence entre les heures travaillées et les heures planifiées - # La feuille de temps sur cette période doit compter 0h de déficit et 0h de récupération + for stat in self._create_stats(start_date, 5, 7): # create 5 stats of 7h each + # Compare calculated recovery hours and the calendar which plans 7h per day + 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",) # the calendar plans 7h each day + 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",) # the employee has no recovery on this day + self.assertEqual(stat.gap_hours, 0, "gap_hours should be 0",) # no difference between worked and planned hours + # 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_recovery_hours, 0, "timesheet_sheet_recovery_hours should be 0",) 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) - for stat in self._create_stats(start_date, 5, 8): #créer 5 stats de 8h chacune - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour - self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 8h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - 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_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.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de plus que prévu - # La feuille de temps doit compter 5h d'heure sup soit 6,25h de récupération avec la majoration de 25% + for stat in self._create_stats(start_date, 5, 8): # create 5 stats of 8h each + # Compare calculated recovery hours and the calendar which plans 7h per day + 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",) # the calendar plans 7h each day + 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",) # the employee has no recovery on this day + self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # the employee worked one hour more than planned + # 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_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() 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(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): - 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) - for stat in self._create_stats(start_date, 5, 6): #créer 5 stats de 6h chacune - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour - self.assertEqual(stat.total_hours, 6, "total_hours should be 6",) # l'employé a travaillé 6h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - 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_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.gap_hours, -1, "gap_hours should be -1",) # l'employée a travaillé une heure de moins que prévu - # La feuille de temps doit compter -5h de déficit et -5h de récupération - 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_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) - # générer l'allocation de récupération depuis la feuille de temps + for stat in self._create_stats(start_date, 5, 6): # create 5 stats of 6h each + # Compare calculated recovery hours and the calendar which plans 7h per day + 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",) # the calendar plans 7h each day + 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",) # the employee has no recovery on this day + self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # the employee worked one hour less than planned + # 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",) # total gap hours for the week + 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) + # generate the recovery allocation from the timesheet 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, "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): part_time_calendar = self.env['resource.calendar'].create({ @@ -140,19 +150,18 @@ class TestHrEmployeeStatsRecovery(TransactionCase): ], }) 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 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 - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour pendant 4 jours - self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 6h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - 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_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.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de moins que prévu - # La feuille de temps doit compter 4h d'heure sup soit 5h de récupération avec la majoration de 25% - 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_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) + for stat in self._create_stats(start_date, 4, 8): # create 4 stats of 8h each + # 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",) # the employee worked 8h each day + 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",) # the employee has no leave on this day + 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",) # the employee worked one hour more than planned + # 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",) # total extra hours for the week + 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): part_time_calendar = self.env['resource.calendar'].create({ @@ -172,8 +181,8 @@ class TestHrEmployeeStatsRecovery(TransactionCase): self.env['hr.contract'].create({ 'name': 'Contract 1', 'employee_id': self.employee.id, - 'date_start': date.today() - timedelta(days=300), # date de début factice - 'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # date de fin le mercredi de la semaine dernière + 'date_start': date.today() - timedelta(days=300), # fake start date + 'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # end date: wednesday of last week 'resource_calendar_id': self.base_calendar.id, 'wage': 2000, 'state': 'close', @@ -182,16 +191,16 @@ class TestHrEmployeeStatsRecovery(TransactionCase): 'name': 'Contract 2', 'employee_id': self.employee.id, '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, 'resource_calendar_id': self.base_calendar.id, 'wage': 1500, }) 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 - #create a timesheet with period including the change of contract + start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week + # create a timesheet with period including the change of contract 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): 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)]) 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")