213 lines
9.6 KiB
Python
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)
|