[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',
'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. <hello@hibou.io>',
'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',

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

View File

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

View File

@@ -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)],
}

View File

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

View File

@@ -7,15 +7,9 @@
<field name="priority">20</field>
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='advantages']" position="after">
<xpath expr="//field[@name='wage_type']" position="after">
<field name="paid_hourly_attendance"/>
</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>
</record>