From 2f73c1b3a7e1ab16e34aefa03dcd36d1a57c15a3 Mon Sep 17 00:00:00 2001 From: Laetitia Da Costa Date: Mon, 15 Jun 2026 16:54:38 +0200 Subject: [PATCH] [FIX]hr_employee_stats_sheet:fix bug in _get_total_recovery_hours and _get_total_leave_hours for half-days request unit leaves --- hr_employee_stats_sheet/__manifest__.py | 2 +- .../models/hr_employee_stats.py | 36 ++-- .../tests/test_employee_stats.py | 177 ++++++++++++++++++ 3 files changed, 196 insertions(+), 19 deletions(-) diff --git a/hr_employee_stats_sheet/__manifest__.py b/hr_employee_stats_sheet/__manifest__.py index 6eb55a7..ca680be 100755 --- a/hr_employee_stats_sheet/__manifest__.py +++ b/hr_employee_stats_sheet/__manifest__.py @@ -1,6 +1,6 @@ { "name": "hr_employee_stats_sheet", - "version": "16.0.3.2.0", + "version": "16.0.3.3.0", "description": "Add global sheet for employee stats", "summary": "Add global sheet for employee stats", "author": "Nicolas JEUDY", diff --git a/hr_employee_stats_sheet/models/hr_employee_stats.py b/hr_employee_stats_sheet/models/hr_employee_stats.py index 32bef82..f191dfa 100644 --- a/hr_employee_stats_sheet/models/hr_employee_stats.py +++ b/hr_employee_stats_sheet/models/hr_employee_stats.py @@ -121,15 +121,15 @@ class HrEmployeeStats(models.Model): ("request_date_to", ">=", self.date), ], ) - if recovery_ids: - for recovery_id in recovery_ids: - if recovery_id.request_unit_hours: - recovery_hours = recovery_id.number_of_hours_display - total_recovery_hours += min(recovery_hours,self._get_total_planned_hours()) - elif recovery_id.request_unit_half: - total_recovery_hours += self._get_total_planned_hours() / 2 - else : - total_recovery_hours += self._get_total_planned_hours() + for recovery_id in recovery_ids: + if recovery_id.request_date_from == recovery_id.request_date_to: + # single-day request (hours or half-day): + # Odoo already computed the hours from the calendar + total_recovery_hours += recovery_id.number_of_hours_display + else: + # multi-day request: we take the planned hours + # of THAT specific day + total_recovery_hours += self._get_total_planned_hours() return total_recovery_hours def _get_total_leave_hours(self): @@ -147,15 +147,15 @@ class HrEmployeeStats(models.Model): ("active", '=', True), ], ) - if leave_ids: - for leave_id in leave_ids: - if leave_id.request_unit_hours: - leave_hours = leave_id.number_of_hours_display - total_leave_hours += min(leave_hours,self._get_total_planned_hours()) - elif leave_id.request_unit_half: - total_leave_hours += self._get_total_planned_hours() / 2 - else : - total_leave_hours += self._get_total_planned_hours() + for leave_id in leave_ids: + if leave_id.request_date_from == leave_id.request_date_to: + # single-day request (hours or half-day): + # Odoo already computed the hours from the calendar + total_leave_hours += leave_id.number_of_hours_display + else: + # multi-day request: we take the planned hours + # of THAT specific day + total_leave_hours += self._get_total_planned_hours() return total_leave_hours @api.depends("employee_id", "date") diff --git a/hr_employee_stats_sheet/tests/test_employee_stats.py b/hr_employee_stats_sheet/tests/test_employee_stats.py index 8d1c218..9a84dce 100644 --- a/hr_employee_stats_sheet/tests/test_employee_stats.py +++ b/hr_employee_stats_sheet/tests/test_employee_stats.py @@ -394,7 +394,184 @@ class TestHrEmployeeStatsRecovery(TransactionCase): 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")