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

775 lines
37 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.rrule import rrule, DAILY
from datetime import datetime, date, timedelta
from dateutil.relativedelta import relativedelta
from odoo.fields import Date
from odoo.tests import Form, tagged
from odoo.exceptions import UserError
from odoo.addons.hr_payroll.tests.common import TestPayslipContractBase
@tagged('payslip_computation')
class TestPayslipComputation(TestPayslipContractBase):
@classmethod
def setUpClass(cls):
super(TestPayslipComputation, cls).setUpClass()
cls.env.user.group_ids |= cls.env.ref('hr_payroll.group_hr_payroll_manager')
cls.richard_payslip = cls.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': cls.richard_emp.id,
'version_id': cls.contract_cdi.id, # wage = 5000 => average/day (over 3months/13weeks): 230.77
'struct_id': cls.developer_pay_structure.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31)
})
cls.richard_emp.resource_calendar_id = cls.contract_cdi.resource_calendar_id
cls.richard_payslip_quarter = cls.env['hr.payslip'].create({
'name': 'Payslip of Richard Quarter',
'employee_id': cls.richard_emp.id,
'version_id': cls.contract_cdi.id,
'struct_id': cls.developer_pay_structure.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 3, 31)
})
# To avoid having is_paid = False in some tests, as all the records are created on the
# same transaction which is quite unlikely supposed to happen in real conditions
worked_days = (cls.richard_payslip + cls.richard_payslip_quarter).worked_days_line_ids
worked_days._compute_is_paid()
worked_days.flush_model(['is_paid'])
def _reset_work_entries(self, contract):
# Use hr.leave to automatically regenerate work entries for absences
self.env['hr.work.entry'].search([('employee_id', '=', contract.employee_id.id)]).unlink()
now = datetime(2016, 1, 1, 0, 0, 0)
contract.write({
'date_generated_from': now,
'date_generated_to': now,
})
def test_unpaid_amount(self):
self.assertAlmostEqual(self.richard_payslip._get_unpaid_amount(), 0, places=2, msg="It should be paid the full wage")
self._reset_work_entries(self.richard_payslip.version_id)
self.env['resource.calendar.leaves'].create({
'name': 'Doctor Appointment',
'date_from': datetime.strptime('2016-01-11 07:00:00', '%Y-%m-%d %H:%M:%S'),
'date_to': datetime.strptime('2016-01-11 18:00:00', '%Y-%m-%d %H:%M:%S'),
'resource_id': self.richard_emp.resource_id.id,
'calendar_id': self.richard_emp.resource_calendar_id.id,
'work_entry_type_id': self.work_entry_type_unpaid.id,
'time_type': 'leave',
})
self.richard_emp.version_ids.generate_work_entries(date(2016, 1, 1), date(2016, 2, 1))
self.richard_payslip._compute_worked_days_line_ids()
# TBE: In master the Monetary field were not rounded because the currency_id wasn't computed yet.
# The test was incorrect using the value 238.09, with 238.11 it is ok
self.assertAlmostEqual(self.richard_payslip._get_unpaid_amount(), 238.11, delta=0.01, msg="It should be paid 238.11 less")
def test_worked_days_amount_with_unpaid(self):
self.env['resource.calendar.leaves'].create({
'name': 'Doctor Appointment',
'date_from': datetime.strptime('2016-01-11 07:00:00', '%Y-%m-%d %H:%M:%S'),
'date_to': datetime.strptime('2016-01-11 18:00:00', '%Y-%m-%d %H:%M:%S'),
'resource_id': self.richard_emp.resource_id.id,
'calendar_id': self.richard_emp.resource_calendar_id.id,
'work_entry_type_id': self.work_entry_type_leave.id,
'time_type': 'leave',
})
self.env['resource.calendar.leaves'].create({
'name': 'Unpaid Doctor Appointment',
'date_from': datetime.strptime('2016-01-21 07:00:00', '%Y-%m-%d %H:%M:%S'),
'date_to': datetime.strptime('2016-01-21 18:00:00', '%Y-%m-%d %H:%M:%S'),
'resource_id': self.richard_emp.resource_id.id,
'calendar_id': self.richard_emp.resource_calendar_id.id,
'work_entry_type_id': self.work_entry_type_unpaid.id,
'time_type': 'leave',
})
self._reset_work_entries(self.richard_payslip.version_id)
work_entries = self.richard_emp.version_ids.generate_work_entries(date(2016, 1, 1), date(2016, 2, 1))
work_entries.action_validate()
self.richard_payslip._compute_worked_days_line_ids()
work_days = self.richard_payslip.worked_days_line_ids
self.assertAlmostEqual(sum(work_days.mapped('amount')), self.contract_cdi.wage - self.richard_payslip._get_unpaid_amount())
leave_line = work_days.filtered(lambda l: l.code == self.work_entry_type_leave.code)
self.assertAlmostEqual(leave_line.amount, 238.11, delta=0.01, msg="His paid time off must be paid 238.11")
extra_attendance_line = work_days.filtered(lambda l: l.code == self.work_entry_type_unpaid.code)
self.assertAlmostEqual(extra_attendance_line.amount, 0.0, places=2, msg="His unpaid time off must be paid 0.")
attendance_line = work_days.filtered(lambda l: l.code == self.env.ref('hr_work_entry.work_entry_type_attendance').code)
self.assertAlmostEqual(attendance_line.amount, 4524.11, delta=0.01, msg="His attendance must be paid 4524.11")
def test_worked_days_with_unpaid(self):
self.contract_cdi.resource_calendar_id = self.calendar_38h
self.richard_emp.resource_calendar_id = self.calendar_38h
# Create 2 hours upaid leave every day during 2 weeks
for day in rrule(freq=DAILY, byweekday=[0, 1, 2, 3, 4], count=10, dtstart=datetime(2016, 2, 8)):
start = day + timedelta(hours=13.6)
end = day + timedelta(hours=15.6)
self.env['resource.calendar.leaves'].create({
'name': 'Unpaid Leave',
'date_from': start,
'date_to': end,
'resource_id': self.richard_emp.resource_id.id,
'calendar_id': self.richard_emp.resource_calendar_id.id,
'work_entry_type_id': self.work_entry_type_unpaid.id,
'time_type': 'leave',
})
self._reset_work_entries(self.richard_payslip_quarter.version_id)
work_entries = self.richard_emp.version_ids.generate_work_entries(date(2016, 1, 1), date(2016, 3, 31))
work_entries.action_validate()
self.richard_payslip_quarter._compute_worked_days_line_ids()
work_days = self.richard_payslip_quarter.worked_days_line_ids
leave_line = work_days.filtered(lambda l: l.code == self.env.ref('hr_work_entry.work_entry_type_attendance').code)
self.assertAlmostEqual(leave_line.number_of_days, 62.5, places=2)
extra_attendance_line = work_days.filtered(lambda l: l.code == self.work_entry_type_unpaid.code)
self.assertAlmostEqual(extra_attendance_line.number_of_days, 2.5, places=2)
def test_worked_days_16h_with_unpaid(self):
self.contract_cdi.resource_calendar_id = self.calendar_16h
self.richard_emp.resource_calendar_id = self.calendar_16h
# Create 2 hours upaid leave every Thursday Evening during 5 weeks
for day in rrule(freq=DAILY, byweekday=3, count=5, dtstart=datetime(2016, 2, 4)):
start = day + timedelta(hours=12.5)
end = day + timedelta(hours=14.5)
self.env['resource.calendar.leaves'].create({
'name': 'Unpaid Leave',
'date_from': start,
'date_to': end,
'resource_id': self.richard_emp.resource_id.id,
'calendar_id': self.richard_emp.resource_calendar_id.id,
'work_entry_type_id': self.work_entry_type_unpaid.id,
'time_type': 'leave',
})
self._reset_work_entries(self.richard_payslip_quarter.version_id)
work_entries = self.richard_emp.version_ids.generate_work_entries(date(2016, 1, 1), date(2016, 3, 31))
work_entries.action_validate()
self.richard_payslip_quarter._compute_worked_days_line_ids()
work_days = self.richard_payslip_quarter.worked_days_line_ids
leave_line = work_days.filtered(lambda l: l.code == self.env.ref('hr_work_entry.work_entry_type_attendance').code)
self.assertAlmostEqual(leave_line.number_of_days, 49.5, places=2)
extra_attendance_line = work_days.filtered(lambda l: l.code == self.work_entry_type_unpaid.code)
self.assertAlmostEqual(extra_attendance_line.number_of_days, 2.5, places=2)
def test_worked_days_38h_friday_with_unpaid(self):
self.contract_cdi.resource_calendar_id = self.calendar_38h_friday_light
self.richard_emp.resource_calendar_id = self.calendar_38h_friday_light
# Create 4 hours (all work day) upaid leave every Friday during 5 weeks
for day in rrule(freq=DAILY, byweekday=4, count=5, dtstart=datetime(2016, 2, 4)):
start = day + timedelta(hours=7)
end = day + timedelta(hours=11)
self.env['resource.calendar.leaves'].create({
'name': 'Unpaid Leave',
'date_from': start,
'date_to': end,
'resource_id': self.richard_emp.resource_id.id,
'calendar_id': self.richard_emp.resource_calendar_id.id,
'work_entry_type_id': self.work_entry_type_unpaid.id,
'time_type': 'leave',
})
self._reset_work_entries(self.richard_payslip_quarter.version_id)
work_entries = self.richard_emp.version_ids.generate_work_entries(date(2016, 1, 1), date(2016, 3, 31))
work_entries.action_validate()
self.richard_payslip_quarter._compute_worked_days_line_ids()
work_days = self.richard_payslip_quarter.worked_days_line_ids
leave_line = work_days.filtered(lambda l: l.code == self.env.ref('hr_work_entry.work_entry_type_attendance').code)
self.assertAlmostEqual(leave_line.number_of_days, 62.5, places=2)
extra_attendance_line = work_days.filtered(lambda l: l.code == self.work_entry_type_unpaid.code)
self.assertAlmostEqual(extra_attendance_line.number_of_days, 2.5, places=2)
def test_sum_category(self):
self.richard_payslip.compute_sheet()
self.richard_payslip.action_payslip_done()
self.richard_payslip2 = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'struct_id': self.developer_pay_structure.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31)
})
self.richard_payslip2.compute_sheet()
self.assertEqual(3010.13, self.richard_payslip2.line_ids.filtered(lambda x: x.code == 'SUMALW').total)
def test_payslip_generation_with_extra_work(self):
# /!\ this is in the weekend (Sunday) => no calendar attendance at this time
start = datetime(2015, 11, 1, 10, 0, 0)
end = datetime(2015, 11, 1, 17, 0, 0)
work_entries = self.contract_cdd.generate_work_entries(start.date(), (end + relativedelta(days=2)).date())
work_entries.action_validate()
work_entry = self.env['hr.work.entry'].create({
'name': 'Extra',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdd.id,
'work_entry_type_id': self.work_entry_type.id,
'date': start.date(),
'duration': 7,
})
work_entry.action_validate()
payslip_run = self.env['hr.payslip.run'].create({
'date_start': Date.to_string(start),
'date_end': Date.to_string(end + relativedelta(days=1)),
})
payslip_run.generate_payslips(employee_ids=[self.richard_emp.id])
payslip = self.env['hr.payslip'].search([
('employee_id', '=', self.richard_emp.id),
('payslip_run_id', '=', payslip_run.id),
])
work_line = payslip.worked_days_line_ids.filtered(lambda l: l.work_entry_type_id == self.env.ref('hr_work_entry.work_entry_type_attendance')) # From default calendar.attendance
extra_work_line = payslip.worked_days_line_ids.filtered(lambda l: l.work_entry_type_id == self.work_entry_type)
self.assertTrue(work_line, "It should have a work line in the payslip")
self.assertTrue(extra_work_line, "It should have an extra work line in the payslip")
self.assertEqual(work_line.number_of_hours, 8.0, "It should have 8 hours of work") # Monday
self.assertEqual(extra_work_line.number_of_hours, 7.0, "It should have 7 hours of extra work") # Sunday
def test_payslip_generation_with_overtime_work_rate(self):
""" Test the computation of overtime amount in the payslip as per rate. """
self.env['hr.work.entry'].create(self.env['hr.version']._generate_work_entries_postprocess([{
'name': 'Overtime Work',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'work_entry_type_id': self.work_entry_type_overtime_duty.id,
'date_start': datetime(2024, 12, 16, 18, 0, 0),
'date_stop': datetime(2024, 12, 16, 22, 0, 0),
}]))
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'date_from': date(2024, 12, 1),
'date_to': date(2024, 12, 31),
})
payslip.compute_sheet()
overtime_line = payslip.worked_days_line_ids.filtered(lambda l: l.work_entry_type_id == self.work_entry_type_overtime_duty)
expected_hours = 4.0
expected_amount = 64.93
self.assertEqual(overtime_line.number_of_hours, expected_hours, "Overtime hours mismatch")
self.assertAlmostEqual(overtime_line.amount, expected_amount, delta=0.01, msg="Overtime amount calculation mismatch")
def test_work_data_with_exceeding_interval(self):
# The start and stop dates for these entries are stored in UTC.
# Note that the contract for these entries is for a Europe/Brussels
# timezone calendar.
# Also, note that since these entries are being made for December
# dates, the offset will only be +1 between UTC and Europe/Brussels,
# instead of +2, normally.
#
# Finally, we must test the boundaries of the payslip one at a time
# to ensure that the datetimes are converted correctly to their
# local timezones when computing the durations within the bounds
# of the payslip. If we tested both on the payslip at the same time,
# the timezone difference would shift them both by an equal amount,
# effectively keeping the valid durations the same.
entry_exceeding_lower_bound = self.env['hr.work.entry'].create(self.env['hr.version']._generate_work_entries_postprocess([{
'name': 'Attendance',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdd.id,
'work_entry_type_id': self.env.ref('hr_work_entry.work_entry_type_attendance').id,
'date_start': datetime(2015, 12, 12, 18, 0), # 19:00 local time
'date_stop': datetime(2015, 12, 13, 2, 0), # 03:00 local time
}]))
entry_exceeding_lower_bound.action_validate()
self.contract_cdd.generate_work_entries(date(2015, 12, 13), date(2015, 12, 13))
hours = self.contract_cdd.get_work_hours(date(2015, 12, 13), date(2015, 12, 13))
sum_hours = sum(v for k, v in hours.items() if k in self.env.ref('hr_work_entry.work_entry_type_attendance').ids)
# 3 hours after the lower bound
self.assertAlmostEqual(sum_hours, 3, delta=0.01, msg='It should count 3 attendance hours')
entry_exceeding_upper_bound = self.env['hr.work.entry'].create(self.env['hr.version']._generate_work_entries_postprocess([{
'name': 'Attendance',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdd.id,
'work_entry_type_id': self.env.ref('hr_work_entry.work_entry_type_attendance').id,
'date_start': datetime(2015, 12, 14, 18, 0), # 19:00 local time
'date_stop': datetime(2015, 12, 15, 2, 0), # 03:00 local time
}]))
entry_exceeding_upper_bound.action_validate()
self.contract_cdd.generate_work_entries(date(2015, 12, 14), date(2015, 12, 14))
hours = self.contract_cdd.get_work_hours(date(2015, 12, 14), date(2015, 12, 14))
sum_hours = sum(v for k, v in hours.items() if k in self.env.ref('hr_work_entry.work_entry_type_attendance').ids)
# 5 hours before the upper bound
self.assertAlmostEqual(sum_hours, 5, delta=0.01, msg='It should count 5 attendance hours')
def test_payslip_without_contract(self):
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'date_from': date(2015, 11, 1),
'date_to': date(2015, 11, 30)
})
self.assertTrue(payslip.version_id)
payslip.version_id = False
self.assertEqual(payslip._get_contract_wage(), 0, "It should have a default wage of 0")
self.assertEqual(payslip.basic_wage, 0, "It should have a default wage of 0")
self.assertEqual(payslip.gross_wage, 0, "It should have a default wage of 0")
self.assertEqual(payslip.net_wage, 0, "It should have a default wage of 0")
def test_payslip_with_salary_attachment(self):
#Create multiple salary attachments, some running, some closed
self.env['hr.salary.attachment'].create([
{
'employee_ids': [(4, self.richard_emp.id)],
'monthly_amount': 150,
'other_input_type_id': self.env.ref('hr_payroll.input_child_support').id,
'date_start': date(2016, 1, 1),
'description': 'Child Support',
},
{
'employee_ids': [(4, self.richard_emp.id)],
'monthly_amount': 400,
'total_amount': 1000,
'paid_amount': 1000,
'other_input_type_id': self.env.ref('hr_payroll.input_assignment_salary').id,
'date_start': date(2015, 1, 1),
'date_end': date(2015, 4, 1),
'description': 'Unpaid fine',
'state': 'close',
},
])
car_accident = self.env['hr.salary.attachment'].create({
'employee_ids': [(4, self.richard_emp.id)],
'monthly_amount': 250,
'paid_amount': 1450,
'total_amount': 1500,
'other_input_type_id': self.env.ref('hr_payroll.input_attachment_salary').id,
'date_start': date(2016, 1, 1),
'description': 'Car accident',
})
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31)
})
input_lines = payslip.input_line_ids
self.assertTrue(input_lines.filtered(lambda r: r.code == 'CHILD_SUPPORT'), 'There should be an input line for child support.')
self.assertTrue(input_lines.filtered(lambda r: r.code == 'ATTACH_SALARY'), 'There should be an input line for the car accident.')
self.assertTrue(input_lines.filtered(lambda r: r.code == 'ATTACH_SALARY').amount <= 50, 'The amount for the car accident input line should be 50 or less.')
self.assertFalse(input_lines.filtered(lambda r: r.code == 'ASSIG_SALARY'), 'There should not be an input line for the unpaid fine.')
payslip.compute_sheet()
lines = payslip.line_ids
self.assertTrue(lines.filtered(lambda r: r.code == 'CHILD_SUPPORT'), 'There should be a salary line for child support.')
self.assertTrue(lines.filtered(lambda r: r.code == 'ATTACH_SALARY'), 'There should be a salary line for car accident.')
payslip.action_payslip_done()
payslip.action_payslip_paid()
self.assertEqual(car_accident.state, 'close', 'The salary adjustment should be completed.')
def test_payslip_with_multiple_input_same_type(self):
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31)
})
self.env['hr.payslip.input'].create([
{
'payslip_id': payslip.id,
'sequence': 1,
'input_type_id': self.env.ref("hr_payroll.BASIC").id,
'amount': 100,
'version_id': self.contract_cdi.id
},
{
'payslip_id': payslip.id,
'sequence': 2,
'input_type_id': self.env.ref("hr_payroll.BASIC").id,
'amount': 200,
'version_id': self.contract_cdi.id
},
])
payslip.compute_sheet()
lines = payslip.line_ids
self.assertEqual(len(lines.filtered(lambda r: r.code == 'BASIC')), 1)
def test_payslip_with_multiple_input_same_type_aggregations(self):
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31)
})
self.env['hr.payslip.input'].create([
{
'payslip_id': payslip.id,
'sequence': 1,
'input_type_id': self.env.ref("hr_payroll.BASIC").id,
'amount': 300,
'version_id': self.contract_cdi.id,
'code': 'DED'
},
{
'payslip_id': payslip.id,
'sequence': 1,
'input_type_id': self.env.ref("hr_payroll.BASIC").id,
'amount': 200,
'version_id': self.contract_cdi.id,
'code': 'DED'
},
])
self.developer_pay_structure.write({
'rule_ids': [
(0, 0, {
'name': 'Test 1',
'sequence': 1000000,
'code': 'TEST1',
'category_id': self.env.ref('hr_payroll.ALW').id,
'amount_select': 'code',
'amount_python_compute': 'result = inputs["DEDUCTION"].amount',
}), (0, 0, {
'name': 'Test 2',
'sequence': 1000000,
'code': 'TEST2',
'category_id': self.env.ref('hr_payroll.ALW').id,
'amount_select': 'code',
'amount_python_compute': 'result = inputs["DEDUCTION"][0].amount + inputs["DEDUCTION"][1].amount',
}), (0, 0, {
'name': 'Test 3',
'sequence': 1000000,
'code': 'TEST3',
'category_id': self.env.ref('hr_payroll.ALW').id,
'amount_select': 'code',
'amount_python_compute': 'result = inputs["DEDUCTION"][0].amount',
}),
]})
payslip.compute_sheet()
assert(payslip.line_ids.filtered(lambda r: r.code == 'TEST1').total == 500.00)
assert(payslip.line_ids.filtered(lambda r: r.code == 'TEST2').total == 500.00)
assert(payslip.line_ids.filtered(lambda r: r.code == 'TEST3').total == 300.00)
def test_payslip_with_multiple_input_same_type_no_matching_rule_aggregations(self):
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31)
})
input_type = self.env['hr.payslip.input.type'].create({'name': 'ABCD', 'code': 'ABCD'})
self.env['hr.payslip.input'].create([
{
'payslip_id': payslip.id,
'sequence': 1,
'input_type_id': input_type.id,
'amount': 300,
'version_id': self.contract_cdi.id,
},
{
'payslip_id': payslip.id,
'sequence': 2,
'input_type_id': input_type.id,
'amount': 200,
'version_id': self.contract_cdi.id,
},
])
self.developer_pay_structure.write({'rule_ids': [
(0, 0, {
'name': 'Non matching code rule',
'sequence': 5,
'code': 'EFGH',
'category_id': self.env.ref('hr_payroll.COMP').id,
'amount_select': 'code',
'amount_python_compute': 'result = inputs["ABCD"].amount',
})]})
payslip.compute_sheet()
self.assertEqual(payslip.line_ids.filtered(lambda r: r.code == 'EFGH').total, 500.00)
def test_payslip_multiple_inputs_and_attachments_same_type(self):
self.env['hr.salary.attachment'].create([
{
'employee_ids': [self.richard_emp.id],
'monthly_amount': 100,
'paid_amount': 0,
'total_amount': 100,
'other_input_type_id': self.env.ref('hr_payroll.default_attachment_of_salary_rule').id,
'date_start': date(2016, 1, 1),
'description': 'Some attachment',
},
{
'employee_ids': [self.richard_emp.id],
'monthly_amount': 200,
'paid_amount': 0,
'total_amount': 200,
'other_input_type_id': self.env.ref('hr_payroll.default_attachment_of_salary_rule').id,
'date_start': date(2016, 1, 1),
'description': 'Another attachment',
},
])
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31),
})
with Form(payslip) as payslip_form:
payslip_form.input_line_ids.remove(0) # remove merged input of attachments and use 2 inputs instead
with payslip_form.input_line_ids.new() as line:
line.input_type_id = self.env.ref("hr_payroll.input_assignment_salary")
line.amount = 100
with payslip_form.input_line_ids.new() as line:
line.input_type_id = self.env.ref("hr_payroll.input_assignment_salary")
line.amount = 200
payslip.compute_sheet()
payslip.action_payslip_done()
payslip.action_payslip_paid()
self.assertEqual(sum(s.paid_amount for s in payslip.salary_attachment_ids), 100 + 200)
def test_defaultdict_get(self):
# defaultdict.get(key) returns None if the key doesn't exist instead of default factory value
# which could lead to a traceback
self.developer_pay_structure.rule_ids.filtered(lambda r: r.code == "NET").amount_python_compute = "result = categories['BASIC'] + categories['ALW'] + categories['DED'] + categories.get('TEST')"
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'date_from': date(2015, 11, 1),
'date_to': date(2015, 11, 30)
})
payslip.compute_sheet()
def test_payslip_warning_message_without_duration_dates(self):
payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'date_from': date(2016, 1, 15),
'date_to': date(2016, 1, 31)
})
self.assertTrue(payslip.issues)
payslip_form = Form(payslip)
payslip_form.date_from = date(2016, 1, 16) # setting any other date reset the end date
self.assertFalse(payslip_form.issues)
def test_payslip_state_effect_on_work_entries(self):
richard_work_entry = self.env['hr.work.entry'].create({
'name': 'Extra',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdd.id,
'work_entry_type_id': self.work_entry_type.id,
'date': date(2016, 1, 1),
'duration': 1,
})
# Validated work entries linked to a payslip can be edited
self.assertEqual(richard_work_entry.state, 'draft')
self.assertFalse(richard_work_entry.has_payslip, False)
self.richard_payslip.action_payslip_done()
self.assertEqual(richard_work_entry.state, 'validated')
self.assertEqual(richard_work_entry.has_payslip, True)
richard_work_entry.write({'state': 'draft'})
self.assertEqual(richard_work_entry.state, 'draft')
def test_duplicate_payslips_cancellation(self):
work_entries = self.env['hr.work.entry'].search([
('employee_id', '=', self.richard_emp.id),
('date', '>=', self.richard_payslip.date_from),
('date', '<=', self.richard_payslip.date_to)])
self.richard_payslip.action_payslip_done()
self.assertTrue(all(entry.state == 'validated' for entry in work_entries))
dup_payslip = self.env['hr.payslip'].create({
'name': 'Payslip of Richard',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'struct_id': self.developer_pay_structure.id,
'date_from': date(2016, 1, 1),
'date_to': date(2016, 1, 31)
})
dup_payslip.action_payslip_done()
self.assertTrue(all(entry.state == 'validated' for entry in work_entries))
self.richard_payslip.action_payslip_cancel()
self.assertTrue(all(entry.state == 'validated' for entry in work_entries))
dup_payslip.action_payslip_cancel()
self.assertTrue(all(entry.state == 'draft' for entry in work_entries))
self.assertTrue(all(not entry.has_payslip for entry in work_entries))
def test_out_of_contract_worked_days(self):
self.contract_cdi.write({
'contract_date_start': datetime.strptime('2025-06-15', '%Y-%m-%d'),
'contract_date_end': datetime.strptime('2025-07-15', '%Y-%m-%d'),
})
payslips = self.env['hr.payslip'].create([
{
'name': 'February', # Before contract
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'struct_id': self.developer_pay_structure.id,
'date_from': date(2025, 2, 1),
'date_to': date(2025, 2, 28),
},
{
'name': 'June', # Partial overlap
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'struct_id': self.developer_pay_structure.id,
'date_from': date(2025, 6, 1),
'date_to': date(2025, 6, 30),
},
{
'name': 'November', # After contract
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'struct_id': self.developer_pay_structure.id,
'date_from': date(2025, 11, 1),
'date_to': date(2025, 11, 30),
},
])
payslips._compute_worked_days_line_ids()
expectations = {
'February': (20.0, 140.0),
'June': (10.0, 70.0),
'November': (20.0, 140.0),
}
out_of_contract_code = self.env.ref('hr_work_entry.hr_work_entry_type_out_of_contract').code
for payslip in payslips:
line = payslip.worked_days_line_ids.filtered(lambda l: l.code == out_of_contract_code)
exp_days, exp_hours = expectations[payslip.name]
self.assertAlmostEqual(line.number_of_days, exp_days, places=2, msg=f"Wrong days for {payslip.name}")
self.assertAlmostEqual(line.number_of_hours, exp_hours, places=2, msg=f"Wrong hours for {payslip.name}")
def test_payslip_fully_flexible_employee_without_calendar(self):
"""
Test that payslips and worked days lines are computed for fully flexible employees.
"""
flexible_employee = self.env['hr.employee'].create({
'name': 'Fully Flexible Employee',
'company_id': self.env.company.id,
'resource_calendar_id': False,
})
flexible_contract = self.env['hr.version'].create({
'name': 'Contract - Fully Flexible Employee',
'employee_id': flexible_employee.id,
'contract_date_start': date(2024, 1, 1),
'date_version': date(2024, 1, 1),
'wage': 5000.0,
'structure_type_id': self.structure_type.id,
'resource_calendar_id': False,
})
# Create a payslip for the flexible employee
payslip = self.env['hr.payslip'].create({
'name': 'Test Payslip - Fully Flexible Employee',
'employee_id': flexible_employee.id,
'version_id': flexible_contract.id,
'struct_id': self.developer_pay_structure.id,
'date_from': date(2024, 1, 1),
'date_to': date(2024, 1, 31),
})
payslip._compute_worked_days_line_ids()
self.assertIsNotNone(
payslip.worked_days_line_ids,
"Worked days lines should be generated for fully flexible employees"
)
payslip.compute_sheet()
self.assertTrue(
payslip.line_ids,
"Payslip lines should be generated for fully flexible employees"
)
self.assertEqual(payslip.basic_wage, 5000.0,
"Basic wage should equal contract wage for flexible employees")
self.assertGreater(payslip.net_wage, 0,
"Net wage should be greater than 0 for flexible employees")
self.assertGreater(payslip.gross_wage, 0,
"Gross wage should be computed for flexible employees")
self.assertAlmostEqual(payslip.net_wage, 7905.0, places=2,
msg="Net wage computation changed for flexible employees - possible regression")
def test_warning_cleared_after_recompute(self):
payslip = self.env['hr.payslip'].create({
'name': 'Payslip Test Warning',
'employee_id': self.richard_emp.id,
'version_id': self.contract_cdi.id,
'struct_id': self.developer_pay_structure.id,
'date_from': date(2026, 1, 1),
'date_to': date(2026, 1, 31),
})
payslip.compute_sheet()
payslip.action_payslip_done()
# Make a modification on the version that should trigger the warning on the payslip
# Note: we need to wait a bit before writing on the version to ensure that the payslip has been fully computed and the warning triggered before the write
# ,otherwise the warning will not be triggered at all and the test will be invalid.
self.richard_emp.write({
'wage': 10000.0,
})
# Cancel & draft the payslip
payslip.action_payslip_cancel()
payslip.action_payslip_draft()
# Recompute & validate the payslip again
payslip.compute_sheet()
payslip.action_payslip_done()
self.assertFalse(payslip.has_wrong_data, "Warning should not reappear after recompute.")
def test_structure_without_worked_days_clears_lines(self):
'''Changing the structure type to one that does not use worked days lines should clear the worked days lines immediately.'''
no_worked_days_structure = self.env['hr.payroll.structure'].create({
'name': '13th Month Structure',
'type_id': self.developer_pay_structure.type_id.id,
'use_worked_day_lines': False,
'country_id': self.env.ref('base.be').id if self.env.ref('base.be', False) else False,
})
self.richard_payslip._compute_worked_days_line_ids()
self.assertTrue(
self.richard_payslip.worked_days_line_ids,
"Initial payslip should have worked day lines generated."
)
with Form(self.richard_payslip) as payslip_form:
payslip_form.struct_id = no_worked_days_structure
self.assertFalse(
self.richard_payslip.worked_days_line_ids,
"Worked days lines must be cleared immediately when switching to a structure with use_worked_day_lines=False."
)
with Form(self.richard_payslip) as payslip_form:
payslip_form.struct_id = self.developer_pay_structure
self.assertTrue(
self.richard_payslip.worked_days_line_ids,
"Worked days lines should be re-computed when switching back to a regular structure."
)