mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
[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:
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,22 +16,17 @@ 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"
|
||||||
def _attendance_get_worked_day_lines(self, worked_day_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
|
||||||
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
|
attendance_to_keep = self.attendance_ids.filtered(lambda a: a.employee_id == self.employee_id
|
||||||
and a.check_out.date() <= self.date_to)
|
and a.check_out.date() <= self.date_to)
|
||||||
attendance_to_keep |= self.env['hr.attendance'].search([
|
attendance_to_keep |= self.env['hr.attendance'].search([
|
||||||
@@ -48,45 +42,11 @@ class HrPayslip(models.Model):
|
|||||||
attendance_type = self.struct_id.type_id.default_work_entry_type_id
|
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"
|
# return early, include the "work calendar lines"
|
||||||
return worked_day_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)],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user