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): 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 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': 1, '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): 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): # 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 ) # monday of last week timesheet_sheet = self._create_timesheet_sheet(start_date) 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",) # 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.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) # monday of last week timesheet_sheet = self._create_timesheet_sheet(start_date) 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.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({ '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.employee.resource_calendar_id = part_time_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, 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({ '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 one contract ending on wednesday and one other starting on thursday self.env['hr.contract'].create({ 'name': 'Contract 1', 'employee_id': self.employee.id, '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', }) self.env['hr.contract'].create({ 'name': 'Contract 2', 'employee_id': self.employee.id, 'state': 'open', '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) # monday of last week # create a timesheet with period including the change of contract timesheet_sheet = self._create_timesheet_sheet(start_date) # the creation of the recovery allocation should raise an error with self.assertRaises(UserError): timesheet_sheet.action_generate_recovery_allocation() def test_recovery_hours_change_contract_sucess(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'}), ], }) #create one contract ending on wednesday and one other starting on thursday self.env['hr.contract'].create({ 'name': 'Contract 1', 'employee_id': self.employee.id, 'date_start': date(2025,8,18), 'date_end': date(2025,8,24), 'resource_calendar_id': self.base_calendar.id, 'wage': 2000, 'state': 'close', }) self.env['hr.contract'].create({ 'name': 'Contract 2', 'employee_id': self.employee.id, 'state': 'open', 'date_start': date(2025,8,25), 'date_end': date(2025,8,31), 'resource_calendar_id': part_time_calendar.id, 'wage': 1500, }) self.employee.resource_calendar_id = part_time_calendar.id #create a timesheet with period including the change of contract timesheet_sheet_1 = self._create_timesheet_sheet(date(2025,8,18)) timesheet_sheet_2 = self._create_timesheet_sheet(date(2025,8,25)) timesheet_sheet_1.action_generate_recovery_allocation() timesheet_sheet_2.action_generate_recovery_allocation() 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") 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")