[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.
This commit is contained in:
Jared Kipe
2020-11-27 14:20:26 -08:00
parent 0dfee20750
commit 9f59dd2dee
7 changed files with 101 additions and 144 deletions

View File

@@ -3,7 +3,7 @@
{ {
'name': 'Attendance on Payslips', 'name': 'Attendance on Payslips',
'description': 'Get Attendence numbers onto Employee Payslips.', 'description': 'Get Attendence numbers onto Employee Payslips.',
'version': '13.0.1.0.1', 'version': '14.0.1.0.0',
'website': 'https://hibou.io/', 'website': 'https://hibou.io/',
'author': 'Hibou Corp. <hello@hibou.io>', 'author': 'Hibou Corp. <hello@hibou.io>',
'license': 'OPL-1', 'license': 'OPL-1',
@@ -15,8 +15,7 @@
'views/hr_payslip_views.xml', 'views/hr_payslip_views.xml',
], ],
'depends': [ 'depends': [
'hr_payroll', 'hr_payroll_hibou',
'hr_attendance',
'hr_attendance_work_entry', 'hr_attendance_work_entry',
'hr_payroll_overtime', 'hr_payroll_overtime',
'hibou_professional', 'hibou_professional',

View File

@@ -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)

View File

@@ -1,12 +1,31 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. # 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): class HrAttendance(models.Model):
_inherit = 'hr.attendance' _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): def unlink(self):
attn_with_payslip = self.filtered(lambda a: a.payslip_id) attn_with_payslip = self.filtered(lambda a: a.payslip_id)

View File

@@ -1,9 +1,16 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. # 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): class HrContract(models.Model):
_inherit = 'hr.contract' _inherit = 'hr.contract'
paid_hourly_attendance = fields.Boolean(string="Paid Hourly Attendance") 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'

View File

@@ -1,6 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. # 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, _ from odoo import api, fields, models, _
@@ -17,76 +16,37 @@ class HrPayslip(models.Model):
for payslip in self: for payslip in self:
payslip.attendance_count = len(payslip.attendance_ids) payslip.attendance_count = len(payslip.attendance_ids)
def _get_worked_day_lines(self): def _filter_worked_day_lines_values(self, worked_day_lines_values):
# Called at the end of _onchange_employee() if self.contract_id.paid_hourly_attendance:
worked_day_lines = super()._get_worked_day_lines() original_work_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
return self._attendance_get_worked_day_lines(worked_day_lines) 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): def _pre_aggregate_work_data(self):
""" work_data = super()._pre_aggregate_work_data()
Filters out basic "Attendance"/"Work Calendar" entries as they would add to salary. if self.contract_id.paid_hourly_attendance:
Note that this is during an onchange (probably). attendance_to_keep = self.attendance_ids.filtered(lambda a: a.employee_id == self.employee_id
:returns: a list of dict containing the worked days values that should be applied for the given payslip and a.check_out.date() <= self.date_to)
""" attendance_to_keep |= self.env['hr.attendance'].search([
if not self.contract_id.paid_hourly_attendance: ('employee_id', '=', self.employee_id.id),
return worked_day_lines ('check_out', '<=', self.date_to),
if not self.state in ('draft', 'verify'): ('payslip_id', '=', False),
return worked_day_lines ])
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 attendance_type = self.env.ref('hr_attendance_work_entry.work_input_attendance', raise_if_not_found=False)
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: if not attendance_type:
# return early, include the "work calendar lines" # different default type
return worked_day_lines 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) def _pre_aggregate_attendance_data(self, work_data, default_workentrytype):
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)
for attn in self.attendance_ids: for attn in self.attendance_ids:
if attn.worked_hours: if attn.worked_hours:
# Avoid in/outs # Avoid in/outs
@@ -94,10 +54,11 @@ class HrPayslip(models.Model):
attendance_type = attn.work_type_id or default_workentrytype attendance_type = attn.work_type_id or default_workentrytype
if attendance_type in self.struct_id.unpaid_work_entry_type_ids: if attendance_type in self.struct_id.unpaid_work_entry_type_ids:
# this is unpaid, so we have to skip it from aggregation # 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 continue
worked_attn[attn_iso].append((attendance_type, attn.worked_hours, attn)) work_data[attn_iso].append((attendance_type, attn.worked_hours, attn))
res = [(k, worked_attn[k]) for k in sorted(worked_attn.keys())] return work_data
return res
def action_open_attendances(self): def action_open_attendances(self):
self.ensure_one() self.ensure_one()
@@ -106,5 +67,9 @@ class HrPayslip(models.Model):
'name': _('Paid Attendances'), 'name': _('Paid Attendances'),
'res_model': 'hr.attendance', 'res_model': 'hr.attendance',
'view_mode': 'tree,form', 'view_mode': 'tree,form',
'context': {
'default_employee_id': self.employee_id.id,
'default_payslip_id': self.id,
},
'domain': [('id', 'in', self.attendance_ids.ids)], 'domain': [('id', 'in', self.attendance_ids.ids)],
} }

