Files
hr-tools/hr_employee_stats_sheet/tests/test_employee_stats.py

288 lines
18 KiB
Python

from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from datetime import date, timedelta
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.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 sheet 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 with 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 with 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.78125, "The recovery allocation should be for 6.25h/8h = 0.78125 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 with 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 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.625, "The recovery allocation should be for -5/8 hours = ")
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 with 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 another 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 a period that includes the contract change
timesheet_sheet = self._create_timesheet_sheet(start_date)
#the create of 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")
def test_public_holiday_detection(self):
# Create a public holiday on the Wednesday of the test week and verify detection
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
timesheet_sheet = self._create_timesheet_sheet(start_date)
holiday_date = start_date + timedelta(days=2) # Wednesday
# create a global public holiday (no resource or calendar restriction)
self.env['resource.calendar.leaves'].create({
'name': 'Public Holiday',
'company_id': self.env.company.id,
'date_from': Date.to_string(holiday_date),
'date_to': Date.to_string(holiday_date),
'resource_id': False,
'calendar_id': False,
})
# create a timesheet entry on that day
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_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")