[FIX]hr_employee_stats_sheet:convert public holidays in employee tz

This commit is contained in:
2025-11-05 11:41:08 +01:00
parent 20f04d710c
commit 54e3f32eda
2 changed files with 66 additions and 65 deletions

View File

@@ -1,9 +1,9 @@
import logging import logging
import datetime import pytz
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 from pytz import utc
import pytz
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -147,7 +147,7 @@ class HrEmployeeStats(models.Model):
total_leave_hours = leave_id.number_of_hours_display total_leave_hours = leave_id.number_of_hours_display
elif leave_id.request_unit_half: elif leave_id.request_unit_half:
total_leave_hours = self._get_total_planned_hours() / 2 total_leave_hours = self._get_total_planned_hours() / 2
else: else :
total_leave_hours = self._get_total_planned_hours() total_leave_hours = self._get_total_planned_hours()
return total_leave_hours return total_leave_hours
@@ -167,21 +167,21 @@ class HrEmployeeStats(models.Model):
stat.is_public_holiday = stat._is_public_holiday_accordig_to_employe_tz() stat.is_public_holiday = stat._is_public_holiday_accordig_to_employe_tz()
def _convert_to_employee_tz(self, date): def _convert_to_employee_tz(self, date):
"""Convertit un datetime UTC en datetime dans le fuseau de l'employé.""" """Convert a UTC datetime to the employee's timezone datetime."""
self.ensure_one() self.ensure_one()
if not date: if not date:
return None return None
employee_tz = pytz.timezone(self.sheet_id.employee_id.tz or "UTC") employee_tz = pytz.timezone(self.employee_id.tz or "UTC")
if date.tzinfo is None: if date.tzinfo is None:
dt = pytz.utc.localize(date) dt = pytz.utc.localize(date)
return dt.astimezone(employee_tz) - timedelta(minutes=1) return dt.astimezone(employee_tz)
def _is_public_holiday_accordig_to_employe_tz(self): def _is_public_holiday_accordig_to_employe_tz(self):
self.ensure_one() self.ensure_one()
if not self.date or not self.sheet_id or not self.sheet_id.employee_id: if not self.date or not self.employee_id:
return False return False
#get public holidays for the employee #get public holidays for the employee
public_holidays = self.sheet_id.employee_id._get_public_holidays( public_holidays = self.employee_id._get_public_holidays(
self.date, self.date self.date, self.date
) )
if not public_holidays: if not public_holidays:
@@ -199,12 +199,13 @@ class HrEmployeeStats(models.Model):
else: else:
return False return False
def _get_gap_hours( def _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours):
self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours
):
self.ensure_one() self.ensure_one()
balance = ( balance = (
total_hours + total_recovery_hours + total_leave_hours - total_planned_hours total_hours
+ total_recovery_hours
+ total_leave_hours
- total_planned_hours
) )
return balance return balance

View File

@@ -1,8 +1,9 @@
from odoo.tests.common import TransactionCase from datetime import date, datetime, timedelta
from odoo.tests import tagged
from datetime import date, timedelta
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.fields import Date from odoo.fields import Date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
class TestHrEmployeeStatsRecovery(TransactionCase): class TestHrEmployeeStatsRecovery(TransactionCase):
@@ -37,12 +38,13 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), (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.employee.resource_calendar_id = self.base_calendar
self.env.company.recovery_type_id = self.recovery_type 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):
# Create a timesheet sheet for the week (Monday to Sunday) # Create a timesheet 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,
@@ -69,7 +71,9 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
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) # monday of last week 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)
@@ -81,7 +85,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week 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): # create 5 stats of 7h each for stat in self._create_stats(start_date, 5, 7): # create 5 stats of 7h each
# Compare calculated recovery hours with the calendar which plans 7h per day # 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_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_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_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
@@ -92,10 +96,12 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
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) # monday of last week 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): # create 5 stats of 8h each for stat in self._create_stats(start_date, 5, 8): # create 5 stats of 8h each
# Compare calculated recovery hours with the calendar which plans 7h per day # 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_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_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_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
@@ -108,13 +114,13 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
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.8928571428571429, "The recovery allocation should be for 6.25h/7h = 0.8928571428571429 day")
def test_negative_recovery_hours(self): def test_negative_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week 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): # create 5 stats of 6h each for stat in self._create_stats(start_date, 5, 6): # create 5 stats of 6h each
# Compare calculated recovery hours with the calendar which plans 7h per day # 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_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_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_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
@@ -122,12 +128,12 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # the employee worked one hour less than planned 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 # 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_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 for deficits) 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 # 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.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): def test_recovery_hours_part_time_employee(self):
part_time_calendar = self.env['resource.calendar'].create({ part_time_calendar = self.env['resource.calendar'].create({
@@ -144,11 +150,10 @@ 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) # monday of last week 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): # create 4 stats of 8h each for stat in self._create_stats(start_date, 4, 8): # create 4 stats of 8h each
# Compare calculated recovery hours with the calendar which plans 7h per day for 4 days # 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_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_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_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
@@ -172,7 +177,7 @@ 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 another starting on thursday #create one contract ending on wednesday and one other 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,
@@ -193,9 +198,9 @@ 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) # monday of last week start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
# create a timesheet with a period that includes the contract change # create a timesheet with period including the change of contract
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 creation of the recovery allocation should raise an error
with self.assertRaises(UserError): with self.assertRaises(UserError):
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
@@ -243,41 +248,36 @@ 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")
def test_public_holiday_detection(self): recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)])
# Create a public holiday on the Wednesday of the test week and verify detection self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
timesheet_sheet = self._create_timesheet_sheet(start_date) def test_public_holiday(self):
holiday_date = start_date + timedelta(days=2) # Wednesday # create a public holiday
# create a global public holiday (no resource or calendar restriction) self.env["resource.calendar.leaves"].create(
self.env['resource.calendar.leaves'].create({ {
'name': 'Public Holiday', "name": "1 mai 2025",
'company_id': self.env.company.id, "date_from": datetime(2025,4,30,22,0,0),
'date_from': Date.to_string(holiday_date), "date_to": datetime(2025,5,1,21,0,0),
'date_to': Date.to_string(holiday_date), }
'resource_id': False, )
'calendar_id': False, #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)
# create a timesheet entry on that day for stat in stats:
self.env['account.analytic.line'].create({ 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, 'employee_id': self.employee.id,
'date': holiday_date, 'date_start': "2025-04-28",
'unit_amount': 7, 'date_end': "2025-05-04",
'account_id': 1,
'name': 'Work Entry',
}) })
# create the stat for that date and recompute self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 7, "timesheet_sheet_gap_hours should be 7",)
stat = self.env['hr.employee.stats'].create({ self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 8.75, "timesheet_sheet_recovery_hours should be 8,75",)
'employee_id': self.employee.id,
'date': holiday_date, timesheet_sheet.action_generate_recovery_allocation()
'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, "Il doit y avoir une allocation de récupération générée")
stat._compute_dayofweek() self.assertEqual(recovery_allocation.number_of_days,1.25, "The recovery allocation should be 1,25 days")
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")