582 lines
35 KiB
Python
582 lines
35 KiB
Python
from odoo.tests.common import TransactionCase
|
||
from odoo.tests import tagged
|
||
from datetime import date, timedelta, datetime
|
||
from odoo.exceptions import UserError
|
||
from odoo.fields import Date
|
||
|
||
@tagged("post_install", "-at_install")
|
||
class TestHrEmployeeStatsRecovery(TransactionCase):
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.user = self.env['res.users'].create({
|
||
'name': 'Camille',
|
||
'login': 'camille',
|
||
})
|
||
self.recovery_type = self.env['hr.leave.type'].create({
|
||
'name': 'Recovery',
|
||
'request_unit': 'hour',
|
||
'allows_negative': True,
|
||
})
|
||
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',
|
||
'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'}),
|
||
],
|
||
})
|
||
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
|
||
analytic_plan = self.env['account.analytic.plan'].create({
|
||
'name': 'Test Plan',
|
||
})
|
||
self.analytic_account = self.env['account.analytic.account'].create({
|
||
'name': 'Test Analytic Account',
|
||
'plan_id': analytic_plan.id,
|
||
})
|
||
|
||
def _create_timesheet_sheet(self, start_date):
|
||
# 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,
|
||
'date_end': start_date + timedelta(days=6),
|
||
})
|
||
|
||
def _create_stats(self, start_date, nb_days, unit_amount):
|
||
# 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,
|
||
'date': start_date + timedelta(days=i),
|
||
'unit_amount': unit_amount,
|
||
'account_id': self.analytic_account.id,
|
||
'name': 'Work Entry',
|
||
})
|
||
# 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) # monday of last week
|
||
self.recovery_type.request_unit = 'day'
|
||
self.recovery_type.allows_negative = False
|
||
timesheet_sheet = self._create_timesheet_sheet(start_date)
|
||
self.env.company.recovery_type_id = self.recovery_type
|
||
with self.assertRaises(UserError):
|
||
timesheet_sheet.action_generate_recovery_allocation()
|
||
|
||
def test_no_recovery_hours(self):
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-01-01"),
|
||
'date_end': None,
|
||
'calendar_id': self.base_calendar.id,
|
||
})
|
||
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
|
||
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):
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-01-01"),
|
||
'date_end': None,
|
||
'calendar_id': self.base_calendar.id,
|
||
})
|
||
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
|
||
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%
|
||
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
|
||
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.8928571428571429, "The recovery allocation should be for 6.25h/7h = 0.8928571428571429 day")
|
||
|
||
def test_negative_recovery_hours(self):
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-01-01"),
|
||
'date_end': None,
|
||
'calendar_id': self.base_calendar.id,
|
||
})
|
||
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
|
||
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
|
||
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.7142857142857143, "The recovery allocation should be for -5/7 hours = −0,714285714")
|
||
|
||
def test_recovery_hours_part_time_employee(self):
|
||
part_time_calendar = self.env['resource.calendar'].create({
|
||
'name': 'Part Time Calendar',
|
||
'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'}),
|
||
],
|
||
})
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-01-01"),
|
||
'date_end': None,
|
||
'calendar_id': part_time_calendar.id,
|
||
})
|
||
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
|
||
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)
|
||
|
||
def test_recovery_hours_change_calendar(self):
|
||
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday)
|
||
employee_part_time_calendar = self.env['resource.calendar'].create({
|
||
'name': 'Part Time Calendar',
|
||
'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'}),
|
||
],
|
||
})
|
||
|
||
#create two hr.employee.calendar to change calendar during the timesheet period
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2023-07-01"),
|
||
'date_end': Date.to_date("2025-07-31"),
|
||
'calendar_id': employee_full_time_calendar.id,
|
||
})
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-08-01"),
|
||
'date_end': None,
|
||
'calendar_id': employee_part_time_calendar.id,
|
||
})
|
||
self.employee.resource_calendar_id = employee_part_time_calendar.id
|
||
|
||
#create recovery hours on a period including the change of calendar
|
||
timesheet_sheet = self._create_timesheet_sheet(Date.to_date("2025-07-28")) #a week including the change of calendar on 1st august
|
||
#the create of recovery allocation should raise an error
|
||
with self.assertRaises(UserError):
|
||
timesheet_sheet.action_generate_recovery_allocation()
|
||
|
||
def test_recovery_hours_change_calendar_sucess(self):
|
||
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday)
|
||
employee_part_time_calendar = self.env['resource.calendar'].create({
|
||
'name': 'Part Time Calendar',
|
||
'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'}),
|
||
],
|
||
})
|
||
|
||
#create two hr.employee.calendar to change calendar during the timesheet period
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2023-07-01"),
|
||
'date_end': Date.to_date("2025-07-31"),
|
||
'calendar_id': employee_full_time_calendar.id,
|
||
})
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-08-01"),
|
||
'date_end': None,
|
||
'calendar_id': employee_part_time_calendar.id,
|
||
})
|
||
self.employee.resource_calendar_id = employee_part_time_calendar.id
|
||
|
||
#create stats during period of full time calendar for the employee
|
||
timesheet_sheet = self.env['hr_timesheet.sheet'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': "2025-07-07",
|
||
'date_end': "2025-07-13",
|
||
})
|
||
stats = self._create_stats(Date.to_date("2025-07-07"), 5, 7)
|
||
for stat in stats:
|
||
stat._compute_dayofweek()
|
||
stat._compute_hours()
|
||
timesheet_sheet.action_generate_recovery_allocation()
|
||
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",)
|
||
|
||
#create stats during period of part time calendar for the employee
|
||
stats = self._create_stats(Date.to_date("2025-09-08"), 5, 7)
|
||
for stat in stats:
|
||
stat._compute_dayofweek()
|
||
stat._compute_hours()
|
||
timesheet_sheet = self.env['hr_timesheet.sheet'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': "2025-09-08",
|
||
'date_end': "2025-09-14",
|
||
})
|
||
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",)
|
||
|
||
def test_recovery_allocation_doesnt_change_with_of_calendar(self):
|
||
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday)
|
||
employee_full_time_calendar.hours_per_day = 7
|
||
employee_part_time_calendar = self.env['resource.calendar'].create({
|
||
'name': 'Part Time Calendar',
|
||
'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': 19, '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': 19, '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': 19, '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': 19, 'day_period': 'afternoon'}),
|
||
],
|
||
})
|
||
employee_part_time_calendar.hours_per_day = 9
|
||
|
||
#create two hr.employee.calendar to change calendar during the timesheet period
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2023-07-01"),
|
||
'date_end': Date.to_date("2025-07-31"),
|
||
'calendar_id': employee_full_time_calendar.id,
|
||
})
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-08-01"),
|
||
'date_end': None,
|
||
'calendar_id': employee_part_time_calendar.id,
|
||
})
|
||
self.employee.resource_calendar_id = employee_part_time_calendar.id
|
||
|
||
#create stats during period of full time calendar for the employee
|
||
timesheet_sheet_1 = self.env['hr_timesheet.sheet'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': "2025-07-07",
|
||
'date_end': "2025-07-13",
|
||
})
|
||
stats = self._create_stats(Date.to_date("2025-07-07"), 5, 8)
|
||
for stat in stats:
|
||
stat._compute_dayofweek()
|
||
stat._compute_hours()
|
||
timesheet_sheet_1.action_generate_recovery_allocation()
|
||
self.assertEqual(timesheet_sheet_1.timesheet_sheet_gap_hours, 5, "timesheet_sheet_gap_hours should be 0",)
|
||
self.assertEqual(timesheet_sheet_1.timesheet_sheet_recovery_hours, 6.25, "timesheet_sheet_recovery_hours should be 6,25",)
|
||
allocation_1 = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)])
|
||
self.assertEqual(len(allocation_1), 1, "There should be one recovery")
|
||
self.assertEqual(allocation_1.number_of_days,0.8928571428571429, "The recovery allocation should be for 0.8928571428571429 day")
|
||
|
||
#create stats during period of part time calendar for the employee
|
||
# generation 4 stats of 10h each, the employee is supposed to work 9h per day during 4 days
|
||
stats_2 = self._create_stats(Date.to_date("2025-09-08"), 4, 10)
|
||
for stat in stats_2:
|
||
stat._compute_dayofweek()
|
||
stat._compute_hours()
|
||
timesheet_sheet_2 = self.env['hr_timesheet.sheet'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': "2025-09-08",
|
||
'date_end': "2025-09-14",
|
||
})
|
||
timesheet_sheet_2.action_generate_recovery_allocation()
|
||
self.assertEqual(timesheet_sheet_2.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",)
|
||
self.assertEqual(timesheet_sheet_2.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",)
|
||
allocation_2 = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)])
|
||
self.assertEqual(len(allocation_2), 1, "There should be one recovery")
|
||
self.assertEqual(allocation_2.number_of_days,0.5555555555555556, "The recovery allocation should be for 0,555555556 (5/9) day")
|
||
#check that allocation_1 hasn't changed
|
||
self.assertEqual(allocation_1.number_of_days,0.8928571428571429, "The recovery allocation should be for 0,892857143 day")
|
||
|
||
def test_public_holiday(self):
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-01-01"),
|
||
'date_end': None,
|
||
'calendar_id': self.base_calendar.id,
|
||
})
|
||
# create a public holiday :
|
||
# When you create holidays graphically with a TZ,
|
||
# they are saved in the database after conversion to UTC.
|
||
# This is why, for a holiday starting on May 1, 2025, at 00:00:00 UTC+2,
|
||
# it will be saved in the database as April 30, 2025, at 22:00:00.
|
||
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",
|
||
})
|
||
# the employee has worked 7h on first may (public holiday) instead of 0h
|
||
# so the gap hours should be 7h and recovery hours 8,75h with coef 25%
|
||
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")
|
||
|
||
|
||
@tagged("post_install", "-at_install")
|
||
class TestHrEmployeeStatsLeaveAndRecoveryUnit(TransactionCase):
|
||
"""Check that _get_total_recovery_hours() and _get_total_leave_hours()
|
||
work whatever the request_unit of the leave type
|
||
('hour', 'half_day' or 'day'), both for a single-day request and for a
|
||
multi-day request."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.employee = self.env['hr.employee'].create({
|
||
'name': 'Dominique',
|
||
'tz': 'Europe/Paris',
|
||
})
|
||
# Full-time calendar: Monday to Friday, 9am-12pm and 1pm-5pm (7h/day).
|
||
# The calendar timezone must match the employee's so that hour-based
|
||
# requests fall exactly on the working hours.
|
||
self.base_calendar = self.env['resource.calendar'].create({
|
||
'name': 'Default Calendar',
|
||
'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'}),
|
||
],
|
||
})
|
||
self.base_calendar.hours_per_day = 7
|
||
self.employee.resource_calendar_id = self.base_calendar
|
||
# hr.employee.calendar used by _get_total_planned_hours() (multi-day case)
|
||
self.env['hr.employee.calendar'].create({
|
||
'employee_id': self.employee.id,
|
||
'date_start': Date.to_date("2025-01-01"),
|
||
'date_end': None,
|
||
'calendar_id': self.base_calendar.id,
|
||
})
|
||
# Company recovery type (used by _get_total_recovery_hours)
|
||
self.recovery_type = self.env['hr.leave.type'].create({
|
||
'name': 'Recovery',
|
||
'request_unit': 'hour',
|
||
'requires_allocation': 'no',
|
||
'allows_negative': True,
|
||
'leave_validation_type': 'no_validation',
|
||
})
|
||
self.env.company.recovery_type_id = self.recovery_type
|
||
# Monday and Tuesday with no public holiday over the period
|
||
self.monday = Date.to_date("2025-03-03")
|
||
self.tuesday = Date.to_date("2025-03-04")
|
||
|
||
# --- Helpers ------------------------------------------------------------
|
||
|
||
def _create_leave_type(self, request_unit):
|
||
return self.env['hr.leave.type'].create({
|
||
'name': 'Leave %s' % request_unit,
|
||
'request_unit': request_unit,
|
||
'requires_allocation': 'no',
|
||
'allows_negative': True,
|
||
'leave_validation_type': 'no_validation',
|
||
})
|
||
|
||
def _create_leave(self, leave_type, date_from, date_to, half=False,
|
||
period='am', hours=False, hour_from=None, hour_to=None):
|
||
vals = {
|
||
'name': 'Leave',
|
||
'employee_id': self.employee.id,
|
||
'holiday_status_id': leave_type.id,
|
||
'request_date_from': date_from,
|
||
'request_date_to': date_to,
|
||
}
|
||
if half:
|
||
vals['request_unit_half'] = True
|
||
vals['request_date_from_period'] = period
|
||
if hours:
|
||
vals['request_unit_hours'] = True
|
||
vals['request_hour_from'] = hour_from
|
||
vals['request_hour_to'] = hour_to
|
||
leave = self.env['hr.leave'].create(vals)
|
||
leave._compute_date_from_to()
|
||
leave.action_validate()
|
||
return leave
|
||
|
||
def _create_stat(self, day):
|
||
stat = self.env['hr.employee.stats'].create({
|
||
'employee_id': self.employee.id,
|
||
'date': day,
|
||
})
|
||
stat._compute_dayofweek()
|
||
stat._compute_hours()
|
||
return stat
|
||
|
||
# --- _get_total_recovery_hours -----------------------------------------
|
||
|
||
def test_recovery_hours_unit_day_single_day(self):
|
||
# 'day' type, a recovery taken on Monday only
|
||
self.recovery_type.request_unit = 'day'
|
||
self._create_leave(self.recovery_type, self.monday, self.monday)
|
||
stat = self._create_stat(self.monday)
|
||
# single-day request: Odoo already computed 7h from the calendar
|
||
self.assertEqual(stat.total_recovery_hours, 7, "total_recovery_hours should be 7")
|
||
|
||
def test_recovery_hours_unit_half_day(self):
|
||
# 'half_day' type, recovery taken on Monday morning (9am-12pm = 3h)
|
||
self.recovery_type.request_unit = 'half_day'
|
||
self._create_leave(self.recovery_type, self.monday, self.monday, half=True, period='am')
|
||
stat = self._create_stat(self.monday)
|
||
self.assertEqual(stat.total_recovery_hours, 3, "total_recovery_hours should be 3 (morning 9am-12pm)")
|
||
|
||
def test_recovery_hours_unit_hour(self):
|
||
# 'hour' type, recovery taken from 9am to 12pm (3h)
|
||
self.recovery_type.request_unit = 'hour'
|
||
self._create_leave(self.recovery_type, self.monday, self.monday, hours=True, hour_from='9', hour_to='12')
|
||
stat = self._create_stat(self.monday)
|
||
self.assertEqual(stat.total_recovery_hours, 3, "total_recovery_hours should be 3 (9am-12pm)")
|
||
|
||
def test_recovery_hours_unit_day_several_days(self):
|
||
# 'day' type, recovery taken on Monday AND Tuesday: for each day we
|
||
# fall back to the planned hours of THAT specific day (7h)
|
||
self.recovery_type.request_unit = 'day'
|
||
self._create_leave(self.recovery_type, self.monday, self.tuesday)
|
||
monday_stat = self._create_stat(self.monday)
|
||
tuesday_stat = self._create_stat(self.tuesday)
|
||
self.assertEqual(monday_stat.total_recovery_hours, 7, "total_recovery_hours should be 7 on monday")
|
||
self.assertEqual(tuesday_stat.total_recovery_hours, 7, "total_recovery_hours should be 7 on tuesday")
|
||
|
||
# --- _get_total_leave_hours --------------------------------------------
|
||
|
||
def test_leave_hours_unit_day_single_day(self):
|
||
leave_type = self._create_leave_type('day')
|
||
self._create_leave(leave_type, self.monday, self.monday)
|
||
stat = self._create_stat(self.monday)
|
||
self.assertEqual(stat.total_leave_hours, 7, "total_leave_hours should be 7")
|
||
|
||
def test_leave_hours_unit_half_day(self):
|
||
leave_type = self._create_leave_type('half_day')
|
||
self._create_leave(leave_type, self.monday, self.monday, half=True, period='am')
|
||
stat = self._create_stat(self.monday)
|
||
self.assertEqual(stat.total_leave_hours, 3, "total_leave_hours should be 3 (morning 9am-12pm)")
|
||
|
||
def test_leave_hours_unit_hour(self):
|
||
leave_type = self._create_leave_type('hour')
|
||
self._create_leave(leave_type, self.monday, self.monday, hours=True, hour_from='9', hour_to='12')
|
||
stat = self._create_stat(self.monday)
|
||
self.assertEqual(stat.total_leave_hours, 3, "total_leave_hours should be 3 (9am-12pm)")
|
||
|
||
def test_leave_hours_unit_day_several_days(self):
|
||
leave_type = self._create_leave_type('day')
|
||
self._create_leave(leave_type, self.monday, self.tuesday)
|
||
monday_stat = self._create_stat(self.monday)
|
||
tuesday_stat = self._create_stat(self.tuesday)
|
||
self.assertEqual(monday_stat.total_leave_hours, 7, "total_leave_hours should be 7 on monday")
|
||
self.assertEqual(tuesday_stat.total_leave_hours, 7, "total_leave_hours should be 7 on tuesday")
|
||
|
||
def test_leave_hours_ignore_non_validated_leave(self):
|
||
# _get_total_leave_hours only counts validated and active leaves
|
||
leave_type = self._create_leave_type('day')
|
||
leave = self.env['hr.leave'].create({
|
||
'name': 'Draft leave',
|
||
'employee_id': self.employee.id,
|
||
'holiday_status_id': leave_type.id,
|
||
'request_date_from': self.monday,
|
||
'request_date_to': self.monday,
|
||
})
|
||
leave._compute_date_from_to() # left unvalidated
|
||
stat = self._create_stat(self.monday)
|
||
self.assertEqual(stat.total_leave_hours, 0, "an unvalidated leave must not be counted")
|
||
|
||
def test_leave_hours_excludes_recovery_type(self):
|
||
# a "recovery" type leave must not be counted as a regular leave
|
||
self.recovery_type.request_unit = 'day'
|
||
self._create_leave(self.recovery_type, self.monday, self.monday)
|
||
stat = self._create_stat(self.monday)
|
||
self.assertEqual(stat.total_leave_hours, 0, "recovery must not count as a leave")
|
||
self.assertEqual(stat.total_recovery_hours, 7, "it must count as a recovery")
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|