diff --git a/hr_payroll_attendance/__init__.py b/hr_payroll_attendance/__init__.py new file mode 100755 index 00000000..d3b52674 --- /dev/null +++ b/hr_payroll_attendance/__init__.py @@ -0,0 +1,15 @@ +from . import models + + +def attn_payroll_pre_init_hook(cr): + """ + This module installs a Work Entry Type with code "ATTN_OT" + If you have undergone a migration (either for this module + or even your own Payslip Work Entry lines with code "ATTN_OT") + then the uniqueness constraint will prevent this module + from installing. + """ + cr.execute("UPDATE hr_work_entry_type " + "SET code = 'ATTN_OT-PRE-INSTALL-14' " + "WHERE code = 'ATTN_OT';" + ) diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py new file mode 100755 index 00000000..474f139a --- /dev/null +++ b/hr_payroll_attendance/__manifest__.py @@ -0,0 +1,24 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Attendance on Payslips', + 'description': 'Get Attendence numbers onto Employee Payslips.', + 'version': '15.0.1.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'OPL-1', + 'category': 'Human Resources', + 'data': [ + 'data/hr_payroll_attendance_data.xml', + 'views/hr_attendance_views.xml', + 'views/hr_contract_views.xml', + 'views/hr_payslip_views.xml', + ], + 'depends': [ + 'hr_payroll_hibou', + 'hr_attendance_work_entry', + 'hr_payroll_overtime', + 'hibou_professional', + ], + 'pre_init_hook': 'attn_payroll_pre_init_hook', +} diff --git a/hr_payroll_attendance/data/hr_payroll_attendance_data.xml b/hr_payroll_attendance/data/hr_payroll_attendance_data.xml new file mode 100644 index 00000000..c4efdc82 --- /dev/null +++ b/hr_payroll_attendance/data/hr_payroll_attendance_data.xml @@ -0,0 +1,15 @@ + + + + + + Attendance Overtime + ATTN_OT + + + + + + + + diff --git a/hr_payroll_attendance/models/__init__.py b/hr_payroll_attendance/models/__init__.py new file mode 100644 index 00000000..66b97981 --- /dev/null +++ b/hr_payroll_attendance/models/__init__.py @@ -0,0 +1,3 @@ +from . import hr_attendance +from . import hr_contract +from . import hr_payslip diff --git a/hr_payroll_attendance/models/hr_attendance.py b/hr_payroll_attendance/models/hr_attendance.py new file mode 100644 index 00000000..6baa4832 --- /dev/null +++ b/hr_payroll_attendance/models/hr_attendance.py @@ -0,0 +1,33 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class HrAttendance(models.Model): + _inherit = 'hr.attendance' + + payslip_id = fields.Many2one('hr.payslip', string="Payslip", ondelete='set null') + + @api.model_create_multi + def create(self, vals_list): + if isinstance(vals_list, dict): + vals_list = [vals_list] + + payslips = self.env['hr.payslip'].sudo().browse([d.get('payslip_id', 0) for d in vals_list]) + if any(p.state not in ('draft', 'verify') for p in payslips.exists()): + raise ValidationError('Cannot create attendance linked to payslip that is not draft.') + return super().create(vals_list) + + def write(self, values): + payslip_id = values.get('payslip_id') + if payslip_id: + payslip = self.env['hr.payslip'].sudo().browse(payslip_id) + if payslip.exists().state not in ('draft', 'verify'): + raise ValidationError('Cannot modify attendance linked to payslip that is not draft.') + return super().write(values) + + def unlink(self): + attn_with_payslip = self.filtered(lambda a: a.payslip_id) + attn_with_payslip.write({'payslip_id': False}) + return super(HrAttendance, self - attn_with_payslip).unlink() diff --git a/hr_payroll_attendance/models/hr_contract.py b/hr_payroll_attendance/models/hr_contract.py new file mode 100755 index 00000000..d49f437c --- /dev/null +++ b/hr_payroll_attendance/models/hr_contract.py @@ -0,0 +1,16 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, models, fields + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + paid_hourly_attendance = fields.Boolean(string="Paid Hourly Attendance") + + @api.onchange('paid_hourly_attendance') + def _onchange_paid_hourly_attendance(self): + for contract in self: + if contract.paid_hourly_attendance: + # only allow switch, not automatic switch 'back' + contract.wage_type = 'hourly' diff --git a/hr_payroll_attendance/models/hr_payslip.py b/hr_payroll_attendance/models/hr_payslip.py new file mode 100755 index 00000000..8df33731 --- /dev/null +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -0,0 +1,76 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + attendance_ids = fields.One2many('hr.attendance', 'payslip_id', string='Attendances', + help='Attendances represented by payslip.', readonly=True, + states={'draft': [('readonly', False)], 'verify': [('readonly', False)]}) + attendance_count = fields.Integer(compute='_compute_attendance_count') + + @api.depends('attendance_ids', 'attendance_ids.payslip_id') + def _compute_attendance_count(self): + for payslip in self: + payslip.attendance_count = len(payslip.attendance_ids) + + def _filter_worked_day_lines_values(self, worked_day_lines_values): + worked_day_lines_values = super()._filter_worked_day_lines_values(worked_day_lines_values) + if self.contract_id.paid_hourly_attendance: + original_work_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False) + if original_work_type: + # filter out "work calendar lines" + return [w for w in worked_day_lines_values if w['work_entry_type_id'] != original_work_type.id] + return worked_day_lines_values + + def _pre_aggregate_work_data(self): + work_data = super()._pre_aggregate_work_data() + if self.contract_id.paid_hourly_attendance: + attendance_to_keep = self.attendance_ids.filtered(lambda a: a.employee_id == self.employee_id + and a.check_out.date() <= self.date_to) + attendance_to_keep |= self.env['hr.attendance'].search([ + ('employee_id', '=', self.employee_id.id), + ('check_out', '<=', self.date_to), + ('payslip_id', '=', False), + ]) + self.update({'attendance_ids': [(6, 0, attendance_to_keep.ids)]}) + + attendance_type = self.env.ref('hr_attendance_work_entry.work_input_attendance', raise_if_not_found=False) + if not attendance_type: + # different default type + attendance_type = self.struct_id.type_id.default_work_entry_type_id + if not attendance_type: + # return early, include the "work calendar lines" + return work_data + work_data = self._pre_aggregate_attendance_data(work_data, attendance_type) + return work_data + + def _pre_aggregate_attendance_data(self, work_data, default_workentrytype): + for attn in self.attendance_ids: + if attn.worked_hours: + # Avoid in/outs + attn_iso = attn.check_in.isocalendar() + attendance_type = attn.work_type_id or default_workentrytype + if attendance_type in self.struct_id.unpaid_work_entry_type_ids: + # this is unpaid, so we have to skip it from aggregation + # if we don't then they will be eligible for overtime even + # if this time wasn't intended to be paid + continue + work_data[attn_iso].append((attendance_type, attn.worked_hours, attn)) + return work_data + + def action_open_attendances(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Paid Attendances'), + 'res_model': 'hr.attendance', + 'view_mode': 'tree,form', + 'context': { + 'default_employee_id': self.employee_id.id, + 'default_payslip_id': self.id, + }, + 'domain': [('id', 'in', self.attendance_ids.ids)], + } diff --git a/hr_payroll_attendance/tests/__init__.py b/hr_payroll_attendance/tests/__init__.py new file mode 100644 index 00000000..8904658a --- /dev/null +++ b/hr_payroll_attendance/tests/__init__.py @@ -0,0 +1 @@ +from . import test_payroll_attendance diff --git a/hr_payroll_attendance/tests/test_payroll_attendance.py b/hr_payroll_attendance/tests/test_payroll_attendance.py new file mode 100644 index 00000000..4dcb708a --- /dev/null +++ b/hr_payroll_attendance/tests/test_payroll_attendance.py @@ -0,0 +1,161 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo.addons.hr_payroll_hibou.tests import common +from odoo.exceptions import ValidationError + + +class TestAttendancePayslip(common.TestPayslip): + + def setUp(self): + super().setUp() + self.work_type = self.env.ref('hr_attendance_work_entry.work_input_attendance') + self.overtime_rules = self.work_type.overtime_type_id + self.overtime_rules.hours_per_day = 0.0 + self.overtime_rules.multiplier = 1.5 + self.test_hourly_wage = 21.5 + self.employee = self._createEmployee() + self.contract = self._createContract(self.employee, + wage=self.test_hourly_wage, + hourly_wage=self.test_hourly_wage, + wage_type='hourly', + paid_hourly_attendance=True) + + self.work_entry_type_leave = self.env['hr.work.entry.type'].create({ + 'name': 'Test PTO', + 'code': 'TESTPTO', + 'is_leave': True, + }) + + def _setup_attendance(self, employee): + # Total 127.37 hours in 2 weeks. + # Six 9-hour days in one week (plus a little). 58.97 hours in that week. + attendances = self.env['hr.attendance'] + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-06 10:00:00', # Monday + 'check_out': '2020-01-06 19:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-07 10:00:00', + 'check_out': '2020-01-07 19:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-08 10:00:00', + 'check_out': '2020-01-08 19:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-09 10:00:00', + 'check_out': '2020-01-09 19:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-10 10:00:00', + 'check_out': '2020-01-10 19:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-11 06:00:00', + 'check_out': '2020-01-11 19:58:12', + }) + + # Five 10-hour days, Two 9-hour days (plus a little) in one week. 68.4 hours in that week + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-13 08:00:00', # Monday + 'check_out': '2020-01-13 18:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-14 08:00:00', + 'check_out': '2020-01-14 18:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-15 08:00:00', + 'check_out': '2020-01-15 18:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-16 08:00:00', + 'check_out': '2020-01-16 18:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-17 08:00:00', + 'check_out': '2020-01-17 18:00:00', + }) + attendances += self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-18 09:00:00', + 'check_out': '2020-01-18 18:00:00', + }) + last = self.env['hr.attendance'].create({ + 'employee_id': employee.id, + 'check_in': '2020-01-19 09:00:00', + 'check_out': '2020-01-19 18:24:00', + }) + attendances += last + return last + + def test_attendance_hourly(self): + attn_last = self._setup_attendance(self.employee) + self.payslip = self._createPayslip(self.employee, '2020-01-06', '2020-01-19') + self.assertTrue(self.payslip.contract_id, 'No auto-discovered contract!') + self.payslip.compute_sheet() + # 58.97 => 40hr regular, 18.97hr OT + # 68.4 => 40hr regular, 28.4hr OT + # (80 * 21.50) + (47.37 * 21.50 * 1.5) = 3247.6825 + cats = self._getCategories(self.payslip) + self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) + + # ensure unlink behavior. + self.payslip.attendance_ids = self.env['hr.attendance'].browse() + self.payslip.state = 'draft' + self.payslip.flush() + self.payslip.action_refresh_from_work_entries() + self.payslip.compute_sheet() + cats = self._getCategories(self.payslip) + self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) + + self.payslip.write({'attendance_ids': [(5, 0, 0)]}) + self.payslip.state = 'draft' + self.payslip.flush() + self.payslip.action_refresh_from_work_entries() + self.payslip.compute_sheet() + cats = self._getCategories(self.payslip) + self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) + + self.process_payslip() + self.assertTrue(self.payslip.state not in ('draft', 'verify')) + self.assertEqual(self.payslip, attn_last.payslip_id) + # can empty, by design you have to be able to do so + attn_last.payslip_id = False + with self.assertRaises(ValidationError): + # cannot re-assign as it is a finished payslip + attn_last.payslip_id = self.payslip + + def test_with_leave(self): + date_from = '2020-01-10' + date_to = '2020-01-11' + self.env['resource.calendar.leaves'].create({ + 'name': 'Doctor Appointment', + 'date_from': date_from, + 'date_to': date_to, + 'resource_id': self.employee.resource_id.id, + 'calendar_id': self.employee.resource_calendar_id.id, + 'work_entry_type_id': self.work_entry_type_leave.id, + 'time_type': 'leave', + }) + + self._setup_attendance(self.employee) + self.payslip = self._createPayslip(self.employee, '2020-01-06', '2020-01-19') + self.assertTrue(self.payslip.worked_days_line_ids) + + leave_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TESTPTO') + self.assertTrue(leave_line) + self.assertEqual(leave_line.number_of_days, 1.0) + self.assertEqual(leave_line.number_of_hours, 8.0) + self.assertEqual(leave_line.amount, 8.0 * self.test_hourly_wage) diff --git a/hr_payroll_attendance/views/hr_attendance_views.xml b/hr_payroll_attendance/views/hr_attendance_views.xml new file mode 100644 index 00000000..28a63e7f --- /dev/null +++ b/hr_payroll_attendance/views/hr_attendance_views.xml @@ -0,0 +1,18 @@ + + + + + hr.attendance.tree.inherit + hr.attendance + + + + + + + + + + + diff --git a/hr_payroll_attendance/views/hr_contract_views.xml b/hr_payroll_attendance/views/hr_contract_views.xml new file mode 100755 index 00000000..467f3095 --- /dev/null +++ b/hr_payroll_attendance/views/hr_contract_views.xml @@ -0,0 +1,16 @@ + + + + + hr.contract.form.inherit + hr.contract + 20 + + + + + + + + + diff --git a/hr_payroll_attendance/views/hr_payslip_views.xml b/hr_payroll_attendance/views/hr_payslip_views.xml new file mode 100644 index 00000000..90a390d9 --- /dev/null +++ b/hr_payroll_attendance/views/hr_payslip_views.xml @@ -0,0 +1,20 @@ + + + + + hr.payslip.view.form.inherit + hr.payslip + + + + + + + + + + + +