diff --git a/hr_holidays_accrual_advanced/README.rst b/hr_holidays_accrual_advanced/README.rst new file mode 100644 index 00000000000..52253dd06af --- /dev/null +++ b/hr_holidays_accrual_advanced/README.rst @@ -0,0 +1,95 @@ +=========================== +Advanced Accrual Allocation +=========================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr-lightgray.png?logo=github + :target: https://github.com/OCA/hr/tree/12.0/hr_holidays_accrual_advanced + :alt: OCA/hr +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-12-0/hr-12-0-hr_holidays_accrual_advanced + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/116/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides advanced accrual leaves allocation as extension to +out-of-the-box per-employee accrual leave allocation capabilities of Odoo, +introducing following extra features: + + * Accrual allocation history + * Accrual allocation calculator ("How many leave days I'll have in 3 months from today?") + * Various accrual methods + * Various limits to express complex corporate accrual leave policies + * Takes into account employee service period instead of ``create_date`` + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module is an almost-replacement of accrual feature from the +``hr_holidays`` module and its features are configured in the same manner +under the Leave Types menu. + +Known issues / Roadmap +====================== + +This module overrides accruement computation from base Odoo ``hr_holidays`` +module with its own, yet the provided implementation is backwards-compatible +with out-of-the-box Odoo behaviour. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Brainbean Apps + +Contributors +~~~~~~~~~~~~ + +* Alexey Pelykh + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/hr `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_holidays_accrual_advanced/__init__.py b/hr_holidays_accrual_advanced/__init__.py new file mode 100644 index 00000000000..e1e14440619 --- /dev/null +++ b/hr_holidays_accrual_advanced/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models +from . import wizards diff --git a/hr_holidays_accrual_advanced/__manifest__.py b/hr_holidays_accrual_advanced/__manifest__.py new file mode 100644 index 00000000000..f66353aa91a --- /dev/null +++ b/hr_holidays_accrual_advanced/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + 'name': 'Advanced Accrual Allocation', + 'version': '12.0.1.0.0', + 'category': 'Human Resources', + 'website': 'https://github.com/OCA/hr', + 'author': + 'Brainbean Apps, ' + 'Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'installable': True, + 'application': False, + 'summary': 'Advanced accrual leaves allocation', + 'depends': [ + 'hr_holidays', + 'hr_employee_service', + ], + 'data': [ + 'security/hr_holidays_accrual_security.xml', + 'security/ir.model.access.csv', + 'wizards/hr_leave_allocation_accrual_calculator.xml', + 'views/hr_leave_allocation.xml', + 'views/hr_leave_allocation_accruement.xml', + ], +} diff --git a/hr_holidays_accrual_advanced/models/__init__.py b/hr_holidays_accrual_advanced/models/__init__.py new file mode 100644 index 00000000000..da9780859c1 --- /dev/null +++ b/hr_holidays_accrual_advanced/models/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import hr_leave +from . import hr_leave_allocation +from . import hr_leave_allocation_accruement +from . import resource_calendar_leaves diff --git a/hr_holidays_accrual_advanced/models/hr_leave.py b/hr_holidays_accrual_advanced/models/hr_leave.py new file mode 100644 index 00000000000..9286b581cf5 --- /dev/null +++ b/hr_holidays_accrual_advanced/models/hr_leave.py @@ -0,0 +1,17 @@ +# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models, fields + + +class HrLeave(models.Model): + _inherit = 'hr.leave' + + time_type = fields.Selection( + related='holiday_status_id.time_type', + store=True, + ) + unpaid = fields.Boolean( + related='holiday_status_id.unpaid', + store=True, + ) diff --git a/hr_holidays_accrual_advanced/models/hr_leave_allocation.py b/hr_holidays_accrual_advanced/models/hr_leave_allocation.py new file mode 100644 index 00000000000..25563468b56 --- /dev/null +++ b/hr_holidays_accrual_advanced/models/hr_leave_allocation.py @@ -0,0 +1,624 @@ +# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from collections import namedtuple, defaultdict +from math import ceil +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta +from pytz import utc + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from odoo.addons.resource.models.resource import HOURS_PER_DAY +from odoo.addons.resource.models.resource_mixin import ROUNDING_FACTOR +from odoo.tools import float_utils + +_logger = logging.getLogger(__name__) + + +HrLeaveAllocationAccruementEntry = namedtuple( + 'HrLeaveAllocationAccruementEntry', + [ + 'days_accrued', + 'accrued_on', + 'reason', + ] +) + + +class HrLeaveAllocation(models.Model): + _inherit = 'hr.leave.allocation' + + accruement_ids = fields.One2many( + string='Accruements', + comodel_name='hr.leave.allocation.accruement', + inverse_name='leave_allocation_id', + readonly=True, + ) + limit_accrued_days = fields.Boolean( + string='Limit Number of Days Accrued', + track_visibility='onchange', + readonly=True, + states={ + 'draft': [('readonly', False)], + 'confirm': [('readonly', False)] + }, + help=( + 'Limit total maximum number of days accrued in one accrual' + ' period.' + ), + ) + max_accrued_days = fields.Float( + string='Max Number of Days Accrued', + track_visibility='onchange', + readonly=True, + states={ + 'draft': [('readonly', False)], + 'confirm': [('readonly', False)] + }, + help='Total maximum number of days accrued in one accrual period.', + ) + limit_carryover_days = fields.Boolean( + string='Limit Number of Days to Carryover', + track_visibility='onchange', + readonly=True, + states={ + 'draft': [('readonly', False)], + 'confirm': [('readonly', False)] + }, + help=( + 'Limit total maximum number of days to carryover to next accrual' + ' period.' + ), + ) + max_carryover_days = fields.Float( + string='Max Number of Days to Carryover', + track_visibility='onchange', + readonly=True, + states={ + 'draft': [('readonly', False)], + 'confirm': [('readonly', False)] + }, + help=( + 'Total maximum number of days to carryover to next accrual period.' + ), + ) + limit_accumulated_days = fields.Boolean( + string='Limit Total Balance', + track_visibility='onchange', + readonly=True, + states={ + 'draft': [('readonly', False)], + 'confirm': [('readonly', False)] + }, + help='Limit total maximum number of days that can be accumulated.', + ) + max_accumulated_days = fields.Float( + string='Total Balance Limit', + track_visibility='onchange', + readonly=True, + states={ + 'draft': [('readonly', False)], + 'confirm': [('readonly', False)] + }, + help='Total maximum number of days that can be accumulated.', + ) + accrual_method = fields.Selection( + selection=[ + ('prorate', 'Prorate'), + ('period_start', 'At the beginning of the period'), + ('period_end', 'At the end of the period'), + ], + string='Accrual Method', + default='prorate', + track_visibility='onchange', + readonly=True, + states={ + 'draft': [('readonly', False)], + 'confirm': [('readonly', False)] + }, + ) + accrual_limit = fields.Integer( + compute='_compute_accrual_limit', + readonly=True, + ) + number_per_interval = fields.Float( + string='Allocation per Accrual Period', + default=lambda self: self._default_number_per_interval(), + track_visibility='onchange', + help=( + 'Allocation accrued per Accrual Period, measured in' + ' Allocation Units' + ), + ) + interval_number = fields.Integer( + string='Accrual Period Duration', + default=lambda self: self._default_interval_number(), + track_visibility='onchange', + help=( + 'Duration of a single accrual period, measured in' + ' Accrual Period Units' + ), + ) + unit_per_interval = fields.Selection( + string='Allocation Unit', + default=lambda self: self._default_unit_per_interval(), + track_visibility='onchange', + help='Units in which Allocation per Accrual Period is defined', + ) + interval_unit = fields.Selection( + string='Accrual Period Unit', + default=lambda self: self._default_interval_unit(), + track_visibility='onchange', + help='Units in which Accrual Period Duration is defined', + ) + + @api.model + def _default_number_per_interval(self): + return 20.0 + + @api.model + def _default_interval_number(self): + return 1 + + @api.model + def _default_unit_per_interval(self): + return 'days' + + @api.model + def _default_interval_unit(self): + return 'years' + + @api.onchange('holiday_type') + def _onchange_holiday_type(self): # pragma: no cover + if self.holiday_type != 'employee': + self.accrual = False + + @api.multi + @api.depends('limit_accumulated_days', 'max_accumulated_days') + def _compute_accrual_limit(self): + for allocation in self: + if allocation.limit_accumulated_days: + allocation.accrual_limit = ceil( + allocation.max_accumulated_days + ) + else: + allocation.accrual_limit = 0 + + @api.multi + def action_recalculate_accrual_allocations(self): + for allocation in self: + allocation._update_accrual_allocation() + + @api.model + def action_recalculate_accrual_allocations_all(self): + allocations = self.search([ + ('accrual', '=', True), + ('state', '=', 'validate'), + ('holiday_type', '=', 'employee') + ]) + + for allocation in allocations: + allocation._update_accrual_allocation() + + @api.model + def create(self, values): + if 'holiday_type' in values and values['holiday_type'] != 'employee': + values['accrual'] = False + + accrual = values.get('accrual', False) + date_from = values.get('date_from', None) + + result = super().create(values) + + if accrual: + result.filtered( + lambda x: x.date_from != date_from + ).write({ + 'date_from': date_from, + }) + + return result + + @api.multi + def write(self, values): + if 'holiday_type' in values and values['holiday_type'] != 'employee': + values['accrual'] = False + return super().write(values) + + def _update_accrual(self): + super()._update_accrual() + + allocations = self.search([ + ('accrual', '=', True), + ('state', '=', 'validate'), + ('holiday_type', '=', 'employee') + ]) + + for allocation in allocations: + allocation._update_accrual_allocation() + + @api.multi + def _update_accrual_allocation(self): + self.ensure_one() + + if not self.accrual: # pragma: no cover + raise UserError(_('Only accrual allocations can be recalculated')) + + accruements, number_of_days = self._calculate_accrued_amount( + datetime.combine( + datetime.today(), + datetime.min.time() + ) + ) + + accruement_ids = [(5, False, False)] + for accruement in accruements: + accruement_ids.append((0, False, { + 'days_accrued': accruement.days_accrued, + 'accrued_on': accruement.accrued_on, + 'reason': accruement.reason, + })) + + self.with_context({ + 'mail_notrack': True, + }).write({ + 'number_of_days': number_of_days, + 'accruement_ids': accruement_ids, + }) + + @api.multi + def _calculate_accrued_amount( + self, + as_of_datetime, + ): + self.ensure_one() + + period = self._get_accrual_period() + date_from = self._get_date_from() + date_to = self._get_date_to() + + if not date_to or date_to > as_of_datetime: + date_to = as_of_datetime + + _logger.info( + ( + 'Calculating "%s" leave allocation for employee "%s"' + ' between %s and %s with %s period as of %s' + ), + self.holiday_status_id.display_name, + self.employee_id.display_name, + date_from, + date_to, + period, + as_of_datetime, + ) + + balance = 0.0 + total_leave_days = 0.0 + accruements = [] + while date_from < date_to: + if (self.limit_carryover_days + and balance > self.max_carryover_days): + loss = self.max_carryover_days - balance + accruements.append(HrLeaveAllocationAccruementEntry( + days_accrued=loss, + accrued_on=date_from.date(), + reason=_('Loss due to period carry-over limit') + )) + balance += loss + + period_start = date_from + period_end = min(period_start + period, date_to) + + worked_days = self._get_worked_days( + period_start, + period_end, + ) + workable_days = self._get_workable_days( + period_start, + period_start + period, + ) + leave_days = self._get_leave_days( + period_start, + period_end, + ) + + _logger.info( + ( + 'Employee "%s" / allocation %s (%s - %s):' + ' %s days worked, %s workable days, %s leave days' + ), + self.employee_id.name, + self.holiday_status_id.name, + period_start, + period_end, + worked_days, + workable_days, + leave_days, + ) + + accruement = self._get_days_to_accrue( + period_start, + period, + period_end, + as_of_datetime, + worked_days, + workable_days + ) + if accruement: + accruements.append(accruement) + balance += accruement.days_accrued + + if (self.limit_accrued_days + and accruement.days_accrued > self.max_accrued_days): + loss = self.max_accrued_days - accruement.days_accrued + accruements.append(HrLeaveAllocationAccruementEntry( + days_accrued=loss, + accrued_on=accruement.accrued_on, + reason=_('Loss due to accrued amount limit') + )) + balance += loss + + if (self.limit_accumulated_days + and balance > self.max_accumulated_days): + loss = self.max_accumulated_days - balance + accruements.append(HrLeaveAllocationAccruementEntry( + days_accrued=loss, + accrued_on=accruement.accrued_on, + reason=_('Loss due to accumulation limit') + )) + balance += loss + + if leave_days > 0: + accruements.append(HrLeaveAllocationAccruementEntry( + days_accrued=-leave_days, + accrued_on=period_end.date(), + reason=_('Usage during accruement period') + )) + balance -= leave_days + total_leave_days += leave_days + + date_from += period + + number_of_days = balance + total_leave_days + _logger.info( + '%s day(s) of "%s" leave allocated to employee "%s"', + number_of_days, + self.holiday_status_id.name, + self.employee_id.name, + ) + + return accruements, number_of_days + + @api.multi + def _get_worked_days(self, from_datetime, to_datetime): + """ + Compute number of worked days, that is computed as number workable days + without unpaid leaves (that are not on global leaves) counted in. + """ + self.ensure_one() + + # NOTE: This mimics ResourceMixin.get_work_days_data() w/ changes + + calendar = self.employee_id.resource_calendar_id + + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) + + # total hours per day: retrieve attendances with one extra day margin, + # in order to compute the total hours on the first and last days + intervals = calendar._attendance_intervals( + from_datetime - timedelta(days=1), + to_datetime + timedelta(days=1), + self.employee_id.resource_id, + ) + day_total = defaultdict(float) + for start, stop, meta in intervals: + day_total[start.date()] += (stop - start).total_seconds() / 3600 + + # actual hours per day + attendance_intervals = calendar._attendance_intervals( + from_datetime, + to_datetime, + self.employee_id.resource_id, + ) + unpaid_intervals = calendar._leave_intervals( + from_datetime, + to_datetime, + self.employee_id.resource_id, + domain=[ + ('unpaid', '=', True), + ('time_type', '=', 'leave') + ], + ) + global_intervals = calendar._leave_intervals( + from_datetime, + to_datetime, + None, + ) + intervals = ( + attendance_intervals - (unpaid_intervals - global_intervals) + ) + day_hours = defaultdict(float) + for start, stop, meta in intervals: + day_hours[start.date()] += (stop - start).total_seconds() / 3600 + + # compute number of days as quarters + return sum( + float_utils.round( + ROUNDING_FACTOR * day_hours[day] / day_total[day] + ) / ROUNDING_FACTOR + for day in day_hours + ) + + @api.multi + def _get_workable_days(self, from_datetime, to_datetime): + """ + Compute number of workable days, that is computed from calendar and + configured attendances only. + """ + self.ensure_one() + + return self.employee_id.get_work_days_data( + from_datetime, + to_datetime, + compute_leaves=False, + )['days'] + + @api.multi + def _get_leave_days(self, from_datetime, to_datetime): + """ + Compute number of days on used from the allocation, without global + leaves taken into account, other leaves are irrelevant since it's + ensured that no two leaves overlap. + """ + self.ensure_one() + + # NOTE: This mimics ResourceMixin.get_leave_days_data() w/ changes + + calendar = self.employee_id.resource_calendar_id + + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) + + # total hours per day: retrieve attendances with one extra day margin, + # in order to compute the total hours on the first and last days + intervals = calendar._attendance_intervals( + from_datetime - timedelta(days=1), + to_datetime + timedelta(days=1), + self.employee_id.resource_id, + ) + day_total = defaultdict(float) + for start, stop, meta in intervals: + day_total[start.date()] += (stop - start).total_seconds() / 3600 + + # actual hours per day + attendance_intervals = calendar._attendance_intervals( + from_datetime, + to_datetime, + self.employee_id.resource_id, + ) + leave_intervals = calendar._leave_intervals( + from_datetime, + to_datetime, + self.employee_id.resource_id, + domain=[ + ('holiday_status_id', '=', self.holiday_status_id.id), + ('time_type', '=', 'leave') + ], + ) + global_intervals = calendar._leave_intervals( + from_datetime, + to_datetime, + None, + ) + intervals = ( + attendance_intervals & (leave_intervals - global_intervals) + ) + day_hours = defaultdict(float) + for start, stop, meta in intervals: + day_hours[start.date()] += (stop - start).total_seconds() / 3600 + + # compute number of days as quarters + return sum( + float_utils.round( + ROUNDING_FACTOR * day_hours[day] / day_total[day] + ) / ROUNDING_FACTOR + for day in day_hours + ) + + @api.multi + def _get_accrual_period(self): + self.ensure_one() + + if self.interval_unit == 'weeks': + return relativedelta(weeks=self.interval_number) + elif self.interval_unit == 'months': + return relativedelta(months=self.interval_number) + elif self.interval_unit == 'years': + return relativedelta(years=self.interval_number) + + @api.multi + def _get_date_from(self): + self.ensure_one() + + if self.date_from: + return self.date_from + + service_start_date = self.employee_id.sudo().service_start_date + if service_start_date: # pragma: no cover + return datetime.combine( + service_start_date, + datetime.min.time() + ) + + return self.employee_id.sudo().create_date + + @api.multi + def _get_date_to(self): + self.ensure_one() + + if self.date_to: + return self.date_to + + service_termination_date = ( + self.employee_id.sudo().service_termination_date + ) + if service_termination_date: # pragma: no cover + return datetime.combine( + service_termination_date, + datetime.min.time() + ) + + return None + + @api.multi + def _get_days_to_accrue( + self, + period_start, + period, + period_end, + as_of_datetime, + days_worked, + workable_days, + ): + self.ensure_one() + + days_to_accrue = self.number_per_interval + if self.unit_per_interval == 'hours': + days_to_accrue /= ( + self.employee_id.resource_calendar_id.hours_per_day + ) or HOURS_PER_DAY + + if (self.accrual_method == 'period_start' + and period_start < as_of_datetime): + return HrLeaveAllocationAccruementEntry( + days_accrued=days_to_accrue, + accrued_on=period_start.date(), + reason=_('Start-of-period accruement') + ) + elif (self.accrual_method == 'period_end' + and period_start + period < as_of_datetime): + return HrLeaveAllocationAccruementEntry( + days_accrued=days_to_accrue, + accrued_on=period_end.date(), + reason=_('End-of-period accruement') + ) + elif self.accrual_method == 'prorate' and workable_days > 0: + return HrLeaveAllocationAccruementEntry( + days_accrued=days_to_accrue * (days_worked / workable_days), + accrued_on=period_end.date(), + reason=_('Prorate accruement for %s of %s days') % ( + days_worked, + workable_days, + ) + ) + + return None diff --git a/hr_holidays_accrual_advanced/models/hr_leave_allocation_accruement.py b/hr_holidays_accrual_advanced/models/hr_leave_allocation_accruement.py new file mode 100644 index 00000000000..dccecbb0d32 --- /dev/null +++ b/hr_holidays_accrual_advanced/models/hr_leave_allocation_accruement.py @@ -0,0 +1,33 @@ +# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models, fields + + +class HrLeaveAllocationAccruement(models.Model): + """Describes an alteration to the accrual leave balance in order to provide + definitive explanation how the actual balance was changing over the time. + """ + + _name = 'hr.leave.allocation.accruement' + _description = 'Leaves Allocation Accruement' + + leave_allocation_id = fields.Many2one( + string='Leave Allocation', + comodel_name='hr.leave.allocation', + ) + days_accrued = fields.Float( + string='Number of Days', + readonly=True, + required=True, + ) + accrued_on = fields.Date( + string='Accruement Date', + readonly=True, + required=True, + ) + reason = fields.Char( + string='Reason', + readonly=True, + required=True, + ) diff --git a/hr_holidays_accrual_advanced/models/resource_calendar_leaves.py b/hr_holidays_accrual_advanced/models/resource_calendar_leaves.py new file mode 100644 index 00000000000..32e6aebf0ee --- /dev/null +++ b/hr_holidays_accrual_advanced/models/resource_calendar_leaves.py @@ -0,0 +1,17 @@ +# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models, fields + + +class ResourceCalendarLeaves(models.Model): + _inherit = 'resource.calendar.leaves' + + holiday_status_id = fields.Many2one( + related='holiday_id.holiday_status_id', + store=True, + ) + unpaid = fields.Boolean( + related='holiday_id.holiday_status_id.unpaid', + store=True, + ) diff --git a/hr_holidays_accrual_advanced/readme/CONTRIBUTORS.rst b/hr_holidays_accrual_advanced/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..1c6a35a1e35 --- /dev/null +++ b/hr_holidays_accrual_advanced/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Alexey Pelykh diff --git a/hr_holidays_accrual_advanced/readme/DESCRIPTION.rst b/hr_holidays_accrual_advanced/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..10d31116831 --- /dev/null +++ b/hr_holidays_accrual_advanced/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +This module provides advanced accrual leaves allocation as extension to +out-of-the-box per-employee accrual leave allocation capabilities of Odoo, +introducing following extra features: + + * Accrual allocation history + * Accrual allocation calculator ("How many leave days I'll have in 3 months from today?") + * Various accrual methods + * Various limits to express complex corporate accrual leave policies + * Takes into account employee service period instead of ``create_date`` diff --git a/hr_holidays_accrual_advanced/readme/ROADMAP.rst b/hr_holidays_accrual_advanced/readme/ROADMAP.rst new file mode 100644 index 00000000000..e3e34d0160c --- /dev/null +++ b/hr_holidays_accrual_advanced/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +This module overrides accruement computation from base Odoo ``hr_holidays`` +module with its own, yet the provided implementation is backwards-compatible +with out-of-the-box Odoo behaviour. diff --git a/hr_holidays_accrual_advanced/readme/USAGE.rst b/hr_holidays_accrual_advanced/readme/USAGE.rst new file mode 100644 index 00000000000..c70b96f092e --- /dev/null +++ b/hr_holidays_accrual_advanced/readme/USAGE.rst @@ -0,0 +1,3 @@ +This module is an almost-replacement of accrual feature from the +``hr_holidays`` module and its features are configured in the same manner +under the Leave Types menu. diff --git a/hr_holidays_accrual_advanced/security/hr_holidays_accrual_security.xml b/hr_holidays_accrual_advanced/security/hr_holidays_accrual_security.xml new file mode 100644 index 00000000000..f90d81963bd --- /dev/null +++ b/hr_holidays_accrual_advanced/security/hr_holidays_accrual_security.xml @@ -0,0 +1,25 @@ + + + + + + Allocation Accruements: employee: read own + + [('leave_allocation_id.employee_id.user_id', '=', user.id)] + + + + + + + + Allocation Accruements: officer: no limit + + [(1, '=', 1)] + + + + diff --git a/hr_holidays_accrual_advanced/security/ir.model.access.csv b/hr_holidays_accrual_advanced/security/ir.model.access.csv new file mode 100644 index 00000000000..e1e7d2585ea --- /dev/null +++ b/hr_holidays_accrual_advanced/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_leave_allocation_accruement_manager,access_hr_leave_allocation_accruement_manager,model_hr_leave_allocation_accruement,hr_holidays.group_hr_holidays_manager,1,1,1,1 +access_hr_leave_allocation_accruement_user,access_hr_leave_allocation_accruement_user,model_hr_leave_allocation_accruement,hr_holidays.group_hr_holidays_user,1,1,1,1 +access_hr_leave_allocation_accruement_employee,access_hr_leave_allocation_accruement_employee,model_hr_leave_allocation_accruement,base.group_user,1,0,0,0 diff --git a/hr_holidays_accrual_advanced/static/description/index.html b/hr_holidays_accrual_advanced/static/description/index.html new file mode 100644 index 00000000000..27c82db9e62 --- /dev/null +++ b/hr_holidays_accrual_advanced/static/description/index.html @@ -0,0 +1,444 @@ + + + + + + +Advanced Accrual Allocation + + + +
+

