From b69b22084a507598199ad937fafc0c080913580b Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Mon, 14 May 2018 16:08:30 -0700 Subject: [PATCH 01/28] Initial commit of `hr_payroll_attendance` and `hr_payroll_attendance_holidays` modules for 11.0. The purpose of this new functionality is to better distinguish between timesheets and attendance, as well as preventing the stock "salaried" time computations from working. --- hr_payroll_attendance/__init__.py | 2 + hr_payroll_attendance/__manifest__.py | 16 ++++ hr_payroll_attendance/hr_contract.py | 7 ++ hr_payroll_attendance/hr_contract_view.xml | 22 +++++ hr_payroll_attendance/hr_payslip.py | 93 ++++++++++++++++++++++ 5 files changed, 140 insertions(+) create mode 100755 hr_payroll_attendance/__init__.py create mode 100755 hr_payroll_attendance/__manifest__.py create mode 100755 hr_payroll_attendance/hr_contract.py create mode 100755 hr_payroll_attendance/hr_contract_view.xml create mode 100755 hr_payroll_attendance/hr_payslip.py diff --git a/hr_payroll_attendance/__init__.py b/hr_payroll_attendance/__init__.py new file mode 100755 index 00000000..f1f0fe2e --- /dev/null +++ b/hr_payroll_attendance/__init__.py @@ -0,0 +1,2 @@ +from . import hr_payslip +from . import hr_contract diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py new file mode 100755 index 00000000..4aa42648 --- /dev/null +++ b/hr_payroll_attendance/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': 'Attendance on Payslips', + 'description': 'Get Attendence numbers onto Employee Payslips.', + 'version': '11.0.1.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'data': [ + 'hr_contract_view.xml', + ], + 'depends': [ + 'hr_payroll', + 'hr_attendance', + ], +} diff --git a/hr_payroll_attendance/hr_contract.py b/hr_payroll_attendance/hr_contract.py new file mode 100755 index 00000000..ebb0381f --- /dev/null +++ b/hr_payroll_attendance/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", default=False) diff --git a/hr_payroll_attendance/hr_contract_view.xml b/hr_payroll_attendance/hr_contract_view.xml new file mode 100755 index 00000000..5022cc3c --- /dev/null +++ b/hr_payroll_attendance/hr_contract_view.xml @@ -0,0 +1,22 @@ + + + + + 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 new file mode 100755 index 00000000..e4561032 --- /dev/null +++ b/hr_payroll_attendance/hr_payslip.py @@ -0,0 +1,93 @@ +from datetime import datetime +from collections import defaultdict +from odoo import api, models +from odoo.exceptions import ValidationError +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +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_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) + attn_iso = attn_start_time.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_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) + attn_iso = attn_start_time.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 From d457394b64554be5ba282e80b4f0f42e03606966 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 12 Mar 2019 11:20:02 -0700 Subject: [PATCH 02/28] MIG `hr_payroll_attendance` to 12.0 --- hr_payroll_attendance/__manifest__.py | 2 +- hr_payroll_attendance/hr_payslip.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py index 4aa42648..9a1e4976 100755 --- a/hr_payroll_attendance/__manifest__.py +++ b/hr_payroll_attendance/__manifest__.py @@ -1,7 +1,7 @@ { 'name': 'Attendance on Payslips', 'description': 'Get Attendence numbers onto Employee Payslips.', - 'version': '11.0.1.0.0', + 'version': '12.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', diff --git a/hr_payroll_attendance/hr_payslip.py b/hr_payroll_attendance/hr_payslip.py index e4561032..2d028793 100755 --- a/hr_payroll_attendance/hr_payslip.py +++ b/hr_payroll_attendance/hr_payslip.py @@ -1,8 +1,6 @@ -from datetime import datetime from collections import defaultdict from odoo import api, models from odoo.exceptions import ValidationError -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT class HrPayslip(models.Model): @@ -41,8 +39,7 @@ class HrPayslip(models.Model): raise ValidationError('This pay period must not have any open attendances.') if attn.worked_hours: # Avoid in/outs - attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) - attn_iso = attn_start_time.isocalendar() + attn_iso = attn.check_in.isocalendar() if not attn_iso in days: worked_attn['number_of_days'] += 1 days.add(attn_iso) @@ -73,8 +70,7 @@ class HrPayslip(models.Model): raise ValidationError('This pay period must not have any open attendances.') if attn.worked_hours: # Avoid in/outs - attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT) - attn_iso = attn_start_time.isocalendar() + attn_iso = attn.check_in.isocalendar() day_values[attn_iso] += attn.worked_hours return day_values elif hasattr(super(HrPayslip, self), 'hour_break_down'): From 71709012bb09c610482f8dbac9a441296ef02641 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 7 Jul 2020 14:42:42 -0700 Subject: [PATCH 03/28] [MIG] hr_payroll_attendance: to Odoo 13.0 + [ADD] Overtime calculations --- hr_payroll_attendance/__init__.py | 3 +- hr_payroll_attendance/__manifest__.py | 7 +- .../data/hr_payroll_attendance_data.xml | 21 +++ hr_payroll_attendance/hr_contract_view.xml | 22 ---- hr_payroll_attendance/hr_payslip.py | 89 ------------- hr_payroll_attendance/models/__init__.py | 3 + hr_payroll_attendance/models/hr_attendance.py | 7 + .../{ => models}/hr_contract.py | 2 +- hr_payroll_attendance/models/hr_payslip.py | 89 +++++++++++++ hr_payroll_attendance/tests/__init__.py | 1 + .../tests/test_payroll_attendance.py | 122 ++++++++++++++++++ .../views/hr_contract_views.xml | 22 ++++ .../views/hr_payslip_views.xml | 20 +++ 13 files changed, 292 insertions(+), 116 deletions(-) create mode 100644 hr_payroll_attendance/data/hr_payroll_attendance_data.xml delete mode 100755 hr_payroll_attendance/hr_contract_view.xml delete mode 100755 hr_payroll_attendance/hr_payslip.py create mode 100644 hr_payroll_attendance/models/__init__.py create mode 100644 hr_payroll_attendance/models/hr_attendance.py rename hr_payroll_attendance/{ => models}/hr_contract.py (85%) create mode 100755 hr_payroll_attendance/models/hr_payslip.py create mode 100644 hr_payroll_attendance/tests/__init__.py create mode 100644 hr_payroll_attendance/tests/test_payroll_attendance.py create mode 100755 hr_payroll_attendance/views/hr_contract_views.xml create mode 100644 hr_payroll_attendance/views/hr_payslip_views.xml 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 + + + + + + + + + + + + From cf399eb6c3295615b10f5b3474d22aea58124715 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 7 Jul 2020 15:02:08 -0700 Subject: [PATCH 04/28] [FIX] hr_payroll_attendance: unlink behavior for multi-worked-lines --- hr_payroll_attendance/models/hr_payslip.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hr_payroll_attendance/models/hr_payslip.py b/hr_payroll_attendance/models/hr_payslip.py index b41f9648..df811cf4 100755 --- a/hr_payroll_attendance/models/hr_payslip.py +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -50,10 +50,10 @@ class HrPayslip(models.Model): 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)] + # Note that [(5, 0, 0)] + [(4, 999, 0)], will not work + work_lines_vals = [(3, line.id, False) for line in (self.worked_days_line_ids - lines_to_keep)] + work_lines_vals += [(4, line.id, False) for line in lines_to_keep] work_lines_vals += [(0, 0, { 'number_of_days': data[0], 'number_of_hours': data[1], From c07254ef2ba21e4523d4936c082230c018b517f3 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 10 Jul 2020 14:31:14 -0700 Subject: [PATCH 05/28] [FIX] hr_payroll_attendance: Unlink behavior on attendances and remove "Work Calendar" work lines. --- hr_payroll_attendance/__manifest__.py | 1 + hr_payroll_attendance/models/hr_attendance.py | 7 +- hr_payroll_attendance/models/hr_payslip.py | 76 +++++++++---------- .../tests/test_payroll_attendance.py | 17 +++++ .../views/hr_attendance_views.xml | 17 +++++ .../views/hr_payslip_views.xml | 2 +- 6 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 hr_payroll_attendance/views/hr_attendance_views.xml diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py index e8cf0a9b..6b18d0a3 100755 --- a/hr_payroll_attendance/__manifest__.py +++ b/hr_payroll_attendance/__manifest__.py @@ -8,6 +8,7 @@ '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', ], diff --git a/hr_payroll_attendance/models/hr_attendance.py b/hr_payroll_attendance/models/hr_attendance.py index de4a0d3b..7c005f72 100644 --- a/hr_payroll_attendance/models/hr_attendance.py +++ b/hr_payroll_attendance/models/hr_attendance.py @@ -4,4 +4,9 @@ from odoo import fields, models class HrAttendance(models.Model): _inherit = 'hr.attendance' - payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True) + payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True, ondelete='set null') + + 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_payslip.py b/hr_payroll_attendance/models/hr_payslip.py index df811cf4..d000ce79 100755 --- a/hr_payroll_attendance/models/hr_payslip.py +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -6,7 +6,7 @@ class HrPayslip(models.Model): _inherit = 'hr.payslip' attendance_ids = fields.One2many('hr.attendance', 'payslip_id', string='Attendances', - help='Attendances represented by payslip.', + help='Attendances represented by payslip.', readonly=True, states={'draft': [('readonly', False)], 'verify': [('readonly', False)]}) attendance_count = fields.Integer(compute='_compute_attendance_count') @@ -15,53 +15,53 @@ class HrPayslip(models.Model): 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}) + def _get_worked_day_lines(self): + # Called at the end of _onchange_employee() + worked_day_lines = super()._get_worked_day_lines() + return self._attendance_get_worked_day_lines(worked_day_lines) - @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 + def _attendance_get_worked_day_lines(self, worked_day_lines): + """ + Filters out basic "Attendance"/"Work Calendar" entries as they would add to salary. + Note that this is during an onchange (probably). + :returns: a list of dict containing the worked days values that should be applied for the given payslip + """ + if not self.contract_id.paid_hourly_attendance: + return worked_day_lines + if not self.state == 'draft': + return worked_day_lines + + 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)]}) - @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 + # return early, include the "work calendar lines" + return worked_day_lines + original_work_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False) if original_work_type: - types_to_remove = original_work_type + attendance_type - else: - types_to_remove = attendance_type + # filter out "work calendar lines" + worked_day_lines = [w for w in worked_day_lines if w['work_entry_type_id'] != original_work_type.id] work_data = self._pre_aggregate_attendance_data() processed_data = self.aggregate_overtime(work_data) - lines_to_keep = self.worked_days_line_ids.filtered(lambda x: x.work_entry_type_id not in types_to_remove) - # Note that [(5, 0, 0)] + [(4, 999, 0)], will not work - work_lines_vals = [(3, line.id, False) for line in (self.worked_days_line_ids - lines_to_keep)] - work_lines_vals += [(4, line.id, False) for line in lines_to_keep] - 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}) + worked_day_lines += [{ + '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()] + + return worked_day_lines def _wage_for_work_type(self, work_type): # Override if you pay differently for different work types diff --git a/hr_payroll_attendance/tests/test_payroll_attendance.py b/hr_payroll_attendance/tests/test_payroll_attendance.py index 80472a52..b94df0ff 100644 --- a/hr_payroll_attendance/tests/test_payroll_attendance.py +++ b/hr_payroll_attendance/tests/test_payroll_attendance.py @@ -120,3 +120,20 @@ class TestUsPayslip(common.TransactionCase): # (80 * 21.50) + (47.37 * 21.50 * 1.5) = 3247.6825 cats = self._getCategories() 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._onchange_employee() + self.payslip.compute_sheet() + cats = self._getCategories() + self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) + + self.payslip.write({'attendance_ids': [(5, 0, 0)]}) + self.payslip.state = 'draft' + self.payslip.flush() + self.payslip._onchange_employee() + self.payslip.compute_sheet() + cats = self._getCategories() + self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) 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..c0da03f6 --- /dev/null +++ b/hr_payroll_attendance/views/hr_attendance_views.xml @@ -0,0 +1,17 @@ + + + + + hr.attendance.tree.inherit + hr.attendance + + + + + + + + + + + diff --git a/hr_payroll_attendance/views/hr_payslip_views.xml b/hr_payroll_attendance/views/hr_payslip_views.xml index 292f413f..90a390d9 100644 --- a/hr_payroll_attendance/views/hr_payslip_views.xml +++ b/hr_payroll_attendance/views/hr_payslip_views.xml @@ -12,7 +12,7 @@ - + From d501d335b8d7cd544c7fea841010918f86faa173 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 16 Jul 2020 13:26:21 -0700 Subject: [PATCH 06/28] [FIX] hr_payroll_attendance: repeated onchange in a single change will duplicate attendances in a set until saved --- hr_payroll_attendance/models/hr_payslip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hr_payroll_attendance/models/hr_payslip.py b/hr_payroll_attendance/models/hr_payslip.py index d000ce79..a65df224 100755 --- a/hr_payroll_attendance/models/hr_payslip.py +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -33,7 +33,7 @@ class HrPayslip(models.Model): 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([ + attendance_to_keep |= self.env['hr.attendance'].search([ ('employee_id', '=', self.employee_id.id), ('check_out', '<=', self.date_to), ('payslip_id', '=', False), From eeb4a40b69fc34bf4949a3d2b6c3b832295e5aee Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 3 Sep 2020 12:52:45 -0700 Subject: [PATCH 07/28] [IMP] hr_payroll_attendance: Migration script for 13 --- hr_payroll_attendance/__init__.py | 18 ++++++++++++++++++ hr_payroll_attendance/__manifest__.py | 1 + .../migrations/13.0.0.0.1/pre-migration.py | 6 ++++++ 3 files changed, 25 insertions(+) create mode 100644 hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py diff --git a/hr_payroll_attendance/__init__.py b/hr_payroll_attendance/__init__.py index 0650744f..4e3ff034 100755 --- a/hr_payroll_attendance/__init__.py +++ b/hr_payroll_attendance/__init__.py @@ -1 +1,19 @@ from . import models + + +def attn_payroll_pre_init_hook(cr): + """ + This module installs a Work Entry Type with code "ATTN" + If you have undergone a migration (either for this module + or even your own Payslip Work Entry lines with code "ATTN") + then the uniqueness constraint will prevent this module + from installing. + """ + cr.execute("UPDATE hr_work_entry_type " + "SET code = 'ATTN-PRE-INSTALL' " + "WHERE code = 'ATTN';" + ) + cr.execute("UPDATE hr_work_entry_type " + "SET code = 'ATTN_OT-PRE-INSTALL' " + "WHERE code = 'ATTN_OT';" + ) diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py index 6b18d0a3..3a1be326 100755 --- a/hr_payroll_attendance/__manifest__.py +++ b/hr_payroll_attendance/__manifest__.py @@ -17,4 +17,5 @@ 'hr_attendance', 'hr_payroll_overtime', ], + 'pre_init_hook': 'attn_payroll_pre_init_hook', } diff --git a/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py b/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py new file mode 100644 index 00000000..6c081185 --- /dev/null +++ b/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py @@ -0,0 +1,6 @@ + +def migrate(cr, version): + # pre_init_hook script only runs on install, + # if you're coming from 12.0 we need the same change + from odoo.addons.hr_payroll_timesheet import attn_payroll_pre_init_hook + attn_payroll_pre_init_hook(cr) From 3aa19d714fb9a15b19b38c5eb38f0b37e834db82 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 18 Sep 2020 11:28:39 -0700 Subject: [PATCH 08/28] [NEW] hr_attendance_work_entry: for Odoo 13 --- hr_payroll_attendance/__init__.py | 8 ++------ hr_payroll_attendance/__manifest__.py | 1 + .../data/hr_payroll_attendance_data.xml | 10 ++-------- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/hr_payroll_attendance/__init__.py b/hr_payroll_attendance/__init__.py index 4e3ff034..a366a01b 100755 --- a/hr_payroll_attendance/__init__.py +++ b/hr_payroll_attendance/__init__.py @@ -3,16 +3,12 @@ from . import models def attn_payroll_pre_init_hook(cr): """ - This module installs a Work Entry Type with code "ATTN" + 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") + 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-PRE-INSTALL' " - "WHERE code = 'ATTN';" - ) cr.execute("UPDATE hr_work_entry_type " "SET code = 'ATTN_OT-PRE-INSTALL' " "WHERE code = 'ATTN_OT';" diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py index 3a1be326..bb68d371 100755 --- a/hr_payroll_attendance/__manifest__.py +++ b/hr_payroll_attendance/__manifest__.py @@ -15,6 +15,7 @@ 'depends': [ 'hr_payroll', 'hr_attendance', + 'hr_attendance_work_entry', 'hr_payroll_overtime', ], '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 index 6023dd08..c4efdc82 100644 --- a/hr_payroll_attendance/data/hr_payroll_attendance_data.xml +++ b/hr_payroll_attendance/data/hr_payroll_attendance_data.xml @@ -6,16 +6,10 @@ Attendance Overtime ATTN_OT - - Attendance - ATTN + + - - - Work Calendar - - From 73948e5c13aca34629a317919b509859387f04d7 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Mon, 5 Oct 2020 16:48:54 -0700 Subject: [PATCH 09/28] [IMP] hr_payroll_attendance: new default entry mechanisms, otherwise use the type from the attendance Also skip putting it on the sheet if it is an unpaid type. --- hr_payroll_attendance/models/hr_payslip.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/hr_payroll_attendance/models/hr_payslip.py b/hr_payroll_attendance/models/hr_payslip.py index a65df224..50584bdb 100755 --- a/hr_payroll_attendance/models/hr_payslip.py +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -28,7 +28,7 @@ class HrPayslip(models.Model): """ if not self.contract_id.paid_hourly_attendance: return worked_day_lines - if not self.state == 'draft': + if not self.state in ('draft', 'verify'): return worked_day_lines attendance_to_keep = self.attendance_ids.filtered(lambda a: a.employee_id == self.employee_id @@ -40,17 +40,20 @@ class HrPayslip(models.Model): ]) self.update({'attendance_ids': [(6, 0, attendance_to_keep.ids)]}) - attendance_type = self.env.ref('hr_payroll_attendance.work_input_attendance', raise_if_not_found=False) + attendance_type = self.env.ref('hr_attendance_work_entry.work_input_attendance', raise_if_not_found=False) if not attendance_type: - # return early, include the "work calendar lines" - return worked_day_lines + # 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 worked_day_lines 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" worked_day_lines = [w for w in worked_day_lines if w['work_entry_type_id'] != original_work_type.id] - work_data = self._pre_aggregate_attendance_data() + work_data = self._pre_aggregate_attendance_data(attendance_type) processed_data = self.aggregate_overtime(work_data) worked_day_lines += [{ @@ -67,13 +70,16 @@ class HrPayslip(models.Model): # 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) + def _pre_aggregate_attendance_data(self, default_workentrytype): worked_attn = defaultdict(list) 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 + continue 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 From 0dfee20750d88b95d6672209e5aa0265c78726cc Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 26 Nov 2020 04:15:20 -0800 Subject: [PATCH 10/28] [IMP] hr_payroll_attendance: normalize 'amount' on leave lines, require `hibou_professional` --- hr_payroll_attendance/__manifest__.py | 7 +++- .../migrations/13.0.0.0.1/pre-migration.py | 1 + hr_payroll_attendance/models/hr_attendance.py | 2 + hr_payroll_attendance/models/hr_contract.py | 2 + hr_payroll_attendance/models/hr_payslip.py | 15 ++++++++ .../tests/test_payroll_attendance.py | 38 +++++++++++++++++++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py index bb68d371..368e760d 100755 --- a/hr_payroll_attendance/__manifest__.py +++ b/hr_payroll_attendance/__manifest__.py @@ -1,10 +1,12 @@ +# 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': '13.0.1.0.0', + 'version': '13.0.1.0.1', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', - 'license': 'AGPL-3', + 'license': 'OPL-1', 'category': 'Human Resources', 'data': [ 'data/hr_payroll_attendance_data.xml', @@ -17,6 +19,7 @@ 'hr_attendance', 'hr_attendance_work_entry', 'hr_payroll_overtime', + 'hibou_professional', ], 'pre_init_hook': 'attn_payroll_pre_init_hook', } diff --git a/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py b/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py index 6c081185..c04e9319 100644 --- a/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py +++ b/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py @@ -1,3 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. def migrate(cr, version): # pre_init_hook script only runs on install, diff --git a/hr_payroll_attendance/models/hr_attendance.py b/hr_payroll_attendance/models/hr_attendance.py index 7c005f72..f668a82e 100644 --- a/hr_payroll_attendance/models/hr_attendance.py +++ b/hr_payroll_attendance/models/hr_attendance.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from odoo import fields, models diff --git a/hr_payroll_attendance/models/hr_contract.py b/hr_payroll_attendance/models/hr_contract.py index c6a70483..31226a99 100755 --- a/hr_payroll_attendance/models/hr_contract.py +++ b/hr_payroll_attendance/models/hr_contract.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from odoo import models, fields diff --git a/hr_payroll_attendance/models/hr_payslip.py b/hr_payroll_attendance/models/hr_payslip.py index 50584bdb..30504d27 100755 --- a/hr_payroll_attendance/models/hr_payslip.py +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from collections import defaultdict from odoo import api, fields, models, _ @@ -53,6 +55,9 @@ class HrPayslip(models.Model): # filter out "work calendar lines" worked_day_lines = [w for w in worked_day_lines if w['work_entry_type_id'] != original_work_type.id] + # normalize leaves + self._attendance_normalize_other_work_lines(worked_day_lines) + work_data = self._pre_aggregate_attendance_data(attendance_type) processed_data = self.aggregate_overtime(work_data) @@ -66,6 +71,16 @@ class HrPayslip(models.Model): return worked_day_lines + def _attendance_normalize_other_work_lines(self, worked_day_line_values): + # Modifies the values based on 'wage' + unpaid_work_entry_types = self.struct_id.unpaid_work_entry_type_ids + for line_vals in worked_day_line_values: + work_type = self.env['hr.work.entry.type'].browse(line_vals['work_entry_type_id']) + if work_type not in unpaid_work_entry_types: + line_vals['amount'] = line_vals.get('number_of_hours', 0.0) * self._wage_for_work_type(work_type) + else: + line_vals['amount'] = 0.0 + def _wage_for_work_type(self, work_type): # Override if you pay differently for different work types return self.contract_id.wage diff --git a/hr_payroll_attendance/tests/test_payroll_attendance.py b/hr_payroll_attendance/tests/test_payroll_attendance.py index b94df0ff..4bd80c8d 100644 --- a/hr_payroll_attendance/tests/test_payroll_attendance.py +++ b/hr_payroll_attendance/tests/test_payroll_attendance.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from collections import defaultdict from odoo.tests import common @@ -25,6 +27,18 @@ class TestUsPayslip(common.TransactionCase): 'date_from': '2020-01-06', 'date_to': '2020-01-19', }) + self.work_entry_type_leave = self.env['hr.work.entry.type'].create({ + 'name': 'Test PTO', + 'code': 'TESTPTO', + 'is_leave': True, + }) + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Test Paid Time Off', + 'time_type': 'leave', + 'allocation_type': 'no', + 'validity_start': False, + 'work_entry_type_id': self.work_entry_type_leave.id, + }) def _setup_attendance(self, employee): # Total 127.37 hours in 2 weeks. @@ -137,3 +151,27 @@ class TestUsPayslip(common.TransactionCase): self.payslip.compute_sheet() cats = self._getCategories() self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) + + def test_with_leave(self): + date_from = '2020-01-10' + date_to = '2020-01-11' + leave = self.env['hr.leave'].create({ + 'name': 'Test Leave', + 'employee_id': self.employee.id, + 'holiday_status_id': self.leave_type.id, + 'date_to': date_to, + 'date_from': date_from, + 'number_of_days': 1, + }) + leave.action_validate() + self.assertEqual(leave.state, 'validate') + self.payslip._onchange_employee() + self.assertTrue(self.payslip.contract_id, 'No auto-discovered contract!') + self.payslip.compute_sheet() + 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) From 9f59dd2dee2a2364a1d3a1473e85e255a004b4ac Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 27 Nov 2020 14:20:26 -0800 Subject: [PATCH 11/28] [MIG] hr_payroll_attendance: for Odoo Enterprise 13.0 Move some concerns to other modules, refactor new API to make it possible to use timesheets and attendances together. Now possible to add attendances by hand or import via smart button and 'recompute' attendances. --- hr_payroll_attendance/__manifest__.py | 5 +- .../migrations/13.0.0.0.1/pre-migration.py | 7 -- hr_payroll_attendance/models/hr_attendance.py | 23 +++- hr_payroll_attendance/models/hr_contract.py | 9 +- hr_payroll_attendance/models/hr_payslip.py | 105 ++++++------------ .../tests/test_payroll_attendance.py | 88 ++++++--------- .../views/hr_contract_views.xml | 8 +- 7 files changed, 101 insertions(+), 144 deletions(-) delete mode 100644 hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py index 368e760d..8bf590ec 100755 --- a/hr_payroll_attendance/__manifest__.py +++ b/hr_payroll_attendance/__manifest__.py @@ -3,7 +3,7 @@ { 'name': 'Attendance on Payslips', 'description': 'Get Attendence numbers onto Employee Payslips.', - 'version': '13.0.1.0.1', + 'version': '14.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'OPL-1', @@ -15,8 +15,7 @@ 'views/hr_payslip_views.xml', ], 'depends': [ - 'hr_payroll', - 'hr_attendance', + 'hr_payroll_hibou', 'hr_attendance_work_entry', 'hr_payroll_overtime', 'hibou_professional', diff --git a/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py b/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py deleted file mode 100644 index c04e9319..00000000 --- a/hr_payroll_attendance/migrations/13.0.0.0.1/pre-migration.py +++ /dev/null @@ -1,7 +0,0 @@ -# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. - -def migrate(cr, version): - # pre_init_hook script only runs on install, - # if you're coming from 12.0 we need the same change - from odoo.addons.hr_payroll_timesheet import attn_payroll_pre_init_hook - attn_payroll_pre_init_hook(cr) diff --git a/hr_payroll_attendance/models/hr_attendance.py b/hr_payroll_attendance/models/hr_attendance.py index f668a82e..6baa4832 100644 --- a/hr_payroll_attendance/models/hr_attendance.py +++ b/hr_payroll_attendance/models/hr_attendance.py @@ -1,12 +1,31 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from odoo import fields, models +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", readonly=True, ondelete='set null') + 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) diff --git a/hr_payroll_attendance/models/hr_contract.py b/hr_payroll_attendance/models/hr_contract.py index 31226a99..5fce9bd2 100755 --- a/hr_payroll_attendance/models/hr_contract.py +++ b/hr_payroll_attendance/models/hr_contract.py @@ -1,9 +1,16 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from odoo import models, fields +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' + self.wage_type = 'hourly' diff --git a/hr_payroll_attendance/models/hr_payslip.py b/hr_payroll_attendance/models/hr_payslip.py index 30504d27..4fb651e4 100755 --- a/hr_payroll_attendance/models/hr_payslip.py +++ b/hr_payroll_attendance/models/hr_payslip.py @@ -1,6 +1,5 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from collections import defaultdict from odoo import api, fields, models, _ @@ -17,76 +16,37 @@ class HrPayslip(models.Model): for payslip in self: payslip.attendance_count = len(payslip.attendance_ids) - def _get_worked_day_lines(self): - # Called at the end of _onchange_employee() - worked_day_lines = super()._get_worked_day_lines() - return self._attendance_get_worked_day_lines(worked_day_lines) + def _filter_worked_day_lines_values(self, 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 _attendance_get_worked_day_lines(self, worked_day_lines): - """ - Filters out basic "Attendance"/"Work Calendar" entries as they would add to salary. - Note that this is during an onchange (probably). - :returns: a list of dict containing the worked days values that should be applied for the given payslip - """ - if not self.contract_id.paid_hourly_attendance: - return worked_day_lines - if not self.state in ('draft', 'verify'): - return worked_day_lines + 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_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 + attendance_type = self.env.ref('hr_attendance_work_entry.work_input_attendance', raise_if_not_found=False) if not attendance_type: - # return early, include the "work calendar lines" - return worked_day_lines + # 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 res + work_data = self._pre_aggregate_attendance_data(work_data, attendance_type) + return work_data - 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" - worked_day_lines = [w for w in worked_day_lines if w['work_entry_type_id'] != original_work_type.id] - - # normalize leaves - self._attendance_normalize_other_work_lines(worked_day_lines) - - work_data = self._pre_aggregate_attendance_data(attendance_type) - processed_data = self.aggregate_overtime(work_data) - - worked_day_lines += [{ - '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()] - - return worked_day_lines - - def _attendance_normalize_other_work_lines(self, worked_day_line_values): - # Modifies the values based on 'wage' - unpaid_work_entry_types = self.struct_id.unpaid_work_entry_type_ids - for line_vals in worked_day_line_values: - work_type = self.env['hr.work.entry.type'].browse(line_vals['work_entry_type_id']) - if work_type not in unpaid_work_entry_types: - line_vals['amount'] = line_vals.get('number_of_hours', 0.0) * self._wage_for_work_type(work_type) - else: - line_vals['amount'] = 0.0 - - 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, default_workentrytype): - worked_attn = defaultdict(list) + def _pre_aggregate_attendance_data(self, work_data, default_workentrytype): for attn in self.attendance_ids: if attn.worked_hours: # Avoid in/outs @@ -94,10 +54,11 @@ class HrPayslip(models.Model): 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 - 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 + work_data[attn_iso].append((attendance_type, attn.worked_hours, attn)) + return work_data def action_open_attendances(self): self.ensure_one() @@ -106,5 +67,9 @@ class HrPayslip(models.Model): '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/test_payroll_attendance.py b/hr_payroll_attendance/tests/test_payroll_attendance.py index 4bd80c8d..3515a6d9 100644 --- a/hr_payroll_attendance/tests/test_payroll_attendance.py +++ b/hr_payroll_attendance/tests/test_payroll_attendance.py @@ -1,44 +1,26 @@ # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. -from collections import defaultdict -from odoo.tests import common +from odoo.addons.hr_payroll_hibou.tests import common +from odoo.exceptions import ValidationError -class TestUsPayslip(common.TransactionCase): +class TestUsPayslip(common.TestPayslip): 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', - }) + 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, }) - self.leave_type = self.env['hr.leave.type'].create({ - 'name': 'Test Paid Time Off', - 'time_type': 'leave', - 'allocation_type': 'no', - 'validity_start': False, - 'work_entry_type_id': self.work_entry_type_leave.id, - }) def _setup_attendance(self, employee): # Total 127.37 hours in 2 weeks. @@ -114,25 +96,15 @@ class TestUsPayslip(common.TransactionCase): 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() + 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() + cats = self._getCategories(self.payslip) self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) # ensure unlink behavior. @@ -141,7 +113,7 @@ class TestUsPayslip(common.TransactionCase): self.payslip.flush() self.payslip._onchange_employee() self.payslip.compute_sheet() - cats = self._getCategories() + cats = self._getCategories(self.payslip) self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) self.payslip.write({'attendance_ids': [(5, 0, 0)]}) @@ -149,25 +121,33 @@ class TestUsPayslip(common.TransactionCase): self.payslip.flush() self.payslip._onchange_employee() self.payslip.compute_sheet() - cats = self._getCategories() + 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' - leave = self.env['hr.leave'].create({ - 'name': 'Test Leave', - 'employee_id': self.employee.id, - 'holiday_status_id': self.leave_type.id, - 'date_to': date_to, + self.env['resource.calendar.leaves'].create({ + 'name': 'Doctor Appointment', 'date_from': date_from, - 'number_of_days': 1, + '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', }) - leave.action_validate() - self.assertEqual(leave.state, 'validate') - self.payslip._onchange_employee() - self.assertTrue(self.payslip.contract_id, 'No auto-discovered contract!') - self.payslip.compute_sheet() + + 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') diff --git a/hr_payroll_attendance/views/hr_contract_views.xml b/hr_payroll_attendance/views/hr_contract_views.xml index 6ef64386..467f3095 100755 --- a/hr_payroll_attendance/views/hr_contract_views.xml +++ b/hr_payroll_attendance/views/hr_contract_views.xml @@ -7,15 +7,9 @@ 20 - - + - - / pay period - / hour - - From 16821f85c386c087c7459d592e7ac8fa6e2d9b8b Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 27 Nov 2020 14:22:16 -0800 Subject: [PATCH 12/28] [IMP] hr_payroll_hibou: Control the 'wage_type' at the HR Contract Level This will make it possible to be more abstract with 'work_type' or 'worked days lines' and overtime. --- hr_payroll_hibou/__manifest__.py | 1 + hr_payroll_hibou/models/__init__.py | 1 + hr_payroll_hibou/models/hr_contract.py | 20 ++++++++++++++ hr_payroll_hibou/models/hr_payslip.py | 13 ++++++++++ .../models/res_config_settings.py | 2 ++ hr_payroll_hibou/tests/__init__.py | 1 + hr_payroll_hibou/tests/common.py | 9 +++++-- .../tests/test_contract_wage_type.py | 26 +++++++++++++++++++ hr_payroll_hibou/views/hr_contract_views.xml | 22 ++++++++++++++++ .../views/res_config_settings_views.xml | 25 ++++++++++++++++++ 10 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 hr_payroll_hibou/models/hr_contract.py create mode 100644 hr_payroll_hibou/tests/test_contract_wage_type.py create mode 100755 hr_payroll_hibou/views/hr_contract_views.xml diff --git a/hr_payroll_hibou/__manifest__.py b/hr_payroll_hibou/__manifest__.py index 94ba5190..3c074c14 100644 --- a/hr_payroll_hibou/__manifest__.py +++ b/hr_payroll_hibou/__manifest__.py @@ -18,6 +18,7 @@ Base module for fixing specific qwerks or assumptions in the way Payroll Odoo En """, 'data': [ + 'views/hr_contract_views.xml', 'views/res_config_settings_views.xml', ], 'demo': [ diff --git a/hr_payroll_hibou/models/__init__.py b/hr_payroll_hibou/models/__init__.py index 4538599a..ecd8aaf0 100644 --- a/hr_payroll_hibou/models/__init__.py +++ b/hr_payroll_hibou/models/__init__.py @@ -1,4 +1,5 @@ from . import browsable_object +from . import hr_contract from . import hr_payslip from . import hr_salary_rule from . import res_config_settings diff --git a/hr_payroll_hibou/models/hr_contract.py b/hr_payroll_hibou/models/hr_contract.py new file mode 100644 index 00000000..165c45ed --- /dev/null +++ b/hr_payroll_hibou/models/hr_contract.py @@ -0,0 +1,20 @@ +from odoo import fields, models + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + wage_type = fields.Selection([('monthly', 'Period Fixed Wage'), ('hourly', 'Hourly Wage')], + default='monthly', required=True, related=False) + + def _get_contract_wage(self, work_type=None): + # Override if you pay differently for different work types + # In 14.0, this utilizes new computed field mechanism, + # but will still get the 'wage' field by default. + self.ensure_one() + return self[self._get_contract_wage_field(work_type=work_type)] + + def _get_contract_wage_field(self, work_type=None): + if self.wage_type == 'hourly': + return 'hourly_wage' + return super()._get_contract_wage_field() diff --git a/hr_payroll_hibou/models/hr_payslip.py b/hr_payroll_hibou/models/hr_payslip.py index 1a3ffffc..fab438f5 100644 --- a/hr_payroll_hibou/models/hr_payslip.py +++ b/hr_payroll_hibou/models/hr_payslip.py @@ -6,9 +6,22 @@ from odoo import fields, models class HrPayslip(models.Model): _inherit = 'hr.payslip' + # We need to be able to support more complexity, + # namely, that different employees will be paid by different wage types as 'salary' vs 'hourly' + wage_type = fields.Selection(related='contract_id.wage_type') + def get_year(self): """ # Helper method to get the year (normalized between Odoo Versions) :return: int year of payslip """ return self.date_to.year + + def _get_contract_wage(self, work_type=None): + # Override if you pay differently for different work types + # In 14.0, this utilizes new computed field mechanism, + # but will still get the 'wage' field by default. + + # This would be a good place to override though with a 'work type' + # based mechanism, like a minimum rate or 'rate card' implementation + return self.contract_id._get_contract_wage(work_type=work_type) diff --git a/hr_payroll_hibou/models/res_config_settings.py b/hr_payroll_hibou/models/res_config_settings.py index 9d47b99e..d282e2a7 100644 --- a/hr_payroll_hibou/models/res_config_settings.py +++ b/hr_payroll_hibou/models/res_config_settings.py @@ -7,6 +7,8 @@ class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' # TODO We need MORE here... + module_hr_payroll_attendance = fields.Boolean(string='Attendance Entries & Overtime') + module_hr_payroll_timesheet = fields.Boolean(string='Timesheet Entries & Overtime') module_l10n_us_hr_payroll = fields.Boolean(string='USA Payroll') module_l10n_us_hr_payroll_401k = fields.Boolean(string='USA Payroll 401k') diff --git a/hr_payroll_hibou/tests/__init__.py b/hr_payroll_hibou/tests/__init__.py index cf74eea4..45ce36c6 100644 --- a/hr_payroll_hibou/tests/__init__.py +++ b/hr_payroll_hibou/tests/__init__.py @@ -2,4 +2,5 @@ from . import common +from . import test_contract_wage_type from . import test_special diff --git a/hr_payroll_hibou/tests/common.py b/hr_payroll_hibou/tests/common.py index d1423abe..00a3564b 100755 --- a/hr_payroll_hibou/tests/common.py +++ b/hr_payroll_hibou/tests/common.py @@ -10,16 +10,21 @@ from odoo.tools.float_utils import float_round as odoo_float_round def process_payslip(payslip): try: - payslip.action_payslip_done() + return payslip.action_payslip_done() except AttributeError: # v9 - payslip.process_sheet() + return payslip.process_sheet() class TestPayslip(common.TransactionCase): debug = False _logger = getLogger(__name__) + def process_payslip(self, payslip=None): + if not payslip: + return process_payslip(self.payslip) + return process_payslip(payslip) + def setUp(self): super(TestPayslip, self).setUp() self.contract_model = self.env['hr.contract'] diff --git a/hr_payroll_hibou/tests/test_contract_wage_type.py b/hr_payroll_hibou/tests/test_contract_wage_type.py new file mode 100644 index 00000000..5908f8b5 --- /dev/null +++ b/hr_payroll_hibou/tests/test_contract_wage_type.py @@ -0,0 +1,26 @@ +from .common import TestPayslip, process_payslip + + +class TestContractWageType(TestPayslip): + + def test_per_contract_wage_type_salary(self): + self.debug = True + salary = 80000.0 + employee = self._createEmployee() + contract = self._createContract(employee, wage=salary, hourly_wage=salary/100.0, wage_type='monthly', schedule_pay='bi-weekly') + payslip = self._createPayslip(employee, '2019-12-30', '2020-01-12') + self.assertEqual(contract.wage_type, 'monthly') + self.assertEqual(payslip.wage_type, 'monthly') + cats = self._getCategories(payslip) + self.assertEqual(cats['BASIC'], salary) + + def test_per_contract_wage_type_hourly(self): + self.debug = True + hourly_wage = 21.50 + employee = self._createEmployee() + contract = self._createContract(employee, wage=hourly_wage*100.0, hourly_wage=hourly_wage, wage_type='hourly', schedule_pay='bi-weekly') + payslip = self._createPayslip(employee, '2019-12-30', '2020-01-12') + self.assertEqual(contract.wage_type, 'hourly') + self.assertEqual(payslip.wage_type, 'hourly') + cats = self._getCategories(payslip) + self.assertEqual(cats['BASIC'], hourly_wage * 80.0) diff --git a/hr_payroll_hibou/views/hr_contract_views.xml b/hr_payroll_hibou/views/hr_contract_views.xml new file mode 100755 index 00000000..38934065 --- /dev/null +++ b/hr_payroll_hibou/views/hr_contract_views.xml @@ -0,0 +1,22 @@ + + + + + hr.contract.form.inherit + hr.contract + 20 + + + + + + {} + + + Period Advantages in Cash + + + + + + diff --git a/hr_payroll_hibou/views/res_config_settings_views.xml b/hr_payroll_hibou/views/res_config_settings_views.xml index 65c3bd51..9933edbd 100644 --- a/hr_payroll_hibou/views/res_config_settings_views.xml +++ b/hr_payroll_hibou/views/res_config_settings_views.xml @@ -32,6 +32,7 @@