diff --git a/addons/hr_holidays/models/hr_leave_accrual_plan.py b/addons/hr_holidays/models/hr_leave_accrual_plan.py index a4568b48dd8..367850fd65c 100644 --- a/addons/hr_holidays/models/hr_leave_accrual_plan.py +++ b/addons/hr_holidays/models/hr_leave_accrual_plan.py @@ -3,6 +3,11 @@ from odoo import api, fields, models, _ +from odoo.addons.hr_holidays.models.hr_leave_accrual_plan_level import _get_selection_days + +DAY_SELECT_VALUES = [str(i) for i in range(1, 29)] + ['last'] +DAY_SELECT_SELECTION_NO_LAST = tuple(zip(DAY_SELECT_VALUES, (str(i) for i in range(1, 29)))) + class AccrualPlan(models.Model): _name = "hr.leave.accrual.plan" @@ -15,19 +20,48 @@ class AccrualPlan(models.Model): help="""Specify if this accrual plan can only be used with this Time Off Type. Leave empty if this accrual plan can be used with any Time Off Type.""") employees_count = fields.Integer("Employees", compute='_compute_employee_count') - level_ids = fields.One2many('hr.leave.accrual.level', 'accrual_plan_id', copy=True) + level_ids = fields.One2many('hr.leave.accrual.level', 'accrual_plan_id', copy=True, string="Milestone") allocation_ids = fields.One2many('hr.leave.allocation', 'accrual_plan_id') company_id = fields.Many2one('res.company', string='Company', compute="_compute_company_id", store="True", readonly=False) transition_mode = fields.Selection([ ('immediately', 'Immediately'), ('end_of_accrual', "After this accrual's period")], - string="Level Transition", default="immediately", required=True, + string="Milestone Transition", default="immediately", required=True, help="""Specify what occurs if a level transition takes place in the middle of a pay period.\n 'Immediately' will switch the employee to the new accrual level on the exact date during the ongoing pay period.\n 'After this accrual's period' will keep the employee on the same accrual level until the ongoing pay period is complete. After it is complete, the new level will take effect when the next pay period begins.""") show_transition_mode = fields.Boolean(compute='_compute_show_transition_mode') + is_based_on_worked_time = fields.Boolean("Based on worked time", compute="_compute_is_based_on_worked_time", store=True, readonly=False, + help="If checked, the accrual period will be calculated according to the work days, not calendar days.") + accrued_gain_time = fields.Selection([ + ("start", "At the start of the accrual period"), + ("end", "At the end of the accrual period")], + default="end", required=True) + carryover_date = fields.Selection([ + ("year_start", "At the start of the year"), + ("allocation", "At the allocation date"), + ("other", "Other")], + default="year_start", required=True, string="Carry-Over Time") + carryover_day = fields.Integer(default=1) + carryover_day_display = fields.Selection( + _get_selection_days, compute='_compute_carryover_day_display', inverse='_inverse_carryover_day_display') + carryover_month = fields.Selection([ + ("jan", "January"), + ("feb", "February"), + ("mar", "March"), + ("apr", "April"), + ("may", "May"), + ("jun", "June"), + ("jul", "July"), + ("aug", "August"), + ("sep", "September"), + ("oct", "October"), + ("nov", "November"), + ("dec", "December") + ], default="jan") + added_value_type = fields.Selection([('day', 'Days'), ('hour', 'Hours')], compute='_compute_added_value_type', store=True) @api.depends('level_ids') def _compute_show_transition_mode(self): @@ -66,6 +100,31 @@ class AccrualPlan(models.Model): else: accrual_plan.company_id = self.env.company + @api.depends("accrued_gain_time") + def _compute_is_based_on_worked_time(self): + for plan in self: + if plan.accrued_gain_time == "start": + plan.is_based_on_worked_time = False + + @api.depends("level_ids") + def _compute_added_value_type(self): + for plan in self: + if plan.level_ids: + plan.added_value_type = plan.level_ids[0].added_value_type + + @api.depends("carryover_day") + def _compute_carryover_day_display(self): + days_select = _get_selection_days(self) + for plan in self: + plan.carryover_day_display = days_select[min(plan.carryover_day - 1, 28)][0] + + def _inverse_carryover_day_display(self): + for plan in self: + if plan.carryover_day_display == 'last': + plan.carryover_day = 31 + else: + plan.carryover_day = DAY_SELECT_VALUES.index(plan.carryover_day_display) + 1 + def action_open_accrual_plan_employees(self): self.ensure_one() diff --git a/addons/hr_holidays/models/hr_leave_accrual_plan_level.py b/addons/hr_holidays/models/hr_leave_accrual_plan_level.py index 0859e42ffc6..d054bc31dc6 100644 --- a/addons/hr_holidays/models/hr_leave_accrual_plan_level.py +++ b/addons/hr_holidays/models/hr_leave_accrual_plan_level.py @@ -1,13 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import datetime -import calendar - from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models -from odoo.tools.date_utils import get_timedelta DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] @@ -19,6 +15,7 @@ DAY_SELECT_SELECTION_NO_LAST = tuple(zip(DAY_SELECT_VALUES, (str(i) for i in ran def _get_selection_days(self): return DAY_SELECT_SELECTION_NO_LAST + (("last", _("last day")),) + class AccrualPlanLevel(models.Model): _name = "hr.leave.accrual.level" _description = "Accrual Plan Level" @@ -27,27 +24,25 @@ class AccrualPlanLevel(models.Model): sequence = fields.Integer( 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) + accrual_plan_id = fields.Many2one('hr.leave.accrual.plan', "Accrual Plan", required=True, ondelete="cascade") 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") start_type = fields.Selection( - [('day', 'day(s)'), - ('month', 'month(s)'), - ('year', 'year(s)')], + [('day', 'Days'), + ('month', 'Months'), + ('year', 'Years')], default='day', string=" ", required=True, help="This field defines the unit of time after which the accrual starts.") - is_based_on_worked_time = fields.Boolean("Based on worked time", - help="If checked, the rate will be prorated on time off type where type is set on Working Time in the configuration.") # Accrue of added_value = fields.Float( - "Rate", digits=(16, 5), required=True, + "Rate", digits=(16, 5), required=True, default=1, help="The number of hours/days that will be incremented in the specified Time Off Type for every period") - added_value_type = fields.Selection( - [('days', 'Days'), - ('hours', 'Hours')], - default='days', required=True) + added_value_type = fields.Selection([ + ('day', 'Days'), + ('hour', 'Hours') + ], compute="_compute_added_value_type", store=True, required=True, default="day") frequency = fields.Selection([ ('daily', 'Daily'), ('weekly', 'Weekly'), @@ -110,19 +105,19 @@ class AccrualPlanLevel(models.Model): yearly_day = fields.Integer(default=1) yearly_day_display = fields.Selection( _get_selection_days, compute='_compute_days_display', inverse='_inverse_yearly_day_display') + cap_accrued_time = fields.Boolean("Cap accrued time", default=True) maximum_leave = fields.Float( - 'Limit to', required=False, default=100, - help="Choose a cap for this accrual. 0 means no cap.") - parent_id = fields.Many2one( - 'hr.leave.accrual.level', string="Previous Level", - help="If this field is empty, this level is the first one.") + 'Limit to', digits=(16, 2), compute="_compute_maximum_leave", readonly=False, store=True, + help="Choose a cap for this accrual.") action_with_unused_accruals = fields.Selection( - [('postponed', 'Transferred to the next year'), - ('lost', 'Lost')], - string="At the end of the calendar year, unused accruals will be", - default='postponed', required=True) + [('lost', 'None. Accrued time reset to 0'), + ('all', 'All accrued time carried over'), + ('maximum', 'Carry over with a maximum')], + string="Carry over", + default='all', required=True) postpone_max_days = fields.Integer("Maximum amount of accruals to transfer", - help="Set a maximum of days an allocation keeps at the end of the year. 0 for no limit.") + help="Set a maximum of accruals an allocation keeps at the end of the year.") + can_modify_value_type = fields.Boolean(compute="_compute_can_modify_value_type") _sql_constraints = [ ('check_dates', @@ -148,6 +143,19 @@ class AccrualPlanLevel(models.Model): for level in self: level.sequence = level.start_count * start_type_multipliers[level.start_type] + @api.depends('accrual_plan_id', 'accrual_plan_id.level_ids', 'accrual_plan_id.time_off_type_id') + def _compute_can_modify_value_type(self): + for level in self: + level.can_modify_value_type = not level.accrual_plan_id.time_off_type_id and level.accrual_plan_id.level_ids and level.accrual_plan_id.level_ids[0] == level + + @api.depends('accrual_plan_id', 'accrual_plan_id.level_ids', 'accrual_plan_id.time_off_type_id') + def _compute_added_value_type(self): + for level in self: + if level.accrual_plan_id.time_off_type_id: + level.added_value_type = "day" if level.accrual_plan_id.time_off_type_id.request_unit in ["day", "half_day"] else "hour" + elif level.accrual_plan_id.level_ids and level.accrual_plan_id.level_ids[0] != level: + level.added_value_type = level.accrual_plan_id.level_ids[0]._origin.added_value_type + @api.depends('first_day', 'second_day', 'first_month_day', 'second_month_day', 'yearly_day') def _compute_days_display(self): days_select = _get_selection_days(self) @@ -158,6 +166,11 @@ class AccrualPlanLevel(models.Model): level.second_month_day_display = days_select[min(level.second_month_day - 1, 28)][0] level.yearly_day_display = days_select[min(level.yearly_day - 1, 28)][0] + @api.depends('cap_accrued_time') + def _compute_maximum_leave(self): + for level in self: + level.maximum_leave = 100 if level.cap_accrued_time else 0 + def _inverse_first_day_display(self): for level in self: if level.first_day_display == 'last': diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py index 25aad9dc6a7..5b010fd88b6 100644 --- a/addons/hr_holidays/models/hr_leave_allocation.py +++ b/addons/hr_holidays/models/hr_leave_allocation.py @@ -3,23 +3,19 @@ # Copyright (c) 2005-2006 Axelor SARL. (http://www.axelor.com) -from collections import defaultdict -import logging - -from datetime import datetime, time +from datetime import datetime, date, time from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.addons.resource.models.utils import HOURS_PER_DAY from odoo.addons.hr_holidays.models.hr_leave import get_employee_from_context -from odoo.exceptions import AccessError, UserError, ValidationError -from odoo.tools.translate import _ +from odoo.exceptions import AccessError, UserError from odoo.tools.float_utils import float_round from odoo.tools.date_utils import get_timedelta from odoo.osv import expression -_logger = logging.getLogger(__name__) +MONTHS_TO_INTEGER = {"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12} class HolidaysAllocation(models.Model): """ Allocation Requests Access specifications: similar to leave requests """ @@ -75,11 +71,10 @@ class HolidaysAllocation(models.Model): help='Duration in days. Reference field to use when necessary.') number_of_days_display = fields.Float( 'Duration (days)', compute='_compute_number_of_days_display', - readonly=False, - help="If Accrual Allocation: Number of days allocated in addition to the ones you will get via the accrual' system.") + help="For an Accrual Allocation, this field contains the theorical amount of time given to the employee, due to a previous start date, on the first run of the plan. This can be manually edited.") number_of_hours_display = fields.Float( 'Duration (hours)', compute='_compute_number_of_hours_display', - help="If Accrual Allocation: Number of hours allocated in addition to the ones you will get via the accrual' system.") + help="For an Accrual Allocation, this field contains the theorical amount of time given to the employee, due to a previous start date, on the first run of the plan. This can be manually edited.") duration_display = fields.Char('Allocated (Days/Hours)', compute='_compute_duration_display', help="Field allowing to see the allocation duration in days or hours depending on the type_request_unit") # details @@ -91,7 +86,11 @@ class HolidaysAllocation(models.Model): validation_type = fields.Selection(string='Validation Type', related='holiday_status_id.allocation_validation_type', readonly=True) can_reset = fields.Boolean('Can reset', compute='_compute_can_reset') can_approve = fields.Boolean('Can Approve', compute='_compute_can_approve') - type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True) + type_request_unit = fields.Selection([ + ('hour', 'Hours'), + ('half_day', 'Half Day'), + ('day', 'Day'), + ], compute="_compute_type_request_unit") # mode holiday_type = fields.Selection([ ('employee', 'By Employee'), @@ -117,12 +116,12 @@ class HolidaysAllocation(models.Model): 'hr.employee.category', compute='_compute_from_holiday_type', store=True, string='Employee Tag', readonly=False) # accrual configuration lastcall = fields.Date("Date of the last accrual allocation", readonly=True, default=fields.Date.context_today) - nextcall = fields.Date("Date of the next accrual allocation", default=False, readonly=True) - allocation_type = fields.Selection( - [ - ('regular', 'Regular Allocation'), - ('accrual', 'Accrual Allocation') - ], string="Allocation Type", default="regular", required=True, readonly=False) + nextcall = fields.Date("Date of the next accrual allocation", readonly=True, default=False) + already_accrued = fields.Boolean() + allocation_type = fields.Selection([ + ('regular', 'Regular Allocation'), + ('accrual', 'Accrual Allocation') + ], string="Allocation Type", default="regular", required=True, readonly=True) is_officer = fields.Boolean(compute='_compute_is_officer') accrual_plan_id = fields.Many2one('hr.leave.accrual.plan', compute="_compute_from_holiday_status_id", store=True, readonly=False, domain="['|', ('time_off_type_id', '=', False), ('time_off_type_id', '=', holiday_status_id)]", tracking=True) max_leaves = fields.Float(compute='_compute_leaves') @@ -222,7 +221,6 @@ class HolidaysAllocation(models.Model): allocation.number_of_hours_display = allocation.number_of_days * (allocation_calendar.hours_per_day or HOURS_PER_DAY) - @api.depends('number_of_hours_display', 'number_of_days_display') def _compute_duration_display(self): for allocation in self: @@ -336,37 +334,42 @@ class HolidaysAllocation(models.Model): if allocation.holiday_status_id: allocation.accrual_plan_id = accruals_dict.get(allocation.holiday_status_id.id, [False])[0] - def _end_of_year_accrual(self): - # to override in payroll - today = fields.Date.today() - last_day_last_year = today + relativedelta(years=-1, month=12, day=31) - first_day_this_year = today + relativedelta(month=1, day=1) + @api.depends("allocation_type", "holiday_status_id", "accrual_plan_id") + def _compute_type_request_unit(self): for allocation in self: - current_level = allocation._get_current_accrual_plan_level_id(first_day_this_year)[0] - if not current_level: - continue - # lastcall has two cases: - # 1. The period was fully ran until the last day of last year - # 2. The period was not fully ran until the last day of last year - # For case 2, we need to prorata the number of days so need to check if the lastcall within the current level period - lastcall = current_level._get_previous_date(last_day_last_year) if allocation.lastcall < current_level._get_previous_date(last_day_last_year) else allocation.lastcall - nextcall = current_level._get_next_date(last_day_last_year) - if current_level.action_with_unused_accruals == 'lost': - # Allocations are lost but number_of_days should not be lower than leaves_taken - allocation.write({'number_of_days': allocation.leaves_taken, 'lastcall': lastcall, 'nextcall': nextcall}) - elif current_level.action_with_unused_accruals == 'postponed' and current_level.postpone_max_days: - # Make sure the period was ran until the last day of last year - if allocation.nextcall: - allocation.nextcall = first_day_this_year - # date_to should be first day of this year so the prorata amount is computed correctly - allocation._process_accrual_plans(first_day_this_year, True) - number_of_days = min(allocation.number_of_days - allocation.leaves_taken, current_level.postpone_max_days) + allocation.leaves_taken - allocation.write({'number_of_days': number_of_days, 'lastcall': lastcall, 'nextcall': nextcall}) + if allocation.allocation_type == "accrual" and allocation.accrual_plan_id: + allocation.type_request_unit = allocation.accrual_plan_id.added_value_type + elif allocation.allocation_type == "regular": + allocation.type_request_unit = allocation.holiday_status_id.request_unit + else: + allocation.type_request_unit = "day" + + def _get_carryover_date(self, date_from): + self.ensure_one() + carryover_time = self.accrual_plan_id.carryover_date + accrual_plan = self.accrual_plan_id + carryover_date = False + if carryover_time == 'year_start': + carryover_date = date(date_from.year, 1, 1) + elif carryover_time == 'allocation': + carryover_date = date(date_from.year, self.date_from.month, self.date_from.day) + else: + carryover_date = date(date_from.year, MONTHS_TO_INTEGER[accrual_plan.carryover_month], accrual_plan.carryover_day) + if date_from > carryover_date: + carryover_date += relativedelta(years=1) + return carryover_date + + def _add_days_to_allocation(self, current_level, current_level_maximum_leave, leaves_taken, period_start, period_end): + days_to_add = self._process_accrual_plan_level( + current_level, period_start, self.lastcall, period_end, self.nextcall) + self.number_of_days += days_to_add + if current_level.cap_accrued_time: + self.number_of_days = min(self.number_of_days, current_level_maximum_leave + leaves_taken) def _get_current_accrual_plan_level_id(self, date, level_ids=False): """ Returns a pair (accrual_plan_level, idx) where accrual_plan_level is the level for the given date - and idx is the index for the plan in the ordered set of levels + and idx is the index for the plan in the ordered set of levels """ self.ensure_one() if not self.accrual_plan_id.level_ids: @@ -398,14 +401,15 @@ class HolidaysAllocation(models.Model): Returns the added days for that level """ self.ensure_one() - if level.is_based_on_worked_time: - start_dt = datetime.combine(start_date, datetime.min.time()) - end_dt = datetime.combine(end_date, datetime.min.time()) + 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()) + 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: @@ -417,10 +421,10 @@ class HolidaysAllocation(models.Model): else: added_value = level.added_value # Convert time in hours to time in days in case the level is encoded in hours - if level.added_value_type == 'hours': + if level.added_value_type == 'hour': added_value = added_value / (self.employee_id.sudo().resource_id.calendar_id.hours_per_day or HOURS_PER_DAY) period_prorata = 1 - if (start_period != start_date or end_period != end_date) and not level.is_based_on_worked_time: + if (start_period != start_date or end_period != end_date) and not level.accrual_plan_id.is_based_on_worked_time: period_days = (end_period - start_period) call_days = (end_date - start_date) period_prorata = min(1, call_days / period_days) if period_days else 1 @@ -438,83 +442,94 @@ class HolidaysAllocation(models.Model): level_ids = allocation.accrual_plan_id.level_ids.sorted('sequence') if not level_ids: continue + # "cache" leaves taken, as it gets recomputed every time allocation.number_of_days is assigned to. Without this, + # every loop will take 1+ second. It can be removed if computes don't chain in a way to always reassign accrual plan + # even if the value doesn't change. This is the best performance atm. + first_level = level_ids[0] + first_level_start_date = allocation.date_from + get_timedelta(first_level.start_count, first_level.start_type) + leaves_taken = allocation.leaves_taken if first_level.added_value_type == "day" else allocation.leaves_taken / (self.employee_id.sudo().resource_id.calendar_id.hours_per_day or HOURS_PER_DAY) + # first time the plan is run, initialize nextcall and take carryover / level transition into account if not allocation.nextcall: - first_level = level_ids[0] - first_level_start_date = allocation.date_from + get_timedelta(first_level.start_count, first_level.start_type) + # Accrual plan is not configured properly or has not started if date_to < first_level_start_date: - # Accrual plan is not configured properly or has not started continue allocation.lastcall = max(allocation.lastcall, first_level_start_date) allocation.nextcall = first_level._get_next_date(allocation.lastcall) + # adjust nextcall for carryover + carryover_date = allocation._get_carryover_date(allocation.nextcall) + allocation.nextcall = min(carryover_date, allocation.nextcall) + # adjust nextcall for level_transition if len(level_ids) > 1: second_level_start_date = allocation.date_from + get_timedelta(level_ids[1].start_count, level_ids[1].start_type) allocation.nextcall = min(second_level_start_date, allocation.nextcall) allocation._message_log(body=first_allocation) - days_added_per_level = defaultdict(lambda: 0) + (current_level, current_level_idx) = (False, 0) + current_level_maximum_leave = 0.0 + # all subsequent runs, at every loop: + # get current level and normal period boundaries, then set nextcall, adjusted for level transition and carryover + # add days, trimmed if there is a maximum_leave while allocation.nextcall <= date_to: (current_level, current_level_idx) = allocation._get_current_accrual_plan_level_id(allocation.nextcall) if not current_level: break - current_level_maximum_leave = current_level.maximum_leave if current_level.added_value_type == "days" else current_level.maximum_leave / (allocation.employee_id.sudo().resource_id.calendar_id.hours_per_day or HOURS_PER_DAY) + if current_level.cap_accrued_time: + current_level_maximum_leave = current_level.maximum_leave if current_level.added_value_type == "day" else current_level.maximum_leave / (allocation.employee_id.sudo().resource_id.calendar_id.hours_per_day or HOURS_PER_DAY) nextcall = current_level._get_next_date(allocation.nextcall) # Since _get_previous_date returns the given date if it corresponds to a call date # this will always return lastcall except possibly on the first call # this is used to prorate the first number of days given to the employee period_start = current_level._get_previous_date(allocation.lastcall) period_end = current_level._get_next_date(allocation.lastcall) - # Also prorate this accrual in the event that we are passing from one level to another + # There are 2 cases where nextcall could be closer than the normal period: + # 1. Passing from one level to another, if mode is set to 'immediately' if current_level_idx < (len(level_ids) - 1) and allocation.accrual_plan_id.transition_mode == 'immediately': next_level = level_ids[current_level_idx + 1] current_level_last_date = allocation.date_from + get_timedelta(next_level.start_count, next_level.start_type) if allocation.nextcall != current_level_last_date: nextcall = min(nextcall, current_level_last_date) - # We have to check for end of year actions if it is within our period - # since we can create retroactive allocations. - if allocation.lastcall.year < allocation.nextcall.year and\ - current_level.action_with_unused_accruals == 'postponed' and\ - current_level.postpone_max_days > 0: - # Compute number of days kept - allocation_days = allocation.number_of_days - allocation.leaves_taken - allowed_to_keep = max(0, current_level.postpone_max_days - allocation_days) - number_of_days = min(allocation_days, current_level.postpone_max_days) - allocation.number_of_days = number_of_days + allocation.leaves_taken - total_gained_days = sum(days_added_per_level.values()) - days_added_per_level.clear() - days_added_per_level[current_level] = min(total_gained_days, allowed_to_keep) - gained_days = allocation._process_accrual_plan_level( - current_level, period_start, allocation.lastcall, period_end, allocation.nextcall) - days_added_per_level[current_level] += gained_days - if current_level_maximum_leave > 0 and sum(days_added_per_level.values()) > current_level_maximum_leave: - days_added_per_level[current_level] -= sum(days_added_per_level.values()) - current_level_maximum_leave + # 2. On carry-over date + carryover_date = allocation._get_carryover_date(allocation.nextcall) + if allocation.nextcall < carryover_date < nextcall: + nextcall = min(nextcall, carryover_date) + if not allocation.already_accrued: + allocation._add_days_to_allocation(current_level, current_level_maximum_leave, leaves_taken, period_start, period_end) + # if it's the carry-over date, adjust days using current level's carry-over policy, then continue + if allocation.nextcall == carryover_date: + if current_level.action_with_unused_accruals in ['lost', 'maximum']: + allocation_days = allocation.number_of_days + leaves_taken + allocation_max_days = current_level.postpone_max_days + leaves_taken + allocation.number_of_days = min(allocation_days, allocation_max_days) allocation.lastcall = allocation.nextcall allocation.nextcall = nextcall + allocation.already_accrued = False if force_period and allocation.nextcall > date_to: allocation.nextcall = date_to force_period = False - if days_added_per_level: - number_of_days_to_add = allocation.number_of_days + sum(days_added_per_level.values()) - max_allocation_days = current_level_maximum_leave + (allocation.leaves_taken if allocation.type_request_unit != "hour" else allocation.leaves_taken / (allocation.employee_id.sudo().resource_id.calendar_id.hours_per_day or HOURS_PER_DAY)) - # Let's assume the limit of the last level is the correct one - allocation.number_of_days = min(number_of_days_to_add, max_allocation_days) if current_level_maximum_leave > 0 else number_of_days_to_add + # if plan.accrued_gain_time == 'start', process next period and set flag 'already_accrued', this will skip adding days + # once, preventing double allocation. + if allocation.accrual_plan_id.accrued_gain_time == 'start': + # check that we are at the start of a period, not on a carry-over or level transition date + current_level = current_level or allocation.accrual_plan_id.level_ids[0] + period_start = current_level._get_previous_date(allocation.lastcall) + if allocation.lastcall != period_start: + continue + if current_level.cap_accrued_time: + current_level_maximum_leave = current_level.maximum_leave if current_level.added_value_type == "day" else current_level.maximum_leave / (allocation.employee_id.sudo().resource_id.calendar_id.hours_per_day or HOURS_PER_DAY) + allocation._add_days_to_allocation(current_level, current_level_maximum_leave, leaves_taken, allocation.lastcall, allocation.nextcall) + allocation.already_accrued = True @api.model def _update_accrual(self): """ - Method called by the cron task in order to increment the number_of_days when - necessary. + Method called by the cron task in order to increment the number_of_days when + necessary. """ - # Get the current date to determine the start and end of the accrual period today = datetime.combine(fields.Date.today(), time(0, 0, 0)) - this_year_first_day = (today + relativedelta(day=1, month=1)).date() - end_of_year_allocations = self.search( - [('allocation_type', '=', 'accrual'), ('state', '=', 'validate'), ('accrual_plan_id', '!=', False), ('employee_id', '!=', False), - '|', ('date_to', '=', False), ('date_to', '>', fields.Datetime.now()), ('lastcall', '<', this_year_first_day)]) - end_of_year_allocations._end_of_year_accrual() - end_of_year_allocations.flush_model() - allocations = self.search( - [('allocation_type', '=', 'accrual'), ('state', '=', 'validate'), ('accrual_plan_id', '!=', False), ('employee_id', '!=', False), + allocations = self.search([ + ('allocation_type', '=', 'accrual'), ('state', '=', 'validate'), + ('accrual_plan_id', '!=', False), ('employee_id', '!=', False), '|', ('date_to', '=', False), ('date_to', '>', fields.Datetime.now()), '|', ('nextcall', '=', False), ('nextcall', '<=', today)]) allocations._process_accrual_plans() @@ -757,6 +772,22 @@ class HolidaysAllocation(models.Model): elif not self.number_of_days_display: self.number_of_days = 1.0 + # Allows user to simulate how many days an accrual plan would give from a certain start date. + # it uses the actual computation function but resets values of lastcall, nextcall and nbr of days + # before every run, as if it was run from date_from, after an optional change in the allocation value + # the user can simply confirm and validate the allocation. The record is in correct state for the next + # call of the cron job. + @api.onchange('date_from', 'accrual_plan_id') + def _onchange_date_from(self): + now = date.today() + if self.allocation_type != 'accrual' or self.state == 'validate' or not self.accrual_plan_id\ + or not self.employee_id or not (not self.date_to or self.date_to > now): + return + self.lastcall = self.date_from + self.nextcall = False + self.number_of_days_display = 0.0 + self._process_accrual_plans() + # ------------------------------------------------------------ # Activity methods # ------------------------------------------------------------ diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py index fda87bdc930..0a18d997544 100644 --- a/addons/hr_holidays/models/hr_leave_type.py +++ b/addons/hr_holidays/models/hr_leave_type.py @@ -101,7 +101,6 @@ class HolidaysType(models.Model): accruals_ids = fields.One2many('hr.leave.accrual.plan', 'time_off_type_id') accrual_count = fields.Float(compute="_compute_accrual_count", string="Accruals count") - @api.model def _search_valid(self, operator, value): """ Returns leave_type ids for which a valid allocation exists @@ -134,7 +133,6 @@ class HolidaysType(models.Model): return [('id', new_operator, [x['holiday_status_id'] for x in self._cr.dictfetchall()])] - @api.depends('requires_allocation') def _compute_valid(self): date_to = self._context.get('default_date_to', fields.Datetime.today()) diff --git a/addons/hr_holidays/static/src/js/accrual_levels_widget.js b/addons/hr_holidays/static/src/js/accrual_levels_widget.js new file mode 100644 index 00000000000..a13fdefb0c1 --- /dev/null +++ b/addons/hr_holidays/static/src/js/accrual_levels_widget.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field"; + +export class AccrualLevelsX2ManyField extends X2ManyField {}; +AccrualLevelsX2ManyField.template = "hr_holidays.AccrualLevelsX2ManyField"; + +export const accrualLevelsX2ManyField = { + ...x2ManyField, + component: AccrualLevelsX2ManyField, +}; + +registry.category("fields").add("accrual_levels_one2many", accrualLevelsX2ManyField); diff --git a/addons/hr_holidays/static/src/scss/accrual_plan_level.scss b/addons/hr_holidays/static/src/scss/accrual_plan_level.scss index 5dc0d22b1cd..5f0a60f008c 100644 --- a/addons/hr_holidays/static/src/scss/accrual_plan_level.scss +++ b/addons/hr_holidays/static/src/scss/accrual_plan_level.scss @@ -112,3 +112,6 @@ $o-hr-holidays-border-color: map-get($grays, '300'); } } } +.o_radio_item .o_form_label { + font-weight: normal; +} diff --git a/addons/hr_holidays/static/src/xml/x2many_field.xml b/addons/hr_holidays/static/src/xml/x2many_field.xml new file mode 100644 index 00000000000..f05f45f71eb --- /dev/null +++ b/addons/hr_holidays/static/src/xml/x2many_field.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/hr_holidays/tests/test_accrual_allocations.py b/addons/hr_holidays/tests/test_accrual_allocations.py index c27c421d30f..2f9c284e006 100644 --- a/addons/hr_holidays/tests/test_accrual_allocations.py +++ b/addons/hr_holidays/tests/test_accrual_allocations.py @@ -21,6 +21,13 @@ class TestAccrualAllocations(TestHrHolidaysCommon): 'requires_allocation': 'yes', 'allocation_validation_type': 'officer', }) + cls.leave_type_hour = cls.env['hr.leave.type'].create({ + 'name': 'Paid Time Off', + 'time_type': 'leave', + 'requires_allocation': 'yes', + 'allocation_validation_type': 'officer', + 'request_unit': 'hour', + }) def setAllocationCreateDate(self, allocation_id, date): """ This method is a hack in order to be able to define/redefine the create_date @@ -35,15 +42,16 @@ class TestAccrualAllocations(TestHrHolidaysCommon): """ % (date, allocation_id)) def test_frequency_daily(self): - with freeze_time(datetime.date(2017, 12, 5)): + 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': 'days', + 'added_value_type': 'day', 'frequency': 'daily', + 'cap_accrued_time': True, 'maximum_leave': 10000 })], }) @@ -72,15 +80,16 @@ class TestAccrualAllocations(TestHrHolidaysCommon): self.assertEqual(allocation.number_of_days, 1, 'There should be only 1 day allocated.') def test_frequency_weekly(self): - with freeze_time(datetime.date(2017, 12, 5)): + 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, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'weekly', + 'cap_accrued_time': True, 'maximum_leave': 10000 })], }) @@ -105,7 +114,7 @@ class TestAccrualAllocations(TestHrHolidaysCommon): with freeze_time(nextWeek): allocation._update_accrual() nextWeek = datetime.date.today() + relativedelta(days=1, weekday=0) - #Prorated + # Prorated self.assertAlmostEqual(allocation.number_of_days, 0.2857, 4, 'There should be 0.2857 day allocated.') self.assertEqual(allocation.nextcall, nextWeek, 'The next call date of the cron should be in 2 weeks') @@ -120,13 +129,14 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'bimonthly', 'first_day': 1, 'second_day': 15, + 'cap_accrued_time': True, 'maximum_leave': 10000, })], }) @@ -151,13 +161,13 @@ class TestAccrualAllocations(TestHrHolidaysCommon): with freeze_time(next_date): next_date = datetime.date(2021, 10, 1) allocation._update_accrual() - #Prorated + # Prorated self.assertAlmostEqual(allocation.number_of_days, 0.7857, 4, 'There should be 0.7857 day allocated.') self.assertEqual(allocation.nextcall, next_date, 'The next call date of the cron should be October 1st') with freeze_time(next_date): allocation._update_accrual() - #Not Prorated + # Not Prorated self.assertAlmostEqual(allocation.number_of_days, 1.7857, 4, 'There should be 1.7857 day allocated.') def test_frequency_monthly(self): @@ -165,11 +175,12 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'monthly', + 'cap_accrued_time': True, 'maximum_leave': 10000 })], }) @@ -203,15 +214,16 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'biyearly', + 'cap_accrued_time': True, 'maximum_leave': 10000, })], }) - #this sets up an accrual on the 1st of January and the 1st of July + # this sets up an accrual on the 1st of January and the 1st of July 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, @@ -246,15 +258,16 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'yearly', + 'cap_accrued_time': True, 'maximum_leave': 10000, })], }) - #this sets up an accrual on the 1st of January + # this sets up an accrual on the 1st of January 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, @@ -319,24 +332,26 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan_not_based_on_worked_time = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 5, - 'added_value_type': 'days', 'frequency': 'weekly', + 'cap_accrued_time': True, 'maximum_leave': 10000, })], }) accrual_plan_based_on_worked_time = 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, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 5, - 'added_value_type': 'days', 'frequency': 'weekly', + 'cap_accrued_time': True, 'maximum_leave': 10000, - 'is_based_on_worked_time': True, })], }) allocation_not_worked_time = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ @@ -401,15 +416,16 @@ class TestAccrualAllocations(TestHrHolidaysCommon): self.assertEqual(allocation_worked_time.nextcall, next_date, 'The next call date of the cron should be September 20th') def test_check_max_value(self): - with freeze_time(datetime.date(2017, 12, 5)): + 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, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'daily', + 'cap_accrued_time': True, 'maximum_leave': 1, })], }) @@ -436,20 +452,21 @@ class TestAccrualAllocations(TestHrHolidaysCommon): with freeze_time(nextcall): allocation._update_accrual() nextcall = datetime.date.today() + relativedelta(days=1) - #The maximum value is 1 so this shouldn't change anything + # The maximum value is 1 so this shouldn't change anything allocation._update_accrual() self.assertEqual(allocation.number_of_days, 1, 'There should be only 1 day allocated.') def test_check_max_value_hours(self): - with freeze_time(datetime.date(2017, 12, 5)): + 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, { + 'added_value_type': 'hour', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'hours', 'frequency': 'daily', + 'cap_accrued_time': True, 'maximum_leave': 4, })], }) @@ -476,29 +493,30 @@ class TestAccrualAllocations(TestHrHolidaysCommon): with freeze_time(nextcall): allocation._update_accrual() nextcall = datetime.date.today() + relativedelta(days=1) - #The maximum value is 1 so this shouldn't change anything + # The maximum value is 1 so this shouldn't change anything allocation._update_accrual() self.assertEqual(allocation.number_of_days, 0.5, 'There should be only 0.5 days allocated.') def test_accrual_transition_immediately(self): - with freeze_time(datetime.date(2017, 12, 5)): - #1 accrual with 2 levels and level transition immediately + with freeze_time("2017-12-5"): + # 1 accrual with 2 levels and level transition immediately accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'transition_mode': 'immediately', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'weekly', + 'cap_accrued_time': True, 'maximum_leave': 1, }), (0, 0, { 'start_count': 10, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'weekly', + 'cap_accrued_time': True, 'maximum_leave': 1, })], }) @@ -517,24 +535,25 @@ class TestAccrualAllocations(TestHrHolidaysCommon): self.assertEqual(allocation._get_current_accrual_plan_level_id(next_date)[0], second_level, 'The second level should be selected') def test_accrual_transition_after_period(self): - with freeze_time(datetime.date(2017, 12, 5)): + with freeze_time("2017-12-5"): # 1 accrual with 2 levels and level transition after accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'transition_mode': 'end_of_accrual', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'weekly', + 'cap_accrued_time': True, 'maximum_leave': 1, }), (0, 0, { 'start_count': 10, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'weekly', + 'cap_accrued_time': True, 'maximum_leave': 1, })], }) @@ -553,16 +572,17 @@ class TestAccrualAllocations(TestHrHolidaysCommon): self.assertEqual(allocation._get_current_accrual_plan_level_id(next_date)[0], second_level, 'The second level should be selected') def test_unused_accrual_lost(self): - #1 accrual with 2 levels and level transition immediately - with freeze_time('2021-09-01'): + # 1 accrual with 2 levels and level transition immediately + with freeze_time('2021-12-15'): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'daily', + 'cap_accrued_time': True, 'maximum_leave': 1, 'action_with_unused_accruals': 'lost', })], @@ -580,25 +600,26 @@ class TestAccrualAllocations(TestHrHolidaysCommon): # Reset the cron's lastcall accrual_cron = self.env['ir.cron'].sudo().env.ref('hr_holidays.hr_leave_allocation_cron_accrual') - accrual_cron.lastcall = datetime.date(2021, 9, 1) + accrual_cron.lastcall = datetime.date(2021, 12, 15) with freeze_time('2022-01-01'): allocation._update_accrual() - self.assertEqual(allocation.number_of_days, 1, 'The number of days should be reset') + self.assertEqual(allocation.number_of_days, 0, 'The number of days should be reset') def test_unused_accrual_postponed(self): # 1 accrual with 2 levels and level transition after # This also tests retroactivity - with freeze_time('2021-09-01'): + with freeze_time('2021-12-15'): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'daily', + 'cap_accrued_time': True, 'maximum_leave': 25, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'all', })], }) allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ @@ -614,22 +635,24 @@ class TestAccrualAllocations(TestHrHolidaysCommon): # Reset the cron's lastcall accrual_cron = self.env['ir.cron'].sudo().env.ref('hr_holidays.hr_leave_allocation_cron_accrual') - accrual_cron.lastcall = datetime.date(2021, 9, 1) + accrual_cron.lastcall = datetime.date(2021, 12, 15) with freeze_time('2022-01-01'): allocation._update_accrual() self.assertEqual(allocation.number_of_days, 25, 'The maximum number of days should be reached and kept.') + def test_unused_accrual_postponed_2(self): with freeze_time('2021-01-01'): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 0, 'start_type': 'day', 'added_value': 2, - 'added_value_type': 'days', 'frequency': 'yearly', + 'cap_accrued_time': True, 'maximum_leave': 100, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'maximum', 'postpone_max_days': 10, })], }) @@ -649,22 +672,24 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_cron.lastcall = datetime.date(2021, 1, 1) with freeze_time('2023-01-26'): allocation._update_accrual() - self.assertEqual(allocation.number_of_days, 6, 'The maximum number of days should be reached and kept.') + self.assertEqual(allocation.number_of_days, 4, 'The maximum number of days should be reached and kept.') def test_unused_accrual_postponed_limit(self): # 1 accrual with 2 levels and level transition after # This also tests retroactivity - with freeze_time('2021-09-01'): + with freeze_time('2021-12-15'): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'start', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'daily', + 'cap_accrued_time': True, 'maximum_leave': 25, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'maximum', 'postpone_max_days': 15, })], }) @@ -681,22 +706,24 @@ class TestAccrualAllocations(TestHrHolidaysCommon): # Reset the cron's lastcall accrual_cron = self.env['ir.cron'].sudo().env.ref('hr_holidays.hr_leave_allocation_cron_accrual') - accrual_cron.lastcall = datetime.date(2021, 9, 1) + accrual_cron.lastcall = datetime.date(2021, 12, 15) with freeze_time('2022-01-01'): allocation._update_accrual() self.assertEqual(allocation.number_of_days, 16, 'The maximum number of days should be reached and kept.') + def test_unused_accrual_postponed_limit_2(self): with freeze_time('2021-01-01'): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 0, 'start_type': 'day', 'added_value': 15, - 'added_value_type': 'days', 'frequency': 'yearly', + 'cap_accrued_time': True, 'maximum_leave': 100, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'maximum', 'postpone_max_days': 7, })], }) @@ -716,7 +743,7 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_cron.lastcall = datetime.date(2021, 1, 1) with freeze_time('2023-01-26'): allocation._update_accrual() - self.assertEqual(allocation.number_of_days, 22, 'The maximum number of days should be reached and kept.') + self.assertEqual(allocation.number_of_days, 7, 'The maximum number of days should be reached and kept.') def test_accrual_skipped_period(self): # Test that when an allocation is made in the past and the second level is technically reached @@ -724,21 +751,22 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 0, 'start_type': 'day', 'added_value': 15, - 'added_value_type': 'days', 'frequency': 'biyearly', + 'cap_accrued_time': True, 'maximum_leave': 100, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'all', }), (0, 0, { 'start_count': 4, 'start_type': 'month', 'added_value': 10, - 'added_value_type': 'days', 'frequency': 'biyearly', + 'cap_accrued_time': True, 'maximum_leave': 500, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'all', })], }) allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ @@ -760,31 +788,32 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 2, 'start_type': 'month', 'added_value': 3, - 'added_value_type': 'days', 'frequency': 'monthly', + 'cap_accrued_time': True, 'maximum_leave': 3, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'all', 'first_day': 31, }), (0, 0, { 'start_count': 3, 'start_type': 'month', 'added_value': 6, - 'added_value_type': 'days', 'frequency': 'monthly', + 'cap_accrued_time': True, 'maximum_leave': 6, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'all', 'first_day': 31, }), (0, 0, { 'start_count': 4, 'start_type': 'month', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'monthly', + 'cap_accrued_time': True, 'maximum_leave': 100, - 'action_with_unused_accruals': 'postponed', + 'action_with_unused_accruals': 'all', 'first_day': 31, })], }) @@ -814,11 +843,12 @@ class TestAccrualAllocations(TestHrHolidaysCommon): 'name': 'Accrual Plan For Test', 'level_ids': [ (0, 0, { + 'added_value_type': 'day', 'start_count': 0, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'monthly', + 'cap_accrued_time': True, 'maximum_leave': 12, 'action_with_unused_accruals': 'lost', }), @@ -826,8 +856,8 @@ class TestAccrualAllocations(TestHrHolidaysCommon): 'start_count': 1, 'start_type': 'year', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'monthly', + 'cap_accrued_time': True, 'maximum_leave': 12, 'action_with_unused_accruals': 'lost', }), @@ -846,18 +876,20 @@ class TestAccrualAllocations(TestHrHolidaysCommon): allocation.action_validate() with freeze_time('2022-4-4'): allocation._update_accrual() - self.assertEqual(allocation.number_of_days, 4, "Invalid number of days") + self.assertEqual(allocation.number_of_days, 3, "Invalid number of days") def test_accrual_lost_first_january(self): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'start', 'level_ids': [ (0, 0, { + 'added_value_type': 'day', 'start_count': 0, 'start_type': 'day', 'added_value': 3, - 'added_value_type': 'days', 'frequency': 'yearly', + 'cap_accrued_time': True, 'maximum_leave': 12, 'action_with_unused_accruals': 'lost', }) @@ -882,11 +914,12 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'daily', + 'cap_accrued_time': True, 'maximum_leave': 5, })], }) @@ -900,7 +933,7 @@ class TestAccrualAllocations(TestHrHolidaysCommon): 'date_from': '2021-09-03', }) - with freeze_time(datetime.date(2021, 10, 3)): + with freeze_time("2021-10-3"): allocation.action_confirm() allocation.action_validate() allocation._update_accrual() @@ -911,12 +944,12 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 1, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'daily', - 'maximum_leave': 0, + 'cap_accrued_time': False, })], }) allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ @@ -929,7 +962,7 @@ class TestAccrualAllocations(TestHrHolidaysCommon): 'date_from': '2021-09-03', }) - with freeze_time(datetime.date(2021, 10, 3)): + with freeze_time("2021-10-3"): allocation.action_confirm() allocation.action_validate() allocation._update_accrual() @@ -940,12 +973,13 @@ class TestAccrualAllocations(TestHrHolidaysCommon): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'day', 'start_count': 0, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'days', 'frequency': 'weekly', 'week_day': 'mon', + 'cap_accrued_time': True, 'maximum_leave': 5, })], }) @@ -961,7 +995,7 @@ class TestAccrualAllocations(TestHrHolidaysCommon): allocation.action_confirm() allocation.action_validate() - with freeze_time(datetime.date(2022, 3, 2)): + with freeze_time("2022-3-2"): allocation._update_accrual() self.assertEqual(allocation.number_of_days, 5, "Maximum of 5 days accrued") @@ -975,20 +1009,21 @@ class TestAccrualAllocations(TestHrHolidaysCommon): }) leave.action_validate() - with freeze_time(datetime.date(2022, 6, 1)): + with freeze_time("2022-6-1"): allocation._update_accrual() self.assertEqual(allocation.number_of_days, 10, "Should accrue 5 additional days") - def test_accrual_leaves_taken_maximum_hours(self): + def test_aaaccrual_leaves_taken_maximum_hours(self): accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ 'name': 'Accrual Plan For Test', 'level_ids': [(0, 0, { + 'added_value_type': 'hour', 'start_count': 0, 'start_type': 'day', 'added_value': 1, - 'added_value_type': 'hours', 'frequency': 'weekly', 'week_day': 'mon', + 'cap_accrued_time': True, 'maximum_leave': 10, })], }) @@ -996,7 +1031,7 @@ class TestAccrualAllocations(TestHrHolidaysCommon): 'name': 'Accrual allocation for employee', 'accrual_plan_id': accrual_plan.id, 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.leave_type.id, + 'holiday_status_id': self.leave_type_hour.id, 'number_of_days': 0, 'allocation_type': 'accrual', 'date_from': '2022-01-01', @@ -1012,7 +1047,7 @@ class TestAccrualAllocations(TestHrHolidaysCommon): leave = self.env['hr.leave'].create({ 'name': 'leave', 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.leave_type.id, + 'holiday_status_id': self.leave_type_hour.id, 'date_from': '2022-03-07 00:00:00', 'date_to': '2022-03-07 23:59:59' }) @@ -1021,3 +1056,287 @@ class TestAccrualAllocations(TestHrHolidaysCommon): with freeze_time(datetime.date(2022, 6, 1)): allocation._update_accrual() self.assertEqual(allocation.number_of_days, 18 / self.hours_per_day, "Should accrue 8 additional hours") + + def test_accrual_period_start(self): + accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ + 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'end', + 'level_ids': [(0, 0, { + 'added_value_type': 'day', + 'start_count': 0, + 'start_type': 'day', + 'added_value': 1, + 'frequency': 'weekly', + 'week_day': 'mon', + 'cap_accrued_time': True, + 'maximum_leave': 5, + })], + }) + 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', + 'date_from': '2023-04-24', + }) + allocation.action_confirm() + allocation.action_validate() + + with freeze_time("2023-4-24"): + allocation._update_accrual() + + self.assertEqual(allocation.number_of_days, 0, "Should accrue 0 days, because the period is not done yet.") + + accrual_plan.accrued_gain_time = 'start' + 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', + 'date_from': '2023-04-24', + }) + allocation.action_confirm() + allocation.action_validate() + + with freeze_time("2023-4-24"): + allocation._update_accrual() + + self.assertEqual(allocation.number_of_days, 1, "Should accrue 1 day, at the start of the period.") + + def test_aaaccrual_period_start_multiple_runs(self): + accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ + 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'start', + 'level_ids': [(0, 0, { + 'added_value_type': 'day', + 'start_count': 0, + 'start_type': 'day', + 'added_value': 1.5, + 'frequency': 'monthly', + 'first_day': 13, + 'cap_accrued_time': True, + 'maximum_leave': 15, + })], + }) + 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', + 'date_from': '2023-04-13', + }) + allocation.action_confirm() + allocation.action_validate() + + with freeze_time("2023-4-13"): + allocation._update_accrual() + + self.assertAlmostEqual(allocation.number_of_days, 1.5, 2) + + with freeze_time("2023-9-13"): + allocation._update_accrual() + + self.assertAlmostEqual(allocation.number_of_days, 9, 2) + + def test_accrual_period_start_level_transfer(self): + accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ + 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'start', + 'level_ids': [ + (0, 0, { + 'added_value_type': 'day', + 'start_count': 0, + 'start_type': 'day', + 'added_value': 1, + 'frequency': 'weekly', + 'week_day': 'wed', + 'cap_accrued_time': True, + 'maximum_leave': 10, + }), + (0, 0, { + 'start_count': 3, + 'start_type': 'month', + 'added_value': 2, + 'frequency': 'weekly', + 'week_day': 'wed', + 'cap_accrued_time': True, + 'maximum_leave': 5, + }) + ], + }) + 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', + 'date_from': '2023-04-26', + }) + allocation.action_confirm() + allocation.action_validate() + + with freeze_time("2023-4-26"): + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 1, "Should accrue 1 day, at the start of the period.") + + with freeze_time("2023-7-5"): + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 10, "Should accrue 10 days, days received, but not over limit.") + + # first wednesday at the second level + with freeze_time("2023-8-02"): + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 5, "Should accrue 5 days, after level transfer 10 are cut to 5") + + def test_accrual_carryover_at_allocation(self): + accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ + 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'start', + 'carryover_date': 'allocation', + 'level_ids': [(0, 0, { + 'added_value_type': 'day', + 'start_count': 0, + 'start_type': 'day', + 'added_value': 1, + 'frequency': 'monthly', + 'first_day': 27, + 'cap_accrued_time': False, + 'action_with_unused_accruals': 'lost', + })], + }) + 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', + 'date_from': '2023-04-26', + }) + allocation.action_confirm() + allocation.action_validate() + + with freeze_time("2023-4-26"): + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 0, "Should accrue 0 days, days are added on 27th.") + + with freeze_time("2023-4-27"): + allocation._update_accrual() + self.assertAlmostEqual(allocation.number_of_days, 1.03, 2, "Should accrue 1 day, days are added on 27th.") + + with freeze_time("2023-12-27"): + allocation._update_accrual() + self.assertAlmostEqual(allocation.number_of_days, 9.03, 2, "Should accrue 9 day, after 8 months.") + + with freeze_time("2024-04-26"): + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 0, "Allocations not lost on 1st of January, but on allocation date.") + + with freeze_time("2024-04-27"): + allocation._update_accrual() + self.assertAlmostEqual(allocation.number_of_days, 1.03, 2, "Allocations lost, then 1 accrued.") + + def test_accrual_carryover_at_other(self): + accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ + 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'start', + 'carryover_date': 'other', + 'carryover_day': 20, + 'carryover_month': 'apr', + 'level_ids': [(0, 0, { + 'added_value_type': 'day', + 'start_count': 0, + 'start_type': 'day', + 'added_value': 10, + 'frequency': 'monthly', + 'first_day': 11, + 'cap_accrued_time': False, + 'action_with_unused_accruals': 'maximum', + 'postpone_max_days': 69, + })], + }) + 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', + 'date_from': '2023-04-20', + }) + allocation.action_confirm() + allocation.action_validate() + + with freeze_time("2024-04-20"): + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 69, "Carryover at other date, level's maximum leave is 69.") + + def test_accrual_carrover_other_period_end_multi_level(self): + accrual_plan = self.env['hr.leave.accrual.plan'].with_context(tracking_disable=True).create({ + 'name': 'Accrual Plan For Test', + 'accrued_gain_time': 'end', + 'carryover_date': 'other', + 'carryover_day': 5, + 'carryover_month': 'jun', + 'level_ids': [ + (0, 0, { + 'added_value_type': 'day', + 'start_count': 5, + 'start_type': 'day', + 'added_value': 1, + 'frequency': 'monthly', + 'first_day': 9, + 'cap_accrued_time': True, + 'maximum_leave': 15, + 'action_with_unused_accruals': 'maximum', + 'postpone_max_days': 13, + }), + (0, 0, { + 'start_count': 9, + 'start_type': 'month', + 'added_value': 2, + 'frequency': 'biyearly', + 'first_month_day': 17, + 'first_month': 'feb', + 'second_month_day': 29, + 'second_month': 'oct', + 'cap_accrued_time': True, + 'maximum_leave': 10, + 'action_with_unused_accruals': 'maximum', + 'postpone_max_days': 20, + }), + (0, 0, { + 'start_count': 17, + 'start_type': 'month', + 'added_value': 12, + 'frequency': 'yearly', + 'yearly_month': 'jul', + 'yearly_day': 15, + 'cap_accrued_time': True, + 'maximum_leave': 21, + 'action_with_unused_accruals': 'lost', + }), + ], + }) + 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': 9, + 'allocation_type': 'accrual', + 'date_from': '2023-04-4', + }) + allocation.action_confirm() + allocation.action_validate() + + with freeze_time("2026-08-01"): + allocation._update_accrual() + self.assertAlmostEqual(allocation.number_of_days, 1.315, 3) diff --git a/addons/hr_holidays/views/hr_leave_accrual_views.xml b/addons/hr_holidays/views/hr_leave_accrual_views.xml index 9d99b5acc4a..6a1de77485c 100644 --- a/addons/hr_holidays/views/hr_leave_accrual_views.xml +++ b/addons/hr_holidays/views/hr_leave_accrual_views.xml @@ -6,49 +6,73 @@
- - -