View File

@@ -1,44 +1,26 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from collections import defaultdict from odoo.addons.hr_payroll_hibou.tests import common
from odoo.tests import common from odoo.exceptions import ValidationError
class TestUsPayslip(common.TransactionCase): class TestUsPayslip(common.TestPayslip):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.test_hourly_wage = 21.5 self.test_hourly_wage = 21.5
self.employee = self.env.ref('hr.employee_hne') self.employee = self._createEmployee()
self.contract = self.env['hr.contract'].create({ self.contract = self._createContract(self.employee,
'name': 'Test', wage=self.test_hourly_wage,
'employee_id': self.employee.id, hourly_wage=self.test_hourly_wage,
'structure_type_id': self.env.ref('hr_payroll.structure_type_employee').id, wage_type='hourly',
'date_start': '2020-01-01', paid_hourly_attendance=True)
'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.work_entry_type_leave = self.env['hr.work.entry.type'].create({ self.work_entry_type_leave = self.env['hr.work.entry.type'].create({
'name': 'Test PTO', 'name': 'Test PTO',
'code': 'TESTPTO', 'code': 'TESTPTO',
'is_leave': True, '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): def _setup_attendance(self, employee):
# Total 127.37 hours in 2 weeks. # Total 127.37 hours in 2 weeks.
@@ -114,25 +96,15 @@ class TestUsPayslip(common.TransactionCase):
attendances += last attendances += last
return 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): 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.assertTrue(self.payslip.contract_id, 'No auto-discovered contract!')
self.payslip.compute_sheet() self.payslip.compute_sheet()
# 58.97 => 40hr regular, 18.97hr OT # 58.97 => 40hr regular, 18.97hr OT
# 68.4 => 40hr regular, 28.4hr OT # 68.4 => 40hr regular, 28.4hr OT
# (80 * 21.50) + (47.37 * 21.50 * 1.5) = 3247.6825 # (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) self.assertAlmostEqual(cats['BASIC'], 3247.68, 2)
# ensure unlink behavior. # ensure unlink behavior.
@@ -141,7 +113,7 @@ class TestUsPayslip(common.TransactionCase):
self.payslip.flush() self.payslip.flush()
self.payslip._onchange_employee() self.payslip._onchange_employee()
self.payslip.compute_sheet() self.payslip.compute_sheet()
cats = self._getCategories() cats = self._getCategories(self.payslip)
self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) self.assertAlmostEqual(cats['BASIC'], 3247.68, 2)
self.payslip.write({'attendance_ids': [(5, 0, 0)]}) self.payslip.write({'attendance_ids': [(5, 0, 0)]})
@@ -149,25 +121,33 @@ class TestUsPayslip(common.TransactionCase):
self.payslip.flush() self.payslip.flush()
self.payslip._onchange_employee() self.payslip._onchange_employee()
self.payslip.compute_sheet() self.payslip.compute_sheet()
cats = self._getCategories() cats = self._getCategories(self.payslip)
self.assertAlmostEqual(cats['BASIC'], 3247.68, 2) 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): def test_with_leave(self):
date_from = '2020-01-10' date_from = '2020-01-10'
date_to = '2020-01-11' date_to = '2020-01-11'
leave = self.env['hr.leave'].create({ self.env['resource.calendar.leaves'].create({
'name': 'Test Leave', 'name': 'Doctor Appointment',
'employee_id': self.employee.id,
'holiday_status_id': self.leave_type.id,
'date_to': date_to,
'date_from': date_from, '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._setup_attendance(self.employee)
self.payslip._onchange_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()
self.assertTrue(self.payslip.worked_days_line_ids) self.assertTrue(self.payslip.worked_days_line_ids)
leave_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TESTPTO') leave_line = self.payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TESTPTO')

View File

@@ -7,15 +7,9 @@
<field name="priority">20</field> <field name="priority">20</field>
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/> <field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<data> <xpath expr="//field[@name='wage_type']" position="after">
<xpath expr="//field[@name='advantages']" position="after">
<field name="paid_hourly_attendance"/> <field name="paid_hourly_attendance"/>
</xpath> </xpath>
<xpath expr="//div[@name='wage']/span" position="replace">
<span attrs="{'invisible': [('paid_hourly_attendance', '=', True)]}">/ pay period</span>
<span attrs="{'invisible': [('paid_hourly_attendance', '=', False)]}">/ hour</span>
</xpath>
</data>
</field> </field>
</record> </record>