From 9f59dd2dee2a2364a1d3a1473e85e255a004b4ac Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 27 Nov 2020 14:20:26 -0800 Subject: [PATCH] [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 - -