[FIX]hr_employee_stats_sheet:convert public holidays in employee tz
This commit is contained in:
@@ -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__)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
|
||||||
'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_dayofweek()
|
||||||
stat._compute_timesheet_line_ids()
|
|
||||||
stat._compute_hours()
|
stat._compute_hours()
|
||||||
# The stat must detect the public holiday and planned hours must be zero
|
#create 1 timesheet sheet from monday to friday including the public holiday on 1st may
|
||||||
self.assertTrue(stat.is_public_holiday, "The day should be detected as a public holiday")
|
timesheet_sheet = self.env['hr_timesheet.sheet'].create({
|
||||||
self.assertEqual(stat.total_planned_hours, 0, "total_planned_hours should be 0 on a public holiday")
|
'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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user