diff --git a/hr_payroll_attendance/__init__.py b/hr_payroll_attendance/__init__.py index f1f0fe2e..0650744f 100755 --- a/hr_payroll_attendance/__init__.py +++ b/hr_payroll_attendance/__init__.py @@ -1,2 +1 @@ -from . import hr_payslip -from . import hr_contract +from . import models diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py index 9a1e4976..e8cf0a9b 100755 --- a/hr_payroll_attendance/__manifest__.py +++ b/hr_payroll_attendance/__manifest__.py @@ -1,16 +1,19 @@ { 'name': 'Attendance on Payslips', 'description': 'Get Attendence numbers onto Employee Payslips.', - 'version': '12.0.1.0.0', + 'version': '13.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', 'category': 'Human Resources', 'data': [ - 'hr_contract_view.xml', + '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/hr_contract_view.xml b/hr_payroll_attendance/hr_contract_view.xml deleted file mode 100755 index 5022cc3c..00000000 --- a/hr_payroll_attendance/hr_contract_view.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - hr.contract.form.inherit - hr.contract - 20 - - - - - - - - / pay period - / hour - - - - - - diff --git a/hr_payroll_attendance/hr_payslip.py b/hr_payroll_attendance/hr_payslip.py deleted file mode 100755 index 2d028793..00000000 --- a/hr_payroll_attendance/hr_payslip.py +++ /dev/null @@ -1,89 +0,0 @@ -from collections import defaultdict -from odoo import api, models -from odoo.exceptions import ValidationError - - -class HrPayslip(models.Model): - _inherit = 'hr.payslip' - - @api.model - def get_worked_day_lines(self, contracts, date_from, date_to): - def create_empty_worked_lines(employee, contract, date_from, date_to): - attn = { - 'name': 'Attendance', - 'sequence': 10, - 'code': 'ATTN', - 'number_of_days': 0.0, - 'number_of_hours': 0.0, - 'contract_id': contract.id, - } - - valid_attn = [ - ('employee_id', '=', employee.id), - ('check_in', '>=', date_from), - ('check_in', '<=', date_to), - ] - return attn, valid_attn - - work = [] - for contract in contracts.filtered(lambda c: c.paid_hourly_attendance): - worked_attn, valid_attn = create_empty_worked_lines( - contract.employee_id, - contract, - date_from, - date_to - ) - days = set() - for attn in self.env['hr.attendance'].search(valid_attn): - if not attn.check_out: - raise ValidationError('This pay period must not have any open attendances.') - if attn.worked_hours: - # Avoid in/outs - attn_iso = attn.check_in.isocalendar() - if not attn_iso in days: - worked_attn['number_of_days'] += 1 - days.add(attn_iso) - worked_attn['number_of_hours'] += attn.worked_hours - worked_attn['number_of_hours'] = round(worked_attn['number_of_hours'], 2) - work.append(worked_attn) - - res = super(HrPayslip, self).get_worked_day_lines(contracts.filtered(lambda c: not c.paid_hourly_attendance), date_from, date_to) - res.extend(work) - return res - - @api.multi - def hour_break_down(self, code): - """ - :param code: what kind of worked days you need aggregated - :return: dict: keys are isocalendar tuples, values are hours. - """ - self.ensure_one() - if code == 'ATTN': - attns = self.env['hr.attendance'].search([ - ('employee_id', '=', self.employee_id.id), - ('check_in', '>=', self.date_from), - ('check_in', '<=', self.date_to), - ]) - day_values = defaultdict(float) - for attn in attns: - if not attn.check_out: - raise ValidationError('This pay period must not have any open attendances.') - if attn.worked_hours: - # Avoid in/outs - attn_iso = attn.check_in.isocalendar() - day_values[attn_iso] += attn.worked_hours - return day_values - elif hasattr(super(HrPayslip, self), 'hour_break_down'): - return super(HrPayslip, self).hour_break_down(code) - - @api.multi - def hours_break_down_week(self, code): - """ - :param code: hat kind of worked days you need aggregated - :return: dict: keys are isocalendar weeks, values are hours. - """ - days = self.hour_break_down(code) - weeks = defaultdict(float) - for isoday, hours in days.items(): - weeks[isoday[1]] += hours - return weeks 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/hr_contract.py b/hr_payroll_attendance/models/hr_contract.py similarity index 85% rename from hr_payroll_attendance/hr_contract.py rename to hr_payroll_attendance/models/hr_contract.py index ebb0381f..c6a70483 100755 --- a/hr_payroll_attendance/hr_contract.py +++ b/hr_payroll_attendance/models/hr_contract.py @@ -4,4 +4,4 @@ from odoo import models, fields class HrContract(models.Model): _inherit = 'hr.contract' - paid_hourly_attendance = fields.Boolean(string="Paid Hourly Attendance", default=False) + 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 + + + + + + + + + + + +