diff --git a/hr_payroll_attendance/__init__.py b/hr_payroll_attendance/__init__.py new file mode 100755 index 00000000..0650744f --- /dev/null +++ b/hr_payroll_attendance/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py new file mode 100755 index 00000000..e8cf0a9b --- /dev/null +++ b/hr_payroll_attendance/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Attendance on Payslips', + 'description': 'Get Attendence numbers onto Employee Payslips.', + 'version': '13.0.1.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'data': [ + 'data/hr_payroll_attendance_data.xml', + 'views/hr_contract_views.xml', + 'views/hr_payslip_views.xml', + ], + 'depends': [ + 'hr_payroll', + 'hr_attendance', + 'hr_payroll_overtime', + ], +} 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..6023dd08 --- /dev/null +++ b/hr_payroll_attendance/data/hr_payroll_attendance_data.xml @@ -0,0 +1,21 @@ + + + + + + Attendance Overtime + ATTN_OT + + + Attendance + ATTN + + + + + + + Work Calendar + + + 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..de4a0d3b --- /dev/null +++ b/hr_payroll_attendance/models/hr_attendance.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class HrAttendance(models.Model): + _inherit = 'hr.attendance' + + payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True) diff --git a/hr_payroll_attendance/models/hr_contract.py b/hr_payroll_attendance/models/hr_contract.py new file mode 100755 index 00000000..c6a70483 --- /dev/null +++ b/hr_payroll_attendance/models/hr_contract.py @@ -0,0 +1,7 @@ +from odoo import models, fields + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + paid_hourly_attendance = fields.Boolean(string="Paid Hourly Attendance") diff --git a/hr_payroll_attendance/models/hr_payslip.py b/hr_payroll_attendance/models/hr_payslip.py new file mode 100755 index 00000000..b41f9648 --- /dev/null +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -0,0 +1,89 @@ +from collections import defaultdict +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.', + 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) + + @api.onchange('worked_days_line_ids') + def _onchange_worked_days_line_ids(self): + # super()._onchange_worked_days_line_ids() + attendance_type = self.env.ref('hr_payroll_attendance.work_input_attendance', raise_if_not_found=False) + if not self.worked_days_line_ids.filtered(lambda line: line.work_entry_type_id == attendance_type): + self.attendance_ids.write({'payslip_id': False}) + + @api.onchange('employee_id', 'struct_id', 'contract_id', 'date_from', 'date_to') + def _onchange_employee(self): + res = super()._onchange_employee() + if self.state == 'draft' and self.contract_id.paid_hourly_attendance: + self.attendance_ids = self.env['hr.attendance'].search([ + ('employee_id', '=', self.employee_id.id), + ('check_out', '<=', self.date_to), + '|', ('payslip_id', '=', False), + ('payslip_id', '=', self.id), + ]) + self._onchange_attendance_ids() + return res + + @api.onchange('attendance_ids') + def _onchange_attendance_ids(self): + original_work_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False) + attendance_type = self.env.ref('hr_payroll_attendance.work_input_attendance', raise_if_not_found=False) + if not attendance_type: + return + + if original_work_type: + types_to_remove = original_work_type + attendance_type + else: + types_to_remove = attendance_type + + work_data = self._pre_aggregate_attendance_data() + processed_data = self.aggregate_overtime(work_data) + + # TODO is it appropriate to remove all lines? Ideally we would only remove the old type. + lines_to_keep = self.worked_days_line_ids.filtered(lambda x: x.work_entry_type_id not in types_to_remove) + work_lines_vals = [(5, 0, 0)] + [(4, line.id, False) for line in lines_to_keep] + # work_lines_vals = [(5, 0, 0)] + work_lines_vals += [(0, 0, { + 'number_of_days': data[0], + 'number_of_hours': data[1], + 'amount': data[1] * data[2] * self._wage_for_work_type(work_type), + 'contract_id': self.contract_id.id, + 'work_entry_type_id': work_type.id, + }) for work_type, data in processed_data.items()] + self.update({'worked_days_line_ids': work_lines_vals}) + + def _wage_for_work_type(self, work_type): + # Override if you pay differently for different work types + return self.contract_id.wage + + def _pre_aggregate_attendance_data(self): + attendance_type = self.env.ref('hr_payroll_attendance.work_input_attendance', raise_if_not_found=False) + worked_attn = defaultdict(list) + for attn in self.attendance_ids: + if attn.worked_hours: + # Avoid in/outs + attn_iso = attn.check_in.isocalendar() + worked_attn[attn_iso].append((attendance_type, attn.worked_hours, attn)) + res = [(k, worked_attn[k]) for k in sorted(worked_attn.keys())] + return res + + 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', + '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..80472a52 --- /dev/null +++ b/hr_payroll_attendance/tests/test_payroll_attendance.py @@ -0,0 +1,122 @@ +from collections import defaultdict +from odoo.tests import common + + +class TestUsPayslip(common.TransactionCase): + + def setUp(self): + super().setUp() + self.test_hourly_wage = 21.5 + self.employee = self.env.ref('hr.employee_hne') + self.contract = self.env['hr.contract'].create({ + 'name': 'Test', + 'employee_id': self.employee.id, + 'structure_type_id': self.env.ref('hr_payroll.structure_type_employee').id, + 'date_start': '2020-01-01', + 'resource_calendar_id': self.employee.resource_calendar_id.id, + 'wage': self.test_hourly_wage, + 'paid_hourly_attendance': True, + 'state': 'open', + }) + self._setup_attendance(self.employee) + self.payslip = self.env['hr.payslip'].create({ + 'name': 'test slip', + 'employee_id': self.employee.id, + 'date_from': '2020-01-06', + 'date_to': '2020-01-19', + }) + + 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 _getCategories(self): + categories = defaultdict(float) + for line in self.payslip.line_ids: + category_id = line.category_id + category_code = line.category_id.code + while category_code: + categories[category_code] += line.total + category_id = category_id.parent_id + category_code = category_id.code + return categories + + def test_attendance_hourly(self): + self.payslip._onchange_employee() + 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.assertAlmostEqual(cats['BASIC'], 3247.68, 2) 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..6ef64386 --- /dev/null +++ b/hr_payroll_attendance/views/hr_contract_views.xml @@ -0,0 +1,22 @@ + + + + + hr.contract.form.inherit + hr.contract + 20 + + + + + + + + / pay period + / hour + + + + + + 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..292f413f --- /dev/null +++ b/hr_payroll_attendance/views/hr_payslip_views.xml @@ -0,0 +1,20 @@ + + + + + hr.payslip.view.form.inherit + hr.payslip + + + + + + + + + + + +