Advanced Accrual Allocation

+ + +

Beta License: AGPL-3 OCA/hr Translate me on Weblate Try me on Runbot

+

This module provides advanced accrual leaves allocation as extension to +out-of-the-box per-employee accrual leave allocation capabilities of Odoo, +introducing following extra features:

+
+
    +
  • Accrual allocation history
  • +
  • Accrual allocation calculator (“How many leave days I’ll have in 3 months from today?”)
  • +
  • Various accrual methods
  • +
  • Various limits to express complex corporate accrual leave policies
  • +
  • Takes into account employee service period instead of create_date
  • +
+
+

Table of contents

+ +
+

Usage

+

This module is an almost-replacement of accrual feature from the +hr_holidays module and its features are configured in the same manner +under the Leave Types menu.

+
+
+

Known issues / Roadmap

+

This module overrides accruement computation from base Odoo hr_holidays +module with its own, yet the provided implementation is backwards-compatible +with out-of-the-box Odoo behaviour.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Brainbean Apps
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/hr project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/hr_holidays_accrual_advanced/tests/__init__.py b/hr_holidays_accrual_advanced/tests/__init__.py new file mode 100644 index 00000000000..6731a311268 --- /dev/null +++ b/hr_holidays_accrual_advanced/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_hr_holidays_accrual_advanced diff --git a/hr_holidays_accrual_advanced/tests/test_hr_holidays_accrual_advanced.py b/hr_holidays_accrual_advanced/tests/test_hr_holidays_accrual_advanced.py new file mode 100644 index 00000000000..e778ebf361b --- /dev/null +++ b/hr_holidays_accrual_advanced/tests/test_hr_holidays_accrual_advanced.py @@ -0,0 +1,746 @@ +# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from unittest import mock +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.tests import common + +module_ns = 'odoo.addons.hr_holidays_accrual_advanced' +hr_leave_allocation_class = ( + module_ns + '.models.hr_leave_allocation.HrLeaveAllocation' +) +_get_date_from = hr_leave_allocation_class + '._get_date_from' +_get_date_to = hr_leave_allocation_class + '._get_date_to' + + +class TestHrHolidaysAccrualAdvanced(common.TransactionCase): + + def setUp(self): + super().setUp() + + self.today = fields.Date.today() + self.now = datetime.combine( + fields.Date.today(), + datetime.min.time() + ) + self.Employee = self.env['hr.employee'] + self.SudoEmployee = self.Employee.sudo() + self.LeaveType = self.env['hr.leave.type'] + self.SudoLeaveType = self.LeaveType.sudo() + self.LeaveAllocation = self.env['hr.leave.allocation'] + self.SudoLeaveAllocation = self.LeaveAllocation.sudo() + self.Leave = self.env['hr.leave'] + self.SudoLeave = self.Leave.sudo() + self.Calculator = self.env[ + 'hr.leave.allocation.accrual.calculator' + ] + self.SudoCalculator = self.Calculator.sudo() + self.ResourceCalendar = self.env['resource.calendar'] + self.SudoResourceCalendar = self.ResourceCalendar.sudo() + + def test_allocation_1(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #1', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #1', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'date_from': ( + self.now - relativedelta(years=3) + ), + 'date_to': ( + self.now - relativedelta(years=1) + ), + }) + + allocation.action_recalculate_accrual_allocations() + self.assertEqual(allocation.number_of_days, 40.0) + + allocation.action_recalculate_accrual_allocations_all() + self.assertEqual(allocation.number_of_days, 40.0) + + self.SudoLeaveAllocation._update_accrual() + self.assertEqual(allocation.number_of_days, 40.0) + + def test_allocation_2(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #2', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #2', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'accrual_method': 'period_end', + }) + + date_from = ( + self.now - relativedelta(years=1) + relativedelta(days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertEqual(allocation.number_of_days, 0.0) + + def test_allocation_3(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #3', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #3', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'accrual_method': 'period_end', + }) + + date_from = ( + self.now - relativedelta(years=1, days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + + def test_allocation_4(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #4', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #4', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_carryover_days': True, + 'max_carryover_days': 5.0, + }) + + date_from = ( + self.now - relativedelta(years=1, days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 5.0, 0) + + def test_allocation_5(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #5', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #5', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_carryover_days': True, + 'max_carryover_days': 0.0, + }) + + date_from = ( + self.now - relativedelta(years=1, days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 0.0, 0) + + def test_allocation_6(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #6', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #6', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_carryover_days': True, + 'max_carryover_days': 5.0, + 'limit_accumulated_days': True, + 'max_accumulated_days': 20.0, + }) + + date_from = ( + self.now - relativedelta(years=10, days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 5.0, 0) + + def test_allocation_7(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #7', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #7', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_carryover_days': True, + 'max_carryover_days': 5.0, + 'limit_accumulated_days': True, + 'max_accumulated_days': 1.0, + }) + + date_from = ( + self.now - relativedelta(years=10, days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 1.0, 0) + + def test_allocation_8(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #8', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #8', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + }) + unpaid_leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #8 (unpaid)', + 'allocation_type': 'no', + 'unpaid': True, + }) + unpaid_from = self.now - relativedelta(years=1) + unpaid_to = self.now - relativedelta(months=6) + unpaid_leave = self.SudoLeave.create({ + 'name': 'Leave #8 (unpaid)', + 'employee_id': employee.id, + 'holiday_status_id': unpaid_leave_type.id, + 'date_from': unpaid_from, + 'date_to': unpaid_to, + }) + unpaid_leave._onchange_leave_dates() + unpaid_leave.action_approve() + + date_from = ( + self.now - relativedelta(years=1, days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 10.0, 0) + + def test_allocation_9(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #9', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #9', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + }) + + allocation._update_accrual_allocation() + + self.assertEqual(allocation.number_of_days, 0) + + def test_allocation_10(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #10', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #10', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_carryover_days': True, + 'max_carryover_days': 5.0, + 'limit_accrued_days': True, + 'max_accrued_days': 20.0, + 'limit_accumulated_days': True, + 'max_accumulated_days': 20.0, + }) + + date_from = ( + self.now - relativedelta(years=10) - relativedelta(days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 5.0, 0) + + def test_allocation_11(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #11', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #11', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + }) + + date_from = ( + self.now - relativedelta(years=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + allocation.limit_accumulated_days = True + allocation.max_accumulated_days = 5.5 + + self.assertEqual(allocation.accrual_limit, 6) + + allocation.limit_accumulated_days = False + + self.assertEqual(allocation.accrual_limit, 0) + + def test_allocation_12(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #12', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #12', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'accrual': True, + }) + + allocation.mode_company_id = employee.company_id + allocation.holiday_type = 'company' + + self.assertEqual(allocation.accrual, False) + + def test_allocation_13(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #13', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #13', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'company', + 'mode_company_id': employee.company_id.id, + 'holiday_status_id': leave_type.id, + 'accrual': True, + }) + + self.assertEqual(allocation.accrual, False) + + def test_allocation_14(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #14', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #14', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'accrual': True, + }) + + allocation.mode_company_id = employee.company_id + allocation.holiday_type = 'company' + + self.assertEqual(allocation.accrual, False) + + def test_allocation_15(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #15', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #15', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_accrued_days': True, + 'max_accrued_days': 5.0, + }) + + date_from = ( + self.now - relativedelta(years=10) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 50.0, 0) + + def test_allocation_16(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #16', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #16', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'date_from': self.now - relativedelta(years=2), + 'date_to': self.now - relativedelta(years=1), + }) + + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + + def test_allocation_17(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #17', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #17', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + }) + + calculator = self.SudoCalculator.with_context( + active_id=allocation.id + ).create({ + 'date': self.now, + }) + + calculator.date = self.today + relativedelta(years=10) + + date_from = ( + self.now - relativedelta(years=10) + ) + with mock.patch(_get_date_from, return_value=date_from): + calculator._onchange() + + self.assertAlmostEqual(calculator.balance, 400.0, 0) + + def test_allocation_18(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #18', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #17', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + }) + + date_from = ( + self.now + relativedelta(years=10) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 0.0, 0) + + def test_allocation_19(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #19', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #19', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'accrual_method': 'period_start', + }) + + date_from = ( + self.now - relativedelta(days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + + def test_allocation_20(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #20', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #20', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_accumulated_days': True, + 'max_accumulated_days': 20.0, + }) + + date_from = ( + self.now - relativedelta(years=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + self.assertEqual(len(allocation.accruement_ids), 1) + + leave_from = self.now - relativedelta(days=10) + leave_to = self.now + leave = self.SudoLeave.create({ + 'name': 'Leave #20', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'date_from': leave_from, + 'date_to': leave_to, + 'number_of_days': 10.0, + }) + + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + self.assertEqual(len(allocation.accruement_ids), 1) + + leave.action_approve() + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + self.assertEqual(len(allocation.accruement_ids), 2) + + def test_allocation_21(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #21', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #21', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_accumulated_days': True, + 'max_accumulated_days': 10.0, + }) + + date_from = ( + self.now - relativedelta(years=1) - relativedelta(days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + self.assertAlmostEqual(allocation.number_of_days, 10.0, 0) + self.assertEqual(len(allocation.accruement_ids), 4) + + def test_allocation_22(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #22', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #22', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + }) + + date_from = ( + self.now - relativedelta(years=1) - relativedelta(days=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + accruements, number_of_days = allocation._calculate_accrued_amount( + datetime.combine( + (self.now - relativedelta(months=6)).date(), + datetime.min.time() + ) + ) + self.assertAlmostEqual(number_of_days, 10.0, 0) + self.assertEqual(len(accruements), 1) + + def test_allocation_23(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #23', + 'allocation_type': 'fixed', + }) + calendar = self.SudoResourceCalendar.create({ + 'name': 'Calendar #23', + }) + calendar.write({ + 'global_leave_ids': [ + (0, False, { + 'name': 'Global Leave #23', + 'date_from': self.now - relativedelta(days=7), + 'date_to': self.now, + }), + ], + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #23', + 'resource_calendar_id': calendar.id, + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + 'limit_accumulated_days': True, + 'max_accumulated_days': 20.0, + }) + + date_from = ( + self.now - relativedelta(years=1) + ) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + self.assertEqual(len(allocation.accruement_ids), 1) + + leave = self.SudoLeave.create({ + 'name': 'Leave #23', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'date_from': self.now - relativedelta(days=14), + 'date_to': self.now, + }) + leave._onchange_leave_dates() + leave.action_approve() + self.assertEqual(leave.number_of_days, 5) + + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + self.assertAlmostEqual(allocation.number_of_days, 20.0, 0) + self.assertAlmostEqual(sum(map( + lambda x: x.days_accrued, + allocation.accruement_ids + )), 15.0, 0) + self.assertEqual(len(allocation.accruement_ids), 2) + + def test_allocation_24(self): + leave_type = self.SudoLeaveType.create({ + 'name': 'Leave Type #24', + 'allocation_type': 'fixed', + }) + employee = self.SudoEmployee.create({ + 'name': 'Employee #24', + }) + allocation = self.SudoLeaveAllocation.create({ + 'holiday_type': 'employee', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'state': 'validate', + 'accrual': True, + }) + + date_from = self.now - relativedelta(years=2) + with mock.patch(_get_date_from, return_value=date_from): + allocation._update_accrual_allocation() + self.assertEqual(allocation.number_of_days, 40.0) + + leave = self.SudoLeave.create({ + 'name': 'Leave #24', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'date_from': ( + self.now - relativedelta(years=1) - relativedelta(days=7) + ), + 'date_to': ( + self.now - relativedelta(years=1) + relativedelta(days=7) + ), + }) + leave._onchange_leave_dates() + leave.action_approve() + self.assertEqual(leave.number_of_days, 10) + + with mock.patch(_get_date_from, return_value=date_from): + accruements, number_of_days = allocation._calculate_accrued_amount( + self.now + ) + self.assertEqual(number_of_days, 40.0) + self.assertEqual(sum(map( + lambda x: x.days_accrued, + accruements + )), 30.0) diff --git a/hr_holidays_accrual_advanced/views/hr_leave_allocation.xml b/hr_holidays_accrual_advanced/views/hr_leave_allocation.xml new file mode 100644 index 00000000000..6500bce97bb --- /dev/null +++ b/hr_holidays_accrual_advanced/views/hr_leave_allocation.xml @@ -0,0 +1,129 @@ + + + + + + hr.leave.allocation.view.form.manager.inherit.accrual + hr.leave.allocation + + + + + { + 'invisible': [('holiday_type', '!=', 'employee')] + } + + + + + { + 'invisible': [('accrual', '=', True)] + } + + + + + { + 'invisible': [('accrual', '=', True)] + } + + + + + + + + + + + + + + + + hr.leave.allocation.view.form.inherit.accrual + hr.leave.allocation + + +
+
+ + + + + +
+
+ + + Recalculate Accrual Allocations + ir.actions.server + + + code + + if records: + action = records.action_recalculate_accrual_allocations() + + + + + Recalculate All Accrual Allocations + ir.actions.server + + code + + action = model.action_recalculate_accrual_allocations_all() + + + + + +
diff --git a/hr_holidays_accrual_advanced/views/hr_leave_allocation_accruement.xml b/hr_holidays_accrual_advanced/views/hr_leave_allocation_accruement.xml new file mode 100644 index 00000000000..769e0b2a78f --- /dev/null +++ b/hr_holidays_accrual_advanced/views/hr_leave_allocation_accruement.xml @@ -0,0 +1,36 @@ + + + + + + hr.leave.allocation.accruement.view.form + hr.leave.allocation.accruement + +
+ + + + + + +
+
+
+ + + hr.leave.allocation.accruement.view.tree + hr.leave.allocation.accruement + + + + + + + + + + +
diff --git a/hr_holidays_accrual_advanced/wizards/__init__.py b/hr_holidays_accrual_advanced/wizards/__init__.py new file mode 100644 index 00000000000..911bb27ba57 --- /dev/null +++ b/hr_holidays_accrual_advanced/wizards/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import hr_leave_allocation_accrual_calculator diff --git a/hr_holidays_accrual_advanced/wizards/hr_leave_allocation_accrual_calculator.py b/hr_holidays_accrual_advanced/wizards/hr_leave_allocation_accrual_calculator.py new file mode 100644 index 00000000000..3c88aac64ff --- /dev/null +++ b/hr_holidays_accrual_advanced/wizards/hr_leave_allocation_accrual_calculator.py @@ -0,0 +1,99 @@ +# Copyright 2018-2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import datetime + +from odoo import api, fields, models + + +class HrLeaveAllocationAccrualCalculatorAccruement(models.TransientModel): + _name = 'hr.leave.allocation.accrual.calculator.accruement' + _description = 'HR Leave Allocation Accrual Calculator Accruement' + + calculator_id = fields.Many2one( + string='Calculator', + comodel_name='hr.leave.allocation.accrual.calculator', + ) + days_accrued = fields.Float( + string='Number of Days', + readonly=True, + required=True, + ) + accrued_on = fields.Date( + string='Accruement Date', + readonly=True, + required=True, + ) + reason = fields.Char( + string='Reason', + readonly=True, + required=True, + ) + + +class HrLeaveAllocationAccrualBalanceCalculator(models.TransientModel): + _name = 'hr.leave.allocation.accrual.calculator' + _description = 'HR Leave Allocation Accrual Calculator' + + date = fields.Date( + string='Date', + required=True, + ) + accruement_ids = fields.One2many( + string='Accruements', + comodel_name=( + 'hr.leave.allocation.accrual.calculator.accruement' + ), + inverse_name='calculator_id', + readonly=True, + ) + accrued = fields.Float( + string='Accrued', + readonly=True, + ) + balance = fields.Float( + string='Balance', + readonly=True, + ) + + @api.onchange( + 'date', + ) + def _onchange(self): + self._recalculate() + + @api.multi + def _recalculate(self): + self.ensure_one() + + if not self.date: + self.write({ + 'balance': 0.0, + 'accruement_ids': [(5, False, False)], + }) + return + + HrLeaveAllocation = self.env['hr.leave.allocation'] + leave_allocation = HrLeaveAllocation.browse( + self.env.context.get('active_id') + ) + + accruements, accrued = leave_allocation._calculate_accrued_amount( + datetime.combine(self.date, datetime.min.time()), + ) + + balance = 0.0 + accruement_ids = [(5, False, False)] + for accruement in accruements: + balance += accruement.days_accrued + accruement_ids.append((0, 0, { + 'days_accrued': accruement.days_accrued, + 'accrued_on': accruement.accrued_on, + 'reason': accruement.reason, + })) + + self.write({ + 'accrued': accrued, + 'balance': balance, + 'accruement_ids': accruement_ids, + }) diff --git a/hr_holidays_accrual_advanced/wizards/hr_leave_allocation_accrual_calculator.xml b/hr_holidays_accrual_advanced/wizards/hr_leave_allocation_accrual_calculator.xml new file mode 100644 index 00000000000..f52c488ca07 --- /dev/null +++ b/hr_holidays_accrual_advanced/wizards/hr_leave_allocation_accrual_calculator.xml @@ -0,0 +1,93 @@ + + + + + + hr_leave_allocation_accrual_calculator + hr.leave.allocation.accrual.calculator + +
+ + + This wizard will calculate leave allocation balance as of date selected. + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + hr.leave.allocation.accrual.calculator.accruement.view.form + hr.leave.allocation.accrual.calculator.accruement + +
+ + + + + + +
+
+
+ + + hr.leave.allocation.accrual.calculator.accruement.view.tree + hr.leave.allocation.accrual.calculator.accruement + + + + + + + + + + + + Accrual Balance Calculator + hr.leave.allocation.accrual.calculator + form + form + + new + + + + Allocation Accruements: manager: no limit + + [(1, '=', 1)] + + + +