oca-ocb-hr/odoo-bringout-oca-ocb-hr_attendance/hr_attendance/tests/test_hr_attendance_undertime.py
Ernad Husremovic a1f02d8cc7 19.0 vanilla
2026-03-25 12:00:11 +01:00

557 lines
25 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, datetime
from odoo import Command
from odoo.tests import Form, HttpCase, new_test_user
from odoo.tests.common import tagged
@tagged('hr_attendance_overtime')
class TestHrAttendanceUndertime(HttpCase):
""" Tests for undertime """
@classmethod
def setUpClass(cls):
def set_calendar_and_tz(employee, tz):
employee.resource_calendar_id = cls.env['resource.calendar'].create({
'name': f'Default Calendar ({tz})',
'tz': tz,
})
super().setUpClass()
cls.company = cls.env['res.company'].create({
'name': 'SweatChipChop Inc.',
'attendance_overtime_validation': 'no_validation',
'absence_management': True,
})
cls.company.resource_calendar_id.tz = 'Europe/Brussels'
cls.ruleset = cls.env['hr.attendance.overtime.ruleset'].with_company(cls.company).create({
'name': 'Ruleset schedule quantity',
'rule_ids': [Command.create({
'name': 'Rule schedule quantity',
'base_off': 'quantity',
'expected_hours_from_contract': True,
'quantity_period': 'day',
})],
})
cls.company_1 = cls.env['res.company'].create({
'name': 'Overtime Inc.',
'absence_management': True,
})
cls.company_1.resource_calendar_id.tz = 'Europe/Brussels'
cls.ruleset_1 = cls.env['hr.attendance.overtime.ruleset'].with_company(cls.company_1).create({
'name': 'Ruleset schedule quantity',
'rule_ids': [Command.create({
'name': 'Rule schedule quantity',
'base_off': 'quantity',
'expected_hours_from_contract': True,
'quantity_period': 'day',
})],
})
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance_manager', company_id=cls.company.id).with_company(cls.company)
cls.employee = cls.env['hr.employee'].create({
'name': "Marie-Edouard De La Court",
'user_id': cls.user.id,
'company_id': cls.company.id,
'tz': 'UTC',
'date_version': date(2020, 1, 1),
'contract_date_start': date(2020, 1, 1),
'resource_calendar_id': cls.company.resource_calendar_id.id,
'ruleset_id': cls.ruleset.id,
})
cls.other_employee = cls.env['hr.employee'].create({
'name': 'Yolanda',
'company_id': cls.company.id,
'tz': 'UTC',
'date_version': date(2020, 1, 1),
'contract_date_start': date(2020, 1, 1),
'resource_calendar_id': cls.company.resource_calendar_id.id,
'ruleset_id': cls.ruleset.id,
})
cls.jpn_employee = cls.env['hr.employee'].create({
'name': 'Sacha',
'company_id': cls.company.id,
'tz': 'Asia/Tokyo',
'date_version': date(2020, 1, 1),
'contract_date_start': date(2020, 1, 1),
'resource_calendar_id': cls.company.resource_calendar_id.id,
'ruleset_id': cls.ruleset.id,
})
set_calendar_and_tz(cls.jpn_employee, 'Asia/Tokyo')
cls.honolulu_employee = cls.env['hr.employee'].create({
'name': 'Susan',
'company_id': cls.company.id,
'tz': 'Pacific/Honolulu',
'date_version': date(2020, 1, 1),
'contract_date_start': date(2020, 1, 1),
'resource_calendar_id': cls.company.resource_calendar_id.id,
'ruleset_id': cls.ruleset.id,
})
set_calendar_and_tz(cls.honolulu_employee, 'Pacific/Honolulu')
cls.europe_employee = cls.env['hr.employee'].with_company(cls.company_1).create({
'name': 'Schmitt',
'company_id': cls.company_1.id,
'tz': 'Europe/Brussels',
'date_version': date(2020, 1, 1),
'contract_date_start': date(2020, 1, 1),
'resource_calendar_id': cls.company_1.resource_calendar_id.id,
'ruleset_id': cls.ruleset_1.id,
})
set_calendar_and_tz(cls.europe_employee, 'Europe/Brussels')
cls.no_contract_employee = cls.env['hr.employee'].create({
'name': 'No Contract',
'company_id': cls.company.id,
'tz': 'UTC',
'resource_calendar_id': cls.company.resource_calendar_id.id,
'date_version': date(2020, 1, 1),
'contract_date_start': False,
})
cls.future_contract_employee = cls.env['hr.employee'].create({
'name': 'Future contract',
'company_id': cls.company.id,
'tz': 'UTC',
'resource_calendar_id': cls.company.resource_calendar_id.id,
'date_version': date(2020, 1, 1),
'contract_date_start': date(2030, 1, 1),
})
cls.calendar_flex_40h = cls.env['resource.calendar'].create({
'name': 'Flexible 40 hours/week',
'company_id': cls.company.id,
'hours_per_day': 8,
'hours_per_week': 40,
'flexible_hours': True,
'full_time_required_hours': 40,
})
cls.flexible_employee = cls.env['hr.employee'].create({
'name': 'Flexi',
'company_id': cls.company.id,
'tz': 'UTC',
'resource_calendar_id': cls.calendar_flex_40h.id,
'date_version': date(2020, 1, 1),
'contract_date_start': date(2020, 1, 1),
'ruleset_id': cls.ruleset.id,
})
def test_overtime_company_settings(self):
self.company.write({
"attendance_overtime_validation": "by_manager",
})
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 8, 0),
'check_out': datetime(2021, 1, 4, 20, 0),
})
self.assertEqual(attendance.overtime_status, 'to_approve')
self.assertAlmostEqual(attendance.validated_overtime_hours, 0, 2)
self.assertEqual(attendance.employee_id.total_overtime, 0)
attendance.action_approve_overtime()
self.assertEqual(attendance.overtime_status, 'approved')
self.assertAlmostEqual(attendance.validated_overtime_hours, 3, 2)
self.assertAlmostEqual(attendance.employee_id.total_overtime, 3, 2)
attendance.action_refuse_overtime()
self.assertEqual(attendance.employee_id.total_overtime, 0, 0)
def test_simple_undertime(self):
checkin_am = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 8, 0),
})
self.env['hr.attendance'].create({
'employee_id': self.other_employee.id,
'check_in': datetime(2021, 1, 4, 8, 0),
'check_out': datetime(2021, 1, 4, 15, 0),
})
overtime = self.env['hr.attendance.overtime.line'].search([('employee_id', '=', self.employee.id), ('date', '=', date(2021, 1, 4))])
self.assertFalse(overtime, 'No overtime record should exist for that employee')
checkin_am.write({'check_out': datetime(2021, 1, 4, 12, 0)})
overtime = checkin_am._linked_overtimes()
self.assertTrue(overtime, 'An overtime record should be created')
self.assertEqual(overtime.duration, -4)
checkin_pm = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 13, 0),
})
overtime = checkin_pm._linked_overtimes()
self.assertFalse(overtime.exists(), 'Overtime duration should not exist when an attendance has not been checked out.')
checkin_pm.write({'check_out': datetime(2021, 1, 4, 18, 0)})
overtime = self.env['hr.attendance.overtime.line'].search([('employee_id', '=', self.employee.id), ('date', '=', date(2021, 1, 4))])
self.assertAlmostEqual(overtime.duration, 1)
self.assertAlmostEqual(self.employee.total_overtime, 1)
def test_simple_undertime_multiple_rules(self):
""" Checks that only the least consequent undertime of the rules is considered."""
ruleset = self.env['hr.attendance.overtime.ruleset'].with_company(self.company).create({
'name': 'Ruleset schedule quantity',
'rule_ids': [Command.create({
'name': 'Rule schedule quantity',
'base_off': 'quantity',
'expected_hours_from_contract': False,
'expected_hours': 8.0,
'quantity_period': 'day',
}),
Command.create({
'name': 'Rule schedule quantity',
'base_off': 'quantity',
'expected_hours_from_contract': False,
'expected_hours': 10.0,
'quantity_period': 'day',
})],
})
self.employee.ruleset_id = ruleset
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 13, 0),
'check_out': datetime(2021, 1, 4, 18, 0),
})
self.assertEqual(attendance.overtime_hours, -3.0)
overtime = attendance._linked_overtimes()
self.assertEqual(len(overtime), 1, 'Only one overtime record should be created')
self.assertEqual(overtime.duration, -3.0)
def test_simple_undertime_multiple_rules_on_several_periods(self):
"""Whatever the period type, only the least consequent undertime of the rules is considered.
"""
ruleset = self.env['hr.attendance.overtime.ruleset'].with_company(self.company).create({
'name': 'Ruleset schedule quantity',
'rule_ids': [Command.create({
'name': 'Rule schedule quantity',
'base_off': 'quantity',
'expected_hours_from_contract': False,
'expected_hours': 8.0,
'quantity_period': 'day',
}),
Command.create({
'name': 'Rule schedule quantity',
'base_off': 'quantity',
'expected_hours_from_contract': False,
'expected_hours': 40.0,
'quantity_period': 'week',
})],
})
self.employee.ruleset_id = ruleset
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 13, 0),
'check_out': datetime(2021, 1, 4, 18, 0),
})
self.assertEqual(attendance.overtime_hours, -3.0)
overtime = attendance._linked_overtimes()
self.assertEqual(len(overtime), 1, 'Only one overtime record should be created')
self.assertEqual(overtime.duration, -3.0)
def test_undertime_change_employee(self):
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 7, 0),
'check_out': datetime(2021, 1, 4, 15, 0),
})
self.assertEqual(self.employee.total_overtime, -1)
self.assertEqual(self.other_employee.total_overtime, 0)
self.other_employee.ruleset_id = self.ruleset
self.env['hr.attendance'].create({
'employee_id': self.other_employee.id,
'check_in': datetime(2021, 1, 4, 7, 0),
'check_out': datetime(2021, 1, 4, 15, 0),
})
attendance.unlink()
self.assertEqual(self.other_employee.total_overtime, -1)
self.assertEqual(self.employee.total_overtime, 0)
def test_undertime_far_timezones(self):
# Since dates have to be stored in utc these are the tokyo timezone times for 7-12 / 13-18 (UTC+9)
(self.jpn_employee | self.honolulu_employee).ruleset_id = self.ruleset
self.env['hr.attendance'].create({
'employee_id': self.jpn_employee.id,
'check_in': datetime(2021, 1, 4, 1, 0),
'check_out': datetime(2021, 1, 4, 4, 0),
})
# Lunch time is at 3-4 UTC in Tokyo, and we don't work during lunch.
# Same but for alaskan times (UTC-10)
self.env['hr.attendance'].create({
'employee_id': self.honolulu_employee.id,
'check_in': datetime(2021, 1, 4, 17, 0),
'check_out': datetime(2021, 1, 4, 20, 0),
})
self.assertAlmostEqual(self.jpn_employee.total_overtime, -6, 2)
self.assertAlmostEqual(self.honolulu_employee.total_overtime, -5, 2)
def test_undertime_lunch(self):
self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 8, 0),
'check_out': datetime(2021, 1, 4, 13, 0),
})
self.assertEqual(self.employee.total_overtime, -4, 'There should be only -4 since the employee did not work through the lunch period.')
def test_undertime_hours_with_multiple_attendance(self):
m_attendance_1 = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 3, 8, 0),
'check_out': datetime(2023, 1, 3, 12, 0),
})
self.assertAlmostEqual(m_attendance_1.overtime_hours, -4, 2)
m_attendance_2 = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 3, 13, 0),
'check_out': datetime(2023, 1, 3, 17, 0),
})
self.assertAlmostEqual(m_attendance_1.overtime_hours, 0, 2)
self.assertAlmostEqual(m_attendance_2.overtime_hours, 0, 2)
m_attendance_3 = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 3, 18, 0),
'check_out': datetime(2023, 1, 3, 19, 0),
})
self.assertAlmostEqual(m_attendance_1.overtime_hours, 0, 2)
self.assertAlmostEqual(m_attendance_2.overtime_hours, 0, 2)
self.assertAlmostEqual(m_attendance_3.overtime_hours, 1, 2)
overtime_2 = self.env['hr.attendance.overtime.line'].search([('employee_id', '=', self.employee.id),
('date', '=', datetime(2023, 1, 3))])
# Total overtime for that day : 5 hours
self.assertEqual(len(overtime_2), 1, "Only one overtime record should be created for that day.")
self.assertEqual(overtime_2.duration, 1)
# Attendance Modification case
m_attendance_3.write({
'check_out': datetime(2023, 1, 3, 20, 00),
})
self.assertAlmostEqual(m_attendance_3.overtime_hours, 2, 2)
# Deleting previous attendances should update correctly the overtime hours in other attendances
m_attendance_2.unlink()
self.assertAlmostEqual(m_attendance_3.overtime_hours, -2, 2)
def test_undertime_across_days_timezones(self):
self.europe_employee.ruleset_id = self.ruleset
early_attendance = self.env['hr.attendance'].create({
'employee_id': self.europe_employee.id,
'check_in': datetime(2024, 5, 27, 23, 30),
'check_out': datetime(2024, 5, 28, 4, 30),
})
self.assertAlmostEqual(early_attendance.overtime_hours, -3, 2)
# Total overtime for that day : -3 hours
overtime_record = early_attendance.linked_overtime_ids
self.assertAlmostEqual(overtime_record.duration, -3, 2)
# Check that the calendar's timezones take priority and that overtimes and attendances dates are consistent
self.europe_employee.resource_calendar_id.tz = 'America/New_York'
early_attendance2 = self.env['hr.attendance'].create({
'employee_id': self.europe_employee.id,
'check_in': datetime(2024, 5, 30, 3, 0), # 23:00 NY prev day
'check_out': datetime(2024, 5, 30, 10, 0), # 6:00 NY
})
# First day you only work 1 hour and second day you work 6 hours, that's -1 hours of overtime
self.assertAlmostEqual(early_attendance2.overtime_hours, -1, 2)
overtime_record2 = early_attendance2.linked_overtime_ids
self.assertEqual(len(overtime_record2), 1, "One undertime records should be created for that attendance.")
self.assertAlmostEqual(overtime_record2.duration, -1, 2)
early_attendance3 = self.env['hr.attendance'].create({
'employee_id': self.europe_employee.id,
'check_in': datetime(2024, 5, 31, 4, 0), # 00:00 NY
'check_out': datetime(2024, 5, 31, 10, 0), # 6:00 NY
})
self.assertAlmostEqual(early_attendance3.overtime_hours, -2, 2)
def test_undertime_hours_flexible_resource(self):
""" Test the computation of overtime hours for a single flexible resource with 8 hours_per_day.
=========
Test Case
1) | 8:00 | 16:00 | -> No overtime
2) | 12:00 | 18:00 | -> -2 hours of overtime
3) | 10:00 | 22:00 | -> 4 hours of overtime
"""
self.flexible_employee.ruleset_id = self.ruleset
# 1) 8:00 - 16:00 should contain 0 hours of overtime
attendance = self.env['hr.attendance'].create({
'employee_id': self.flexible_employee.id,
'check_in': datetime(2023, 1, 2, 8, 0),
'check_out': datetime(2023, 1, 2, 16, 0),
})
self.assertEqual(attendance.overtime_hours, 0, 'There should be no overtime for the flexible resource.')
# 2) 12:00 - 18:00 should contain -2 hours of overtime
# as we expect the employee to work 8 hours per day
attendance.write({
'check_in': datetime(2023, 1, 3, 12, 0),
'check_out': datetime(2023, 1, 3, 18, 0),
})
self.assertAlmostEqual(attendance.overtime_hours, -2, 2, 'There should be -2 hours of overtime for the flexible resource.')
# 3) 10:00 - 22:00 should contain 4 hours of overtime
attendance.write({
'check_in': datetime(2023, 1, 4, 10, 0),
'check_out': datetime(2023, 1, 4, 22, 0),
})
self.assertAlmostEqual(attendance.overtime_hours, 4, 2, 'There should be 4 hours of overtime for the flexible resource.')
def test_undertime_hours_multiple_flexible_resources(self):
""" Test the computation of overtime hours for multiple flexible resources on a single workday with 8 hours_per_day.
=========
We should see that the overtime hours are recomputed correctly when new attendance records are created.
Test Case
1) | 8:00 | 12:00 | -> -4 hours of overtime
2) (| 8:00 | 12:00 |, | 13:00 | 15:00 |) -> (0, -2) hours of overtime
3) (| 8:00 | 12:00 |, | 13:00 | 15:00 |, | 16:00 | 18:00 |) -> (0, 0, 0) hours of overtime
"""
self.flexible_employee.ruleset_id = self.ruleset
# 1) 8:00 - 12:00 should contain -4 hours of overtime
attendance_1 = self.env['hr.attendance'].create({
'employee_id': self.flexible_employee.id,
'check_in': datetime(2023, 1, 2, 8, 0),
'check_out': datetime(2023, 1, 2, 12, 0),
})
self.assertAlmostEqual(attendance_1.overtime_hours, -4, 2, 'There should be -4 hours of overtime for the flexible resource.')
# 2) 8:00 - 12:00 and 13:00 - 15:00 should contain 0 and -2 hours of overtime
attendance_2 = self.env['hr.attendance'].create({
'employee_id': self.flexible_employee.id,
'check_in': datetime(2023, 1, 2, 13, 0),
'check_out': datetime(2023, 1, 2, 15, 0),
})
self.assertEqual(attendance_1.overtime_hours, 0, 'There should be no overtime for the flexible resource.')
self.assertAlmostEqual(attendance_2.overtime_hours, -2, 2, 'There should be -2 hours of overtime for the flexible resource.')
# 3) 8:00 - 12:00, 13:00 - 15:00 and 16:00 - 18:00 should contain 0, 0 and 0 hours of overtime
attendance_3 = self.env['hr.attendance'].create({
'employee_id': self.flexible_employee.id,
'check_in': datetime(2023, 1, 2, 16, 0),
'check_out': datetime(2023, 1, 2, 18, 0),
})
self.assertEqual(attendance_1.overtime_hours, 0, 'There should be no overtime for the flexible resource.')
self.assertEqual(attendance_2.overtime_hours, 0, 'There should be no overtime for the flexible resource.')
self.assertEqual(attendance_3.overtime_hours, 0, 'There should be no overtime for the flexible resource.')
def test_refuse_overtime(self):
self.company.write({
"attendance_overtime_validation": "by_manager",
})
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 2, 8, 0),
'check_out': datetime(2023, 1, 2, 12, 0),
})
overtime = self.env['hr.attendance.overtime.line'].search([('employee_id', '=', self.employee.id)])
overtime.action_approve()
self.assertEqual(attendance.validated_overtime_hours, -4)
self.assertEqual(attendance.overtime_hours, attendance.validated_overtime_hours)
attendance.action_refuse_overtime()
self.assertEqual(attendance.validated_overtime_hours, 0)
def test_no_validation_extra_hours_change(self):
"""
Check that manual edits are recomputed when updating another attendance,
but flags the record as 'to_approve'.
"""
self.company.attendance_overtime_validation = "no_validation"
attendance = self.env['hr.attendance']
# Form is used here as it will send a `validated_overtime_hours` value of 0 when saved.
# This should not be considered as a manual edition of the field by the user.
with Form(attendance) as attendance_form:
attendance_form.employee_id = self.employee
attendance_form.check_in = datetime(2023, 1, 2, 8, 0)
attendance_form.check_out = datetime(2023, 1, 2, 15, 0)
attendance = attendance_form.save()
self.assertAlmostEqual(attendance.overtime_hours, -2, 2)
self.assertAlmostEqual(attendance.validated_overtime_hours, -2, 2)
attendance.linked_overtime_ids.manual_duration = -1.5
self.assertNotEqual(attendance.validated_overtime_hours, attendance.overtime_hours)
# Create another attendance for the same employee
self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 4, 8, 0),
'check_out': datetime(2023, 1, 4, 18, 0),
})
# The hours will now be recomputed
# But they should have the 'to_approve' status
self.assertEqual(attendance.linked_overtime_ids.status, 'to_approve', "Record should be flagged for approval")
self.assertAlmostEqual(attendance.linked_overtime_ids.duration, -2.0, 2, "Math should be reset to -2.0")
self.assertEqual(attendance.validated_overtime_hours, 0.0, "Validated hours should be 0 until approved")
def test_overtime_employee_tolerance(self):
self.ruleset.rule_ids[0].employee_tolerance = 10 / 60
self.env['hr.attendance'].create([
{
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 8, 5),
'check_out': datetime(2021, 1, 4, 12, 0),
},
{
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 4, 13, 0),
'check_out': datetime(2021, 1, 4, 16, 55),
}
])
overtime = self.env['hr.attendance.overtime.line'].search([('employee_id', '=', self.employee.id)])
self.assertFalse(overtime, 'No overtime should be counted because of the tolerance.')
self.ruleset.rule_ids[0].employee_tolerance = 4 / 60
self.ruleset.action_regenerate_overtimes()
overtime = self.env['hr.attendance.overtime.line'].search([('employee_id', '=', self.employee.id)])
self.assertTrue(overtime, 'Overtime entry should exist since the tolerance has been lowered.')
self.assertAlmostEqual(overtime.duration, -(10 / 60), places=2, msg='Overtime should be equal to -10 minutes.')
def test_overtime_on_multiple_days(self):
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2021, 1, 8, 8, 0), # Friday 8 AM - 17 PM work, 17 - 24 overtime (7 hours)
'check_out': datetime(2021, 1, 9, 3, 0), # Saturday 0-3 AM overtime (3 hours)
})
overtime = attendance._linked_overtimes()
self.assertEqual(len(overtime), 2, 'There should be 2 overtime records for that attendance.')
self.assertEqual(sum(overtime.mapped('duration')), 10, 'There should be a total of 10 hours of overtime for that attendance.')
attendance.write({
'check_out': datetime(2021, 1, 8, 20, 0),
})
overtime = attendance._linked_overtimes()
self.assertEqual(len(overtime), 1, 'There should have only 1 overtime for that attendance after modification.')
self.assertEqual(sum(overtime.mapped('duration')), 3, 'There should be a total of 3 hours of overtime for that attendance after modification.')
all_overtimes = self.env['hr.attendance.overtime.line'].search([('employee_id', '=', self.employee.id)])
self.assertEqual(len(all_overtimes), 1, 'There should be only 1 overtime record in total for that employee.')