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