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 fb3c7cb7a4a..d9eb4f8762e 100644 --- a/addons/hr_holidays/models/hr_leave_accrual_plan_level.py +++ b/addons/hr_holidays/models/hr_leave_accrual_plan_level.py @@ -121,6 +121,8 @@ class AccrualPlanLevel(models.Model): ('lost', 'Lost')], string="At the end of the calendar year, unused accruals will be", default='postponed', 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.") _sql_constraints = [ ('check_dates', diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py index df027a44d7c..f44d73d6fc0 100644 --- a/addons/hr_holidays/models/hr_leave_allocation.py +++ b/addons/hr_holidays/models/hr_leave_allocation.py @@ -330,11 +330,22 @@ class HolidaysAllocation(models.Model): 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) for allocation in self: current_level = allocation._get_current_accrual_plan_level_id(today)[0] - if current_level and current_level.action_with_unused_accruals == 'lost': + if not current_level: + continue + nextcall = current_level._get_next_date(today) + 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': today, 'nextcall': False}) + allocation.write({'number_of_days': allocation.leaves_taken, 'lastcall': today, '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 = last_day_last_year + allocation._process_accrual_plans(last_day_last_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': today, 'nextcall': nextcall}) def _get_current_accrual_plan_level_id(self, date, level_ids=False): """ @@ -392,12 +403,13 @@ class HolidaysAllocation(models.Model): period_prorata = min(1, call_days / period_days) if period_days else 1 return added_value * period_prorata - def _process_accrual_plans(self): + def _process_accrual_plans(self, date_to=False, force_period=False): """ This method is part of the cron's process. - The goal of this method is to retroactively apply accrual plan levels and progress from nextcall to today + The goal of this method is to retroactively apply accrual plan levels and progress from nextcall to date_to or today. + If force_period is set, the accrual will run until date_to in a prorated way (used for end of year accrual actions). """ - today = fields.Date.today() + date_to = date_to or fields.Date.today() first_allocation = _("""This allocation have already ran once, any modification won't be effective to the days allocated to the employee. If you need to change the configuration of the allocation, cancel and create a new one.""") for allocation in self: level_ids = allocation.accrual_plan_id.level_ids.sorted('sequence') @@ -406,7 +418,7 @@ class HolidaysAllocation(models.Model): 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) - if today < first_level_start_date: + 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) @@ -416,7 +428,7 @@ class HolidaysAllocation(models.Model): allocation.nextcall = min(second_level_start_date - relativedelta(days=1), allocation.nextcall) allocation._message_log(body=first_allocation) days_added_per_level = defaultdict(lambda: 0) - while allocation.nextcall <= today: + while allocation.nextcall <= date_to: (current_level, current_level_idx) = allocation._get_current_accrual_plan_level_id(allocation.nextcall) nextcall = current_level._get_next_date(allocation.nextcall) # Since _get_previous_date returns the given date if it corresponds to a call date @@ -424,29 +436,46 @@ class HolidaysAllocation(models.Model): # 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) - # If accruals are lost at the beginning of year, skip accrual until beginning of this year - if current_level.action_with_unused_accruals == 'lost': - this_year_first_day = (today + relativedelta(day=1, month=1)).date() - if period_end < this_year_first_day or period_start < period_end: - allocation.lastcall = allocation.nextcall - allocation.nextcall = nextcall - continue - else: - period_start = max(period_start, this_year_first_day) # Also prorate this accrual in the event that we are passing from one level to another 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) - relativedelta(days=1) if allocation.nextcall != current_level_last_date: nextcall = min(nextcall, current_level_last_date) - days_added_per_level[current_level] += allocation._process_accrual_plan_level( + + 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 + # 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 == 'lost' or\ + current_level.postpone_max_days > 0): + after_period_gains = allocation._process_accrual_plan_level( + current_level, period_start, allocation.nextcall + relativedelta(day=1, month=1), + period_end, allocation.nextcall) + if current_level.action_with_unused_accruals == 'postponed': + # 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 - after_period_gains, allowed_to_keep) + else: + allocation.number_of_days = allocation.leaves_taken + days_added_per_level.clear() + days_added_per_level[current_level] += after_period_gains + allocation.lastcall = allocation.nextcall allocation.nextcall = nextcall + 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 = 0 - for value in days_added_per_level.values(): - number_of_days_to_add += value + number_of_days_to_add = sum(days_added_per_level.values()) # Let's assume the limit of the last level is the correct one allocation.write({'number_of_days': min(allocation.number_of_days + number_of_days_to_add, current_level.maximum_leave)}) diff --git a/addons/hr_holidays/tests/test_accrual_allocations.py b/addons/hr_holidays/tests/test_accrual_allocations.py index 5f3715d258e..3ae7acb7a03 100644 --- a/addons/hr_holidays/tests/test_accrual_allocations.py +++ b/addons/hr_holidays/tests/test_accrual_allocations.py @@ -522,6 +522,9 @@ class TestAccrualAllocations(TestHrHolidaysCommon): allocation.action_confirm() allocation.action_validate() + # 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) with freeze_time('2022-01-01'): allocation._update_accrual() self.assertEqual(allocation.number_of_days, 0, 'There number of days should be reset') @@ -553,10 +556,48 @@ class TestAccrualAllocations(TestHrHolidaysCommon): allocation.action_confirm() allocation.action_validate() + # 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) 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_limit(self): + # 1 accrual with 2 levels and level transition after + # This also tests retroactivity + with freeze_time('2021-09-01'): + 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', + 'frequency': 'daily', + 'maximum_leave': 25, + 'action_with_unused_accruals': 'postponed', + 'postpone_max_days': 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': 10, + 'allocation_type': 'accrual', + }) + allocation.action_confirm() + allocation.action_validate() + + # 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) + with freeze_time('2022-01-01'): + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 15, '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 # that the first level is not skipped completely. diff --git a/addons/hr_holidays/views/hr_leave_accrual_views.xml b/addons/hr_holidays/views/hr_leave_accrual_views.xml index cd41690b5db..c58f2245bc6 100644 --- a/addons/hr_holidays/views/hr_leave_accrual_views.xml +++ b/addons/hr_holidays/views/hr_leave_accrual_views.xml @@ -52,6 +52,7 @@ +