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',
|
||||
'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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,22 +16,17 @@ 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 _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 _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 _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([
|
||||
@@ -48,45 +42,11 @@ class HrPayslip(models.Model):
|
||||
attendance_type = self.struct_id.type_id.default_work_entry_type_id
|
||||
if not attendance_type:
|
||||
# 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)
|
||||
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)],
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user