[IMP] hr_holidays: Add hourly frequency on accruals
closes odoo/odoo#133441 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -25,6 +25,7 @@ class AccrualPlanLevel(models.Model):
|
||||
string='sequence', compute='_compute_sequence', store=True,
|
||||
help='Sequence is generated automatically by start time delta.')
|
||||
accrual_plan_id = fields.Many2one('hr.leave.accrual.plan', "Accrual Plan", required=True, ondelete="cascade")
|
||||
accrued_gain_time = fields.Selection(related='accrual_plan_id.accrued_gain_time')
|
||||
start_count = fields.Integer(
|
||||
"Start after",
|
||||
help="The accrual starts after a defined period from the allocation start date. This field defines the number of days, months or years after which accrual is used.", default="1")
|
||||
@@ -44,6 +45,7 @@ class AccrualPlanLevel(models.Model):
|
||||
('hour', 'Hours')
|
||||
], compute="_compute_added_value_type", store=True, required=True, default="day")
|
||||
frequency = fields.Selection([
|
||||
('hourly', 'Hourly'),
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
('bimonthly', 'Twice a month'),
|
||||
@@ -121,7 +123,7 @@ class AccrualPlanLevel(models.Model):
|
||||
|
||||
_sql_constraints = [
|
||||
('check_dates',
|
||||
"CHECK( (frequency = 'daily') or"
|
||||
"CHECK( (frequency IN ('daily', 'hourly')) or"
|
||||
"(week_day IS NOT NULL AND frequency = 'weekly') or "
|
||||
"(first_day > 0 AND second_day > first_day AND first_day <= 31 AND second_day <= 31 AND frequency = 'bimonthly') or "
|
||||
"(first_day > 0 AND first_day <= 31 AND frequency = 'monthly')or "
|
||||
@@ -211,7 +213,7 @@ class AccrualPlanLevel(models.Model):
|
||||
Returns the next date with the given last call
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.frequency == 'daily':
|
||||
if self.frequency in ['hourly', 'daily']:
|
||||
return last_call + relativedelta(days=1)
|
||||
elif self.frequency == 'weekly':
|
||||
daynames = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
@@ -260,7 +262,7 @@ class AccrualPlanLevel(models.Model):
|
||||
Contrary to `_get_next_date` this function will return the 01/02 if that date is given
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.frequency == 'daily':
|
||||
if self.frequency in ['hourly', 'daily']:
|
||||
return last_call
|
||||
elif self.frequency == 'weekly':
|
||||
daynames = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
@@ -401,27 +401,38 @@ class HolidaysAllocation(models.Model):
|
||||
return (previous_level, current_level_idx - 1)
|
||||
return (current_level, current_level_idx)
|
||||
|
||||
def _get_accrual_plan_level_work_entry_prorata(self, level, start_period, start_date, end_period, end_date):
|
||||
self.ensure_one()
|
||||
datetime_min_time = datetime.min.time()
|
||||
start_dt = datetime.combine(start_date, datetime_min_time)
|
||||
end_dt = datetime.combine(end_date, datetime_min_time)
|
||||
worked = self.employee_id._get_work_days_data_batch(start_dt, end_dt, calendar=self.employee_id.resource_calendar_id)\
|
||||
[self.employee_id.id]['hours']
|
||||
if start_period != start_date or end_period != end_date:
|
||||
start_dt = datetime.combine(start_period, datetime_min_time)
|
||||
end_dt = datetime.combine(end_period, datetime_min_time)
|
||||
planned_worked = self.employee_id._get_work_days_data_batch(start_dt, end_dt, calendar=self.employee_id.resource_calendar_id)\
|
||||
[self.employee_id.id]['hours']
|
||||
else:
|
||||
planned_worked = worked
|
||||
left = self.employee_id.sudo()._get_leave_days_data_batch(start_dt, end_dt,
|
||||
domain=[('time_type', '=', 'leave')])[self.employee_id.id]['hours']
|
||||
if level.frequency == 'hourly':
|
||||
if level.accrual_plan_id.is_based_on_worked_time:
|
||||
work_entry_prorata = planned_worked
|
||||
else:
|
||||
work_entry_prorata = planned_worked + left
|
||||
else:
|
||||
work_entry_prorata = worked / (left + planned_worked) if (left + planned_worked) else 0
|
||||
return work_entry_prorata
|
||||
|
||||
def _process_accrual_plan_level(self, level, start_period, start_date, end_period, end_date):
|
||||
"""
|
||||
Returns the added days for that level
|
||||
"""
|
||||
self.ensure_one()
|
||||
if level.accrual_plan_id.is_based_on_worked_time:
|
||||
datetime_min_time = datetime.min.time()
|
||||
start_dt = datetime.combine(start_date, datetime_min_time)
|
||||
end_dt = datetime.combine(end_date, datetime_min_time)
|
||||
worked = self.employee_id._get_work_days_data_batch(start_dt, end_dt, calendar=self.employee_id.resource_calendar_id)\
|
||||
[self.employee_id.id]['hours']
|
||||
if start_period != start_date or end_period != end_date:
|
||||
start_dt = datetime.combine(start_period, datetime_min_time)
|
||||
end_dt = datetime.combine(end_period, datetime_min_time)
|
||||
planned_worked = self.employee_id._get_work_days_data_batch(start_dt, end_dt, calendar=self.employee_id.resource_calendar_id)\
|
||||
[self.employee_id.id]['hours']
|
||||
else:
|
||||
planned_worked = worked
|
||||
left = self.employee_id.sudo()._get_leave_days_data_batch(start_dt, end_dt,
|
||||
domain=[('time_type', '=', 'leave')])[self.employee_id.id]['hours']
|
||||
work_entry_prorata = worked / (left + planned_worked) if (left + planned_worked) else 0
|
||||
if level.frequency == 'hourly' or level.accrual_plan_id.is_based_on_worked_time:
|
||||
work_entry_prorata = self._get_accrual_plan_level_work_entry_prorata(level, start_period, start_date, end_period, end_date)
|
||||
added_value = work_entry_prorata * level.added_value
|
||||
else:
|
||||
added_value = level.added_value
|
||||
|
||||
@@ -10,7 +10,7 @@ from odoo.tests import tagged
|
||||
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysCommon
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
@tagged('post_install', '-at_install', 'accruals')
|
||||
class TestAccrualAllocations(TestHrHolidaysCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -41,6 +41,101 @@ class TestAccrualAllocations(TestHrHolidaysCommon):
|
||||
WHERE id = %s
|
||||
""" % (date, allocation_id))
|
||||
|
||||
def test_frequency_hourly_calendar(self):
|
||||
with freeze_time("2017-12-5"):
|
||||
accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({
|
||||
'name': 'Accrual Plan For Test',
|
||||
'level_ids': [(0, 0, {
|
||||
'start_count': 1,
|
||||
'start_type': 'day',
|
||||
'added_value': 1,
|
||||
'added_value_type': 'day',
|
||||
'frequency': 'hourly',
|
||||
'cap_accrued_time': True,
|
||||
'maximum_leave': 10000
|
||||
})],
|
||||
})
|
||||
allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({
|
||||
'name': 'Accrual allocation for employee',
|
||||
'accrual_plan_id': accrual_plan.id,
|
||||
'employee_id': self.employee_emp.id,
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'number_of_days': 0,
|
||||
'allocation_type': 'accrual',
|
||||
})
|
||||
allocation.action_confirm()
|
||||
allocation.action_validate()
|
||||
self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.')
|
||||
self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.')
|
||||
allocation._update_accrual()
|
||||
tomorrow = datetime.date.today() + relativedelta(days=2)
|
||||
self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.')
|
||||
|
||||
with freeze_time(tomorrow):
|
||||
allocation._update_accrual()
|
||||
nextcall = datetime.date.today() + relativedelta(days=1)
|
||||
self.assertEqual(allocation.number_of_days, 8, 'There should be 8 day allocated.')
|
||||
self.assertEqual(allocation.nextcall, nextcall, 'The next call date of the cron should be in 2 days.')
|
||||
allocation._update_accrual()
|
||||
self.assertEqual(allocation.number_of_days, 8, 'There should be only 8 day allocated.')
|
||||
|
||||
def test_frequency_hourly_worked_hours(self):
|
||||
with freeze_time("2017-12-5"):
|
||||
accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({
|
||||
'name': 'Accrual Plan For Test',
|
||||
'is_based_on_worked_time': True,
|
||||
'level_ids': [(0, 0, {
|
||||
'start_count': 1,
|
||||
'start_type': 'day',
|
||||
'added_value': 1,
|
||||
'added_value_type': 'day',
|
||||
'frequency': 'hourly',
|
||||
'cap_accrued_time': True,
|
||||
'maximum_leave': 10000
|
||||
})],
|
||||
})
|
||||
allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({
|
||||
'name': 'Accrual allocation for employee',
|
||||
'accrual_plan_id': accrual_plan.id,
|
||||
'employee_id': self.employee_emp.id,
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'number_of_days': 0,
|
||||
'allocation_type': 'accrual',
|
||||
})
|
||||
allocation.action_confirm()
|
||||
allocation.action_validate()
|
||||
self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.')
|
||||
self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.')
|
||||
allocation._update_accrual()
|
||||
tomorrow = datetime.date.today() + relativedelta(days=2)
|
||||
self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.')
|
||||
|
||||
holiday_type = self.env['hr.leave.type'].create({
|
||||
'name': 'Paid Time Off',
|
||||
'requires_allocation': 'no',
|
||||
'responsible_ids': [(4, self.user_hrmanager_id)],
|
||||
'time_type': 'leave',
|
||||
'request_unit': 'half_day',
|
||||
})
|
||||
leave = self.env['hr.leave'].create({
|
||||
'name': 'leave',
|
||||
'employee_id': self.employee_emp.id,
|
||||
'holiday_status_id': holiday_type.id,
|
||||
'request_date_from': '2017-12-06 08:00:00',
|
||||
'request_date_to': '2017-12-06 17:00:00',
|
||||
'request_unit_half': True,
|
||||
'request_date_from_period': 'am',
|
||||
})
|
||||
leave.action_validate()
|
||||
|
||||
with freeze_time(tomorrow):
|
||||
allocation._update_accrual()
|
||||
nextcall = datetime.date.today() + relativedelta(days=1)
|
||||
self.assertEqual(allocation.number_of_days, 4, 'There should be 4 day allocated.')
|
||||
self.assertEqual(allocation.nextcall, nextcall, 'The next call date of the cron should be in 2 days.')
|
||||
allocation._update_accrual()
|
||||
self.assertEqual(allocation.number_of_days, 4, 'There should be only 4 day allocated.')
|
||||
|
||||
def test_frequency_daily(self):
|
||||
with freeze_time("2017-12-5"):
|
||||
accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
<label for="added_value" string="Employee accrue"/>
|
||||
</div>
|
||||
<div>
|
||||
<field name="accrued_gain_time" invisible="1"/>
|
||||
<field name="can_modify_value_type" invisible="1"/>
|
||||
<field name="added_value" widget="FloatWithoutTrailingZeros" style="width: 4rem" class="me-1"/>
|
||||
<field name="added_value_type" style="width: 3.4rem" nolabel="1" readonly="not can_modify_value_type"/>
|
||||
</div>
|
||||
<div style="width: 5rem"/>
|
||||
<div name="hourly" invisible="frequency != 'hourly'">
|
||||
<field name="frequency" style="width: 5rem"/>
|
||||
</div>
|
||||
<div name="daily" invisible="frequency != 'daily'">
|
||||
<field name="frequency" style="width: 5rem"/>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ Convert employee's extra hours to leave allocations.
|
||||
'views/hr_leave_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/hr_leave_accrual_level_views.xml',
|
||||
'data/hr_holidays_attendance_data.xml',
|
||||
],
|
||||
'demo': [
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from . import hr_attendance
|
||||
from . import hr_leave_allocation
|
||||
from . import hr_leave_accrual_plan_level
|
||||
from . import hr_leave_type
|
||||
from . import hr_leave
|
||||
from . import res_company
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AccrualPlanLevel(models.Model):
|
||||
_inherit = "hr.leave.accrual.level"
|
||||
|
||||
frequency_hourly_source = fields.Selection(
|
||||
selection=[
|
||||
('calendar', 'Calendar'),
|
||||
('attendance', 'Attendances')
|
||||
],
|
||||
default='calendar',
|
||||
compute='_compute_frequency_hourly_source',
|
||||
store=True,
|
||||
readonly=False,
|
||||
help="If the source is set to Calendar, the amount of worked hours will be computed from employee calendar and time off (if the plan is based on worked time). Otherwise, the amount of worked hours will be based on Attendance records.")
|
||||
|
||||
@api.depends('accrued_gain_time')
|
||||
def _compute_frequency_hourly_source(self):
|
||||
for level in self:
|
||||
if level.accrued_gain_time == 'start':
|
||||
level.frequency_hourly_source = 'calendar'
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import float_round
|
||||
@@ -83,3 +85,18 @@ class HolidaysAllocation(models.Model):
|
||||
res = super().action_refuse()
|
||||
self.overtime_id.sudo().unlink()
|
||||
return res
|
||||
|
||||
def _get_accrual_plan_level_work_entry_prorata(self, level, start_period, start_date, end_period, end_date):
|
||||
self.ensure_one()
|
||||
if level.frequency != 'hourly' or level.frequency_hourly_source != 'attendance':
|
||||
return super()._get_accrual_plan_level_work_entry_prorata(level, start_period, start_date, end_period, end_date)
|
||||
datetime_min_time = datetime.min.time()
|
||||
start_dt = datetime.combine(start_date, datetime_min_time)
|
||||
end_dt = datetime.combine(end_date, datetime_min_time)
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('check_in', '>=', start_dt),
|
||||
('check_out', '<=', end_dt),
|
||||
])
|
||||
work_entry_prorata = sum(attendances.mapped('worked_hours'))
|
||||
return work_entry_prorata
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_holidays_overtime
|
||||
from . import test_accrual_allocations
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import datetime
|
||||
from freezegun import freeze_time
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysCommon
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'accruals')
|
||||
class TestAccrualAllocationsAttendance(TestHrHolidaysCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestAccrualAllocationsAttendance, cls).setUpClass()
|
||||
cls.leave_type = cls.env['hr.leave.type'].create({
|
||||
'name': 'Paid Time Off',
|
||||
'time_type': 'leave',
|
||||
'requires_allocation': 'yes',
|
||||
'allocation_validation_type': 'officer',
|
||||
})
|
||||
|
||||
def test_frequency_hourly_attendance(self):
|
||||
with freeze_time("2017-12-5"):
|
||||
accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({
|
||||
'name': 'Accrual Plan For Test',
|
||||
'is_based_on_worked_time': True,
|
||||
'level_ids': [(0, 0, {
|
||||
'start_count': 1,
|
||||
'start_type': 'day',
|
||||
'added_value': 1,
|
||||
'added_value_type': 'day',
|
||||
'frequency': 'hourly',
|
||||
'frequency_hourly_source': 'attendance',
|
||||
'cap_accrued_time': True,
|
||||
'maximum_leave': 10000
|
||||
})],
|
||||
})
|
||||
allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({
|
||||
'name': 'Accrual allocation for employee',
|
||||
'accrual_plan_id': accrual_plan.id,
|
||||
'employee_id': self.employee_emp.id,
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'number_of_days': 0,
|
||||
'allocation_type': 'accrual',
|
||||
})
|
||||
allocation.action_confirm()
|
||||
allocation.action_validate()
|
||||
self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.')
|
||||
self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.')
|
||||
allocation._update_accrual()
|
||||
tomorrow = datetime.date.today() + relativedelta(days=2)
|
||||
self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.')
|
||||
|
||||
self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee_emp.id,
|
||||
'check_in': datetime.datetime(2017, 12, 6, 8, 0, 0),
|
||||
'check_out': datetime.datetime(2017, 12, 6, 13, 22, 0),
|
||||
})
|
||||
|
||||
with freeze_time(tomorrow):
|
||||
allocation._update_accrual()
|
||||
nextcall = datetime.date.today() + relativedelta(days=1)
|
||||
self.assertAlmostEqual(allocation.number_of_days, 4.37, places=2)
|
||||
self.assertEqual(allocation.nextcall, nextcall, 'The next call date of the cron should be in 2 days.')
|
||||
allocation._update_accrual()
|
||||
self.assertAlmostEqual(allocation.number_of_days, 4.37, places=2)
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='UTF-8' ?>
|
||||
<odoo>
|
||||
<record id="hr_leave_accrual_level_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.leave.accrual.level.form</field>
|
||||
<field name="model">hr.leave.accrual.level</field>
|
||||
<field name="inherit_id" ref="hr_holidays.hr_accrual_level_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="hourly" position="inside">
|
||||
<div class="o_row" invisible="accrued_gain_time == 'start'">
|
||||
<label for="frequency_hourly_source" string="Source"/>
|
||||
<field
|
||||
name="frequency_hourly_source"
|
||||
required="frequency == 'hourly'"
|
||||
widget="radio"
|
||||
options="{'horizontal': true}"/>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user