Files
enterprise-addons/hr_payroll/tests/test_new_employee_worked_days.py
T

213 lines
9.6 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
from dateutil.relativedelta import relativedelta
from odoo.fields import Date, Datetime
from odoo.tests.common import tagged, freeze_time
from odoo.addons.hr_payroll.tests.common import TestPayslipBase
@tagged('work_entry')
class TestWorkEntry(TestPayslipBase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.calendar_40h = cls.env['resource.calendar'].create({'name': 'Default calendar'})
def _month_bounds(self, base_date):
date_from = base_date.replace(day=1)
date_to = date_from + relativedelta(months=1, days=-1)
return date_from, date_to
def _get_work_entry_types(self):
out_type = self.env.ref('hr_work_entry.hr_work_entry_type_out_of_contract', raise_if_not_found=False)
self.assertTrue(out_type, "Missing 'Out of Contract' type")
att_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
self.assertTrue(att_type, "Missing 'Attendance' type")
return out_type, att_type
def _get_entries_by_type(self, employee, date_from, date_to):
out_type, att_type = self._get_work_entry_types()
start_dt = Datetime.to_datetime(date_from)
stop_dt = Datetime.to_datetime(date_to) + relativedelta(hour=23, minute=59, second=59)
work_entries = self.env['hr.work.entry'].search([
('employee_id', '=', employee.id),
('date', '<=', stop_dt),
('date', '>=', start_dt),
])
by_type = {
'attendance': work_entries.filtered(lambda w: w.work_entry_type_id.id == att_type.id),
'out': work_entries.filtered(lambda w: w.work_entry_type_id.id == out_type.id),
}
return by_type
def _assert_attendance_in_range(self, employee, date_from, date_to, expected_we_number):
if date_from <= date_to:
by_type = self._get_entries_by_type(employee, date_from, date_to)
att = by_type['attendance']
self.assertTrue(att, "Expected Attendance work entries, got none.")
earliest, latest = min(att.mapped('date')), max(att.mapped('date'))
self.assertEqual(
earliest, date_from,
f"Attendance work entries start on {earliest}, expected {date_from}."
)
self.assertEqual(
latest, date_to,
f"Attendance work entries end on {earliest}, expected {date_to}."
)
self.assertEqual(
len(att), expected_we_number,
f"Got {len(att)} attendance work entries, expected {expected_we_number}."
)
def _assert_no_attendance_in_range(self, employee, date_from, date_to):
by_type = self._get_entries_by_type(employee, date_from, date_to)
self.assertFalse(by_type['attendance'],
f"Expected no Attendance work entries, got {len(by_type['attendance'])}.")
def _assert_worked_days_lines(self, slip, expected_out, expected_att):
lines = slip._get_worked_day_lines(domain=None, check_out_of_version=True)
out_type, att_type = self._get_work_entry_types()
out_lines = [l for l in lines if l.get('work_entry_type_id') == out_type.id]
att_lines = [l for l in lines if l.get('work_entry_type_id') == att_type.id]
self.assertEqual(len(out_lines), 1, f"Expected exactly one 'Out of Contract' line, got {len(out_lines)}")
out_line = out_lines[0]
self.assertAlmostEqual(out_line['number_of_days'], expected_out['days'], places=2)
self.assertAlmostEqual(out_line['number_of_hours'], expected_out['hours'], places=2)
if abs(expected_att['days']) < 1e-12 and abs(expected_att['hours']) < 1e-12:
if att_lines:
self.assertEqual(len(att_lines), 1, f"Expected at most one 'Attendance' line, got {len(att_lines)}")
att_line = att_lines[0]
self.assertAlmostEqual(att_line['number_of_days'], 0.0, places=2)
self.assertAlmostEqual(att_line['number_of_hours'], 0.0, places=2)
else:
self.assertEqual(len(att_lines), 1, f"Expected exactly one 'Attendance' line, got {len(att_lines)}")
att_line = att_lines[0]
self.assertAlmostEqual(att_line['number_of_days'], expected_att['days'], places=2)
self.assertAlmostEqual(att_line['number_of_hours'], expected_att['hours'], places=2)
def test_work_entries_contract_starts_this_month(self):
"""
Contract starts during current month.
Checks:
- current month: Out-of-contract before start, Attendance after start, work entries only for attendance days
- previous month: full Out-of-contract, no work entries
"""
with freeze_time(date(2025, 12, 20)):
cal = self.calendar_40h
month_start, month_end = self._month_bounds(Date.today())
prev_start, prev_end = self._month_bounds(Date.today() + relativedelta(months=-1))
contract_start = date(2025, 12, 10)
emp = self.env['hr.employee'].create({
'name': 'John Doe',
'company_id': self.company_us.id,
'resource_calendar_id': cal.id,
'contract_date_start': contract_start,
})
version = emp.version_id
# Current month
work_entries = version.generate_work_entries(month_start, month_end)
work_entries.action_validate()
self._assert_attendance_in_range(emp, contract_start, month_end, expected_we_number=16.0)
self._assert_no_attendance_in_range(emp, month_start, contract_start + relativedelta(days=-1))
slip = self.env['hr.payslip'].new({
'name': 'Payslip Dec 2025',
'employee_id': emp.id,
'version_id': version.id,
'company_id': emp.company_id.id,
'date_from': month_start,
'date_to': month_end,
})
expected_out = {'days': 7.0, 'hours': 56.0}
expected_att = {'days': 16.0, 'hours': 128.0}
self._assert_worked_days_lines(slip, expected_out, expected_att)
# Previous month
work_entries_prev = version.generate_work_entries(prev_start, prev_end)
work_entries_prev.action_validate()
self._assert_no_attendance_in_range(emp, prev_start, prev_end)
slip_prev = self.env['hr.payslip'].new({
'name': 'Payslip Nov 2025',
'employee_id': emp.id,
'version_id': version.id,
'company_id': emp.company_id.id,
'date_from': prev_start,
'date_to': prev_end,
})
expected_out_prev = {'days': 20.0, 'hours': 160.0}
expected_att_prev = {'days': 0.0, 'hours': 0.0}
self._assert_worked_days_lines(slip_prev, expected_out_prev, expected_att_prev)
def test_work_entries_contract_ends_this_month(self):
"""
Contract ends during current month.
Checks:
- current month: Attendance until end, Out-of-contract after end, work entries only for attendance days
- next month: full Out-of-contract, no work entries
"""
with freeze_time(date(2025, 12, 20)):
cal = self.calendar_40h
month_start, month_end = self._month_bounds(Date.today())
next_start, next_end = self._month_bounds(Date.today() + relativedelta(months=1))
contract_start = date(2025, 11, 1)
contract_end = date(2025, 12, 10)
emp = self.env['hr.employee'].create({
'name': 'Jane Doe',
'company_id': self.company_us.id,
'department_id': self.dep_rd.id,
'resource_calendar_id': cal.id,
'contract_date_start': contract_start,
'contract_date_end': contract_end,
})
version = emp.version_id
# Current month
work_entries = version.generate_work_entries(month_start, month_end)
work_entries.action_validate()
self._assert_attendance_in_range(emp, month_start, contract_end, expected_we_number=8.0)
self._assert_no_attendance_in_range(emp, contract_end + relativedelta(days=1), month_end)
slip = self.env['hr.payslip'].new({
'name': 'Payslip Dec 2025 (end contract)',
'employee_id': emp.id,
'version_id': version.id,
'company_id': emp.company_id.id,
'date_from': month_start,
'date_to': month_end,
})
expected_att = {'days': 8.0, 'hours': 64.0}
expected_out = {'days': 15.0, 'hours': 120.0}
self._assert_worked_days_lines(slip, expected_out, expected_att)
# Next month
work_entries_next = version.generate_work_entries(next_start, next_end)
work_entries_next.action_validate()
self._assert_no_attendance_in_range(emp, next_start, next_end)
slip_next = self.env['hr.payslip'].new({
'name': 'Payslip Jan 2026 (after end contract)',
'employee_id': emp.id,
'version_id': version.id,
'company_id': emp.company_id.id,
'date_from': next_start,
'date_to': next_end,
})
expected_out_next = {'days': 22.0, 'hours': 176.0}
expected_att_next = {'days': 0.0, 'hours': 0.0}
self._assert_worked_days_lines(slip_next, expected_out_next, expected_att_next)