[WIP]hr_employee_stats_sheet:fix public holidays

This commit is contained in:
2025-11-04 16:00:56 +01:00
parent ee1e1cbe65
commit 20f04d710c
3 changed files with 134 additions and 67 deletions

View File

@@ -1,7 +1,9 @@
import logging import logging
import datetime
from odoo import api, fields, models from odoo import api, fields, models
from datetime import timedelta from datetime import timedelta
from odoo.fields import Date
import pytz
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -162,15 +164,47 @@ 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 _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours): def _convert_to_employee_tz(self, date):
"""Convertit un datetime UTC en datetime dans le fuseau de l'employé."""
self.ensure_one()
if not date:
return None
employee_tz = pytz.timezone(self.sheet_id.employee_id.tz or "UTC")
if date.tzinfo is None:
dt = pytz.utc.localize(date)
return dt.astimezone(employee_tz) - timedelta(minutes=1)
def _is_public_holiday_accordig_to_employe_tz(self):
self.ensure_one()
if not self.date or not self.sheet_id or not self.sheet_id.employee_id:
return False
#get public holidays for the employee
public_holidays = self.sheet_id.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() self.ensure_one()
balance = ( balance = (
total_hours total_hours + total_recovery_hours + total_leave_hours - total_planned_hours
+ total_recovery_hours
+ total_leave_hours
- total_planned_hours
) )
return balance return balance

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

@@ -2,6 +2,7 @@ from odoo.tests.common import TransactionCase
from odoo.tests import tagged from odoo.tests import tagged
from datetime import date, timedelta from datetime import date, timedelta
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.fields import Date
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
class TestHrEmployeeStatsRecovery(TransactionCase): class TestHrEmployeeStatsRecovery(TransactionCase):
@@ -19,6 +20,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',
@@ -36,10 +38,11 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
], ],
}) })
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 sheet 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 +50,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 +59,17 @@ 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,52 +78,52 @@ 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 with 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 with 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.78125, "The recovery allocation should be for 6.25h/8h = 0.78125 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 with 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 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")
@@ -141,18 +145,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) # 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, 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 with 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({
@@ -168,12 +172,12 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
], ],
}) })
#create one contract ending on wednesday and one other starting on thursday # create one contract ending on wednesday and another starting on thursday
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,14 +186,14 @@ 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 a period that includes the contract change
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 create of recovery allocation should raise an error
with self.assertRaises(UserError): with self.assertRaises(UserError):
@@ -239,11 +243,40 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)]) recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)]) def test_public_holiday_detection(self):
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") # Create a public holiday on the Wednesday of the test week and verify detection
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
timesheet_sheet = self._create_timesheet_sheet(start_date)
holiday_date = start_date + timedelta(days=2) # Wednesday
# create a global public holiday (no resource or calendar restriction)
self.env['resource.calendar.leaves'].create({
'name': 'Public Holiday',
'company_id': self.env.company.id,
'date_from': Date.to_string(holiday_date),
'date_to': Date.to_string(holiday_date),
'resource_id': False,
'calendar_id': False,
})
# create a timesheet entry on that day
self.env['account.analytic.line'].create({
'employee_id': self.employee.id,
'date': holiday_date,
'unit_amount': 7,
'account_id': 1,
'name': 'Work Entry',
})
# create the stat for that date and recompute
stat = self.env['hr.employee.stats'].create({
'employee_id': self.employee.id,
'date': holiday_date,
'sheet_id': timesheet_sheet.id,
})
stat._compute_dayofweek()
stat._compute_timesheet_line_ids()
stat._compute_hours()
# The stat must detect the public holiday and planned hours must be zero
self.assertTrue(stat.is_public_holiday, "The day should be detected as a public holiday")
self.assertEqual(stat.total_planned_hours, 0, "total_planned_hours should be 0 on a public holiday")