From 254d6a71e8d5e7bdf8b302d774e024646921b72d Mon Sep 17 00:00:00 2001 From: jdoutreloux Date: Mon, 16 Aug 2021 19:07:50 +0000 Subject: [PATCH] [IMP] hr_holidays: Improve Accruals, dashboards and general UX (Back2basics) Purpose ======= Time Off : New Dashboard, Accruals feature, Public holidays, New time off type configuration view. - Review of Dashboard - Accrual feature : Currently, when you create an allocation, it's possible to create an Accrual, but there is no appraisal Plan. An Appraisal plan is use to define steps of accruals for each employee. In Belgium, we use accrual Allocation for European Leaves, or compensatory hours, it's a simple use case. But in USA, it's possible to change the calculation mode each year. Also, in USA, it's possible to deal your accrual plan when you arrive on the company. It's a HR Officer task to create the right Accrual allocation for each new employee. - Public Holidays : Improvement of global time off feature located on Working hours calendar. Now, Time off application have to manage Public Holidays. - Review of Time Off type configuration COM PR: https://github.com/odoo/odoo/pull/72511 ENT PR: https://github.com/odoo/enterprise/pull/19157 UPG PR: https://github.com/odoo/upgrade/pull/2791 TaskID:2475413 closes odoo/odoo#72511 Related: odoo/enterprise#19157 Related: odoo/upgrade#2791 Signed-off-by: Yannick Tivisse (yti) Co-authored-by: William Braeckman Co-authored-by: Xavier BOL (xbo) Co-authored-by: Laurent Stukkens (LTU) Co-authored-by: Yannick Tivisse --- addons/hr_holidays/__manifest__.py | 4 + addons/hr_holidays/data/hr_holidays_data.xml | 281 ++++++- addons/hr_holidays/data/hr_holidays_demo.xml | 98 ++- addons/hr_holidays/models/__init__.py | 2 + addons/hr_holidays/models/hr_employee.py | 12 +- addons/hr_holidays/models/hr_leave.py | 303 ++++--- .../models/hr_leave_accrual_plan.py | 43 + .../models/hr_leave_accrual_plan_level.py | 291 +++++++ .../hr_holidays/models/hr_leave_allocation.py | 503 +++++++----- addons/hr_holidays/models/hr_leave_type.py | 157 ++-- addons/hr_holidays/report/__init__.py | 1 + .../report/hr_leave_employee_type_report.py | 104 +++ .../report/hr_leave_employee_type_report.xml | 42 + addons/hr_holidays/report/hr_leave_report.py | 12 +- .../report/hr_leave_report_calendar.py | 6 +- .../report/hr_leave_report_calendar.xml | 25 +- .../hr_holidays/report/hr_leave_reports.xml | 57 +- .../security/hr_holidays_security.xml | 9 +- .../hr_holidays/security/ir.model.access.csv | 5 + .../static/src/img/icons/Annual_Time_Off.svg | 21 + .../src/img/icons/Annual_Time_Off_2.svg | 16 + .../src/img/icons/Annual_Time_Off_3.svg | 20 + .../src/img/icons/Compensatory_Time_Off.svg | 15 + .../src/img/icons/Compensatory_Time_Off_2.svg | 15 + .../src/img/icons/Compensatory_Time_Off_3.svg | 11 + .../static/src/img/icons/Credit_Time.svg | 11 + .../static/src/img/icons/Credit_Time_2.svg | 13 + .../static/src/img/icons/Extra_Time_Off.svg | 14 + .../static/src/img/icons/Extra_Time_Off_2.svg | 9 + .../src/img/icons/Maternity_Time_Off.svg | 17 + .../src/img/icons/Maternity_Time_Off_2.svg | 12 + .../src/img/icons/Maternity_Time_Off_3.svg | 19 + .../static/src/img/icons/Paid_Time_Off.svg | 15 + .../static/src/img/icons/Paid_Time_Off_2.svg | 15 + .../static/src/img/icons/Paid_Time_Off_3.svg | 12 + .../src/img/icons/Parental_Time_Off.svg | 5 + .../src/img/icons/Parental_Time_Off_2.svg | 5 + .../src/img/icons/Recovery_Bank_Holiday.svg | 14 + .../src/img/icons/Recovery_Bank_Holiday_2.svg | 8 + .../static/src/img/icons/Refused.svg | 1 + .../static/src/img/icons/Sick_Time_Off.svg | 12 + .../static/src/img/icons/Sick_Time_Off_2.svg | 12 + .../src/img/icons/Small_Unemployement.svg | 10 + .../src/img/icons/Small_Unemployement_2.svg | 10 + .../src/img/icons/Small_Unemployement_3.svg | 11 + .../static/src/img/icons/To_Approve.svg | 1 + .../src/img/icons/Training_Time_Off.svg | 10 + .../src/img/icons/Training_Time_Off_2.svg | 11 + .../static/src/img/icons/Unpaid_Time_Off.svg | 10 + .../src/img/icons/Unpaid_Time_Off_2.svg | 15 + .../src/img/icons/Unpredictable_Reasons.svg | 5 + .../src/img/icons/Unpredictable_Reasons_2.svg | 5 + .../static/src/img/icons/Validated.svg | 1 + .../src/img/icons/Work_Accident_Time_Off.svg | 11 + .../img/icons/Work_Accident_Time_Off_2.svg | 10 + .../hr_holidays/static/src/js/radio_image.js | 37 + .../static/src/js/time_off_calendar.js | 50 +- .../static/src/scss/accrual_plan_level.scss | 107 +++ .../hr_holidays/static/src/scss/time_off.scss | 30 +- .../static/src/xml/time_off_calendar.xml | 59 +- .../hr_holidays/tests/test_access_rights.py | 17 +- .../tests/test_accrual_allocations.py | 751 ++++++++++++------ .../tests/test_allocation_access_rights.py | 111 +-- .../tests/test_automatic_leave_dates.py | 3 +- .../tests/test_change_department.py | 3 +- .../hr_holidays/tests/test_company_leave.py | 4 +- .../hr_holidays/tests/test_holidays_flow.py | 43 +- .../hr_holidays/tests/test_hr_leave_type.py | 5 +- .../hr_holidays/tests/test_leave_requests.py | 55 +- .../hr_holidays/tests/test_out_of_office.py | 6 +- .../hr_holidays/views/hr_holidays_views.xml | 35 +- .../views/hr_leave_accrual_views.xml | 185 +++++ .../views/hr_leave_allocation_views.xml | 154 ++-- .../hr_holidays/views/hr_leave_type_views.xml | 75 +- addons/hr_holidays/views/hr_leave_views.xml | 176 ++-- addons/hr_holidays/views/hr_views.xml | 18 + addons/hr_holidays/views/resource_views.xml | 69 ++ .../data/hr_holidays_attendance_data.xml | 3 +- .../hr_holidays_attendance/models/hr_leave.py | 2 +- .../models/hr_leave_allocation.py | 4 +- .../models/hr_leave_type.py | 2 +- .../models/res_users.py | 5 +- .../static/src/xml/time_off_calendar.xml | 22 +- .../tests/test_holidays_overtime.py | 21 +- .../views/hr_leave_allocation_views.xml | 5 +- .../views/hr_leave_type_views.xml | 2 +- .../views/resource_calendar_views.xml | 22 +- addons/hr_work_entry_contract/__manifest__.py | 2 - .../security/ir.model.access.csv | 3 +- .../views/resource_views.xml | 17 - .../hr_work_entry_contract/wizard/__init__.py | 1 - .../wizard/create_company_global_time_off.py | 27 - .../create_company_global_time_off_views.xml | 34 - addons/hr_work_entry_holidays/tests/common.py | 3 +- .../tests/test_multi_contract.py | 3 +- .../test_payslip_holidays_computation.py | 3 +- .../tests/test_performance.py | 9 +- .../tests/test_work_entry.py | 4 +- .../views/hr_leave_views.xml | 20 +- .../tests/test_timesheet_holidays.py | 19 +- .../views/hr_holidays_views.xml | 2 +- addons/resource/models/resource.py | 30 +- .../resource/security/resource_security.xml | 6 + addons/resource/tests/test_resource.py | 14 + addons/resource/views/resource_views.xml | 13 +- .../tests/test_performance.py | 3 +- .../static/src/legacy/js/libs/fullcalendar.js | 1 - .../js/views/calendar/calendar_controller.js | 15 +- .../js/views/calendar/calendar_renderer.js | 3 + .../static/src/legacy/scss/web_calendar.scss | 41 +- .../static/src/legacy/xml/web_calendar.xml | 2 +- 111 files changed, 3401 insertions(+), 1312 deletions(-) create mode 100644 addons/hr_holidays/models/hr_leave_accrual_plan.py create mode 100644 addons/hr_holidays/models/hr_leave_accrual_plan_level.py create mode 100644 addons/hr_holidays/report/hr_leave_employee_type_report.py create mode 100644 addons/hr_holidays/report/hr_leave_employee_type_report.xml create mode 100644 addons/hr_holidays/static/src/img/icons/Annual_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Annual_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Annual_Time_Off_3.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_3.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Credit_Time.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Credit_Time_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Extra_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Extra_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Maternity_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_3.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Paid_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Paid_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Paid_Time_Off_3.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Parental_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Parental_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Refused.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Sick_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Sick_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Small_Unemployement.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Small_Unemployement_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Small_Unemployement_3.svg create mode 100644 addons/hr_holidays/static/src/img/icons/To_Approve.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Training_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Training_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons_2.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Validated.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off.svg create mode 100644 addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off_2.svg create mode 100644 addons/hr_holidays/static/src/js/radio_image.js create mode 100644 addons/hr_holidays/static/src/scss/accrual_plan_level.scss create mode 100644 addons/hr_holidays/views/hr_leave_accrual_views.xml delete mode 100644 addons/hr_work_entry_contract/views/resource_views.xml delete mode 100644 addons/hr_work_entry_contract/wizard/create_company_global_time_off.py delete mode 100644 addons/hr_work_entry_contract/wizard/create_company_global_time_off_views.xml diff --git a/addons/hr_holidays/__manifest__.py b/addons/hr_holidays/__manifest__.py index db8472955f9..b2911b88e2b 100644 --- a/addons/hr_holidays/__manifest__.py +++ b/addons/hr_holidays/__manifest__.py @@ -38,6 +38,7 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p 'views/hr_leave_views.xml', 'views/hr_leave_type_views.xml', 'views/hr_leave_allocation_views.xml', + 'views/hr_leave_accrual_views.xml', 'views/mail_activity_views.xml', 'wizard/hr_holidays_summary_employees_views.xml', @@ -47,6 +48,7 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p 'report/hr_holidays_reports.xml', 'report/hr_leave_reports.xml', 'report/hr_leave_report_calendar.xml', + 'report/hr_leave_employee_type_report.xml', 'views/hr_views.xml', 'views/hr_holidays_views.xml', @@ -64,10 +66,12 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p ], 'web.assets_backend': [ 'hr_holidays/static/src/js/time_off_calendar.js', + 'hr_holidays/static/src/js/radio_image.js', 'hr_holidays/static/src/js/leave_stats_widget.js', 'hr_holidays/static/src/models/*/*.js', 'hr_holidays/static/src/scss/time_off.scss', 'hr_holidays/static/src/components/*/*.scss', + 'hr_holidays/static/src/scss/accrual_plan_level.scss', ], 'web.tests_assets': [ 'hr_holidays/static/tests/helpers/**/*', diff --git a/addons/hr_holidays/data/hr_holidays_data.xml b/addons/hr_holidays/data/hr_holidays_data.xml index f97277117d9..db3c4a29f86 100644 --- a/addons/hr_holidays/data/hr_holidays_data.xml +++ b/addons/hr_holidays/data/hr_holidays_data.xml @@ -1,55 +1,308 @@ + + + Annual_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Annual_Time_Off.svg + + + Annual_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Annual_Time_Off_2.svg + + + Annual_Time_Off_3.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Annual_Time_Off_3.svg + + + Compensatory_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Compensatory_Time_Off.svg + + + Compensatory_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Compensatory_Time_Off_2.svg + + + Compensatory_Time_Off_3.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Compensatory_Time_Off_3.svg + + + Credit_Time.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Credit_Time.svg + + + Credit_Time_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Credit_Time_2.svg + + + Extra_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Extra_Time_Off.svg + + + Extra_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Extra_Time_Off_2.svg + + + Maternity_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Maternity_Time_Off.svg + + + Maternity_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Maternity_Time_Off_2.svg + + + Maternity_Time_Off_3.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Maternity_Time_Off_3.svg + + + Paid_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Paid_Time_Off.svg + + + Paid_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Paid_Time_Off_2.svg + + + Paid_Time_Off_3.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Paid_Time_Off_3.svg + + + Parental_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Parental_Time_Off.svg + + + Parental_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Parental_Time_Off_2.svg + + + Recovery_Bank_Holiday.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Recovery_Bank_Holiday.svg + + + Recovery_Bank_Holiday_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Recovery_Bank_Holiday_2.svg + + + Sick_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Sick_Time_Off.svg + + + Sick_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Sick_Time_Off_2.svg + + + Small_Unemployement.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Small_Unemployement.svg + + + Small_Unemployement_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Small_Unemployement_2.svg + + + Small_Unemployement_3.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Small_Unemployement_3.svg + + + Training_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Training_Time_Off.svg + + + Training_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Training_Time_Off_2.svg + + + Unpaid_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Unpaid_Time_Off.svg + + + Unpaid_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Unpaid_Time_Off_2.svg + + + Work_Accident_Time_Off.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Work_Accident_Time_Off.svg + + + Work_Accident_Time_Off_2.svg + hr.leave.type + icon_id + + url + /hr_holidays/static/src/img/icons/Work_Accident_Time_Off_2.svg + + Paid Time Off - fixed + yes + no both - both - black + officer - + + 2 Sick Time Off - no + no red - True + + 3 Compensatory Days - fixed_allocation + yes + yes manager - manager + officer hour - lavender - + + 4 Unpaid - no + no both - both - brown + officer hour - + + 5 diff --git a/addons/hr_holidays/data/hr_holidays_demo.xml b/addons/hr_holidays/data/hr_holidays_demo.xml index e9f10534abe..4318519232b 100644 --- a/addons/hr_holidays/data/hr_holidays_demo.xml +++ b/addons/hr_holidays/data/hr_holidays_demo.xml @@ -9,11 +9,11 @@ Parental Leaves - fixed + yes + no brown both - both - + officer @@ -25,6 +25,8 @@ 20 validate + + @@ -33,8 +35,10 @@ 7 confirm + + - + @@ -42,7 +46,10 @@ Summer Vacation 7 + confirm + + @@ -51,30 +58,32 @@ 12 validate + + Trip with Family - - - - + + + + Doctor Appointment - - - - + + + + confirm - + @@ -103,6 +112,8 @@ 20 validate + + @@ -111,6 +122,8 @@ 10 validate + + @@ -119,6 +132,8 @@ 12 validate + + @@ -131,21 +146,21 @@ - + Dentist appointment - - - - + + + + confirm - + @@ -157,13 +172,18 @@ 20 validate + + Summer Vacation 7 + confirm + + @@ -176,17 +196,17 @@ - + Trip - - - - + + + + @@ -198,6 +218,8 @@ 20 validate + + @@ -205,8 +227,11 @@ 7 + confirm + + - + @@ -221,7 +246,7 @@ confirm - + @@ -235,7 +260,7 @@ confirm - + @@ -247,6 +272,8 @@ 20 validate + + @@ -254,6 +281,9 @@ 7 + confirm + + @@ -264,6 +294,8 @@ 20 validate + + @@ -271,8 +303,11 @@ 5 + confirm + + - + @@ -298,7 +333,7 @@ confirm - + @@ -310,13 +345,18 @@ 20 confirm + + Summer Vacation 5 + confirm + + diff --git a/addons/hr_holidays/models/__init__.py b/addons/hr_holidays/models/__init__.py index 368a7bc4bf1..1ab720a3edb 100644 --- a/addons/hr_holidays/models/__init__.py +++ b/addons/hr_holidays/models/__init__.py @@ -7,6 +7,8 @@ from . import hr_department from . import hr_leave from . import hr_leave_allocation from . import hr_leave_type +from . import hr_leave_accrual_plan_level +from . import hr_leave_accrual_plan from . import mail_message_subtype from . import res_partner from . import res_users diff --git a/addons/hr_holidays/models/hr_employee.py b/addons/hr_holidays/models/hr_employee.py index 228d1c8104b..36adb4a7aee 100644 --- a/addons/hr_holidays/models/hr_employee.py +++ b/addons/hr_holidays/models/hr_employee.py @@ -32,6 +32,7 @@ class HrEmployeeBase(models.AbstractModel): leave_date_to = fields.Date('To Date', compute='_compute_leave_status') leaves_count = fields.Float('Number of Time Off', compute='_compute_remaining_leaves') allocation_count = fields.Float('Total number of days allocated.', compute='_compute_allocation_count') + allocations_count = fields.Integer('Total number of allocations', compute="_compute_allocation_count") allocation_used_count = fields.Float('Total number of days off used', compute='_compute_total_allocation_used') show_leaves = fields.Boolean('Able to see Remaining Time Off', compute='_compute_show_leaves') is_absent = fields.Boolean('Absent Today', compute='_compute_leave_status', search='_search_absent_employee') @@ -40,9 +41,6 @@ class HrEmployeeBase(models.AbstractModel): hr_icon_display = fields.Selection(selection_add=[('presence_holiday_absent', 'On leave'), ('presence_holiday_present', 'Present but on leave')]) - def _get_date_start_work(self): - return self.create_date - def _get_remaining_leaves(self): """ Helper to compute the remaining leaves for the current employees :returns dict where the key is the employee id, and the value is the remain leaves @@ -64,7 +62,7 @@ class HrEmployeeBase(models.AbstractModel): join hr_leave_type s ON (s.id=h.holiday_status_id) WHERE s.active = true AND h.state='validate' AND - (s.allocation_type='fixed' OR s.allocation_type='fixed_allocation') AND + s.requires_allocation='yes' AND h.employee_id in %s GROUP BY h.employee_id""", (tuple(self.ids),)) return dict((row['employee_id'], row['days']) for row in self._cr.dictfetchall()) @@ -84,10 +82,12 @@ class HrEmployeeBase(models.AbstractModel): ('holiday_status_id.active', '=', True), ('state', '=', 'validate'), ], ['number_of_days:sum', 'employee_id'], ['employee_id']) - rg_results = dict((d['employee_id'][0], d['number_of_days']) for d in data) + rg_results = dict((d['employee_id'][0], {"employee_id_count": d['employee_id_count'], "number_of_days": d['number_of_days']}) for d in data) for employee in self: - employee.allocation_count = float_round(rg_results.get(employee.id, 0.0), precision_digits=2) + result = rg_results.get(employee.id) + employee.allocation_count = float_round(result['number_of_days'], precision_digits=2) if result else 0.0 employee.allocation_display = "%g" % employee.allocation_count + employee.allocations_count = result['employee_id_count'] if result else 0.0 def _compute_total_allocation_used(self): for employee in self: diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index a52417077d7..11f417456a6 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -5,12 +5,12 @@ import logging -from collections import namedtuple +from collections import namedtuple, defaultdict from datetime import datetime, timedelta, time from pytz import timezone, UTC -from odoo import api, fields, models, tools +from odoo import api, fields, models, tools, SUPERUSER_ID from odoo.addons.base.models.res_partner import _tz_get from odoo.addons.resource.models.resource import float_to_time, HOURS_PER_DAY from odoo.exceptions import AccessError, UserError, ValidationError @@ -72,7 +72,7 @@ class HolidaysRequest(models.Model): defaults = self._default_get_request_parameters(defaults) if 'holiday_status_id' in fields_list and not defaults.get('holiday_status_id'): - lt = self.env['hr.leave.type'].search([('valid', '=', True), ('allocation_type', 'in', ['no', 'fixed_allocation'])], limit=1) + lt = self.env['hr.leave.type'].search(['|', ('requires_allocation', '=', 'no'), ('has_valid_allocation', '=', True)], limit=1) if lt: defaults['holiday_status_id'] = lt.id @@ -112,7 +112,6 @@ class HolidaysRequest(models.Model): private_name = fields.Char('Time Off Description', groups='hr_holidays.group_hr_holidays_user') state = fields.Selection([ ('draft', 'To Submit'), - ('cancel', 'Cancelled'), # YTI This state seems to be unused. To remove ('confirm', 'To Approve'), ('refuse', 'Refused'), ('validate1', 'Second Approval'), @@ -122,7 +121,6 @@ class HolidaysRequest(models.Model): "\nThe status is 'To Approve', when time off request is confirmed by user." + "\nThe status is 'Refused', when time off request is refused by manager." + "\nThe status is 'Approved', when time off request is approved by manager.") - payslip_status = fields.Boolean('Reported in last payslips', help='Green this button when the time off has been taken into account in the payslip.', copy=False) report_note = fields.Text('HR Comments', copy=False, groups="hr_holidays.group_hr_holidays_manager") user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, compute_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True) manager_id = fields.Many2one('hr.employee', compute='_compute_from_employee_id', store=True, readonly=False) @@ -130,14 +128,18 @@ class HolidaysRequest(models.Model): holiday_status_id = fields.Many2one( "hr.leave.type", compute='_compute_from_employee_id', store=True, string="Time Off Type", required=True, readonly=False, states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}, - domain=[('valid', '=', True)]) + domain=['|', ('requires_allocation', '=', 'no'), ('has_valid_allocation', '=', True)]) + holiday_allocation_id = fields.Many2one( + 'hr.leave.allocation', compute='_compute_from_holiday_status_id', string="Allocation", store=True, readonly=False) + color = fields.Integer("Color", related='holiday_status_id.color') validation_type = fields.Selection(string='Validation Type', related='holiday_status_id.leave_validation_type', readonly=False) # HR data employee_id = fields.Many2one( - 'hr.employee', compute='_compute_from_holiday_type', store=True, string='Employee', index=True, readonly=False, ondelete="restrict", + 'hr.employee', compute='_compute_from_employee_ids', store=True, string='Employee', index=True, readonly=False, ondelete="restrict", states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}, tracking=True) + employee_company_id = fields.Many2one(related='employee_id.company_id', readonly=True, store=True) active_employee = fields.Boolean(related='employee_id.active', readonly=True) tz_mismatch = fields.Boolean(compute='_compute_tz_mismatch') tz = fields.Selection(_tz_get, compute='_compute_tz') @@ -176,6 +178,12 @@ class HolidaysRequest(models.Model): string='Allocation Mode', readonly=True, required=True, default='employee', states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, help='By Employee: Allocation/Request for individual Employee, By Employee Tag: Allocation/Request for group of employees in category') + employee_ids = fields.Many2many( + 'hr.employee', compute='_compute_from_holiday_type', store=True, string='Employees', readonly=False, + states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) + multi_employee = fields.Boolean( + compute='_compute_from_employee_ids', store=True, + help='Holds whether this allocation concerns more than 1 employee') category_id = fields.Many2one( 'hr.employee.category', compute='_compute_from_holiday_type', store=True, string='Employee Tag', states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, help='Category of Employee') @@ -196,6 +204,7 @@ class HolidaysRequest(models.Model): supported_attachment_ids = fields.Many2many( 'ir.attachment', string="Attach File", compute='_compute_supported_attachment_ids', inverse='_inverse_supported_attachment_ids') + supported_attachment_ids_count = fields.Integer(compute='_compute_supported_attachment_ids') # UX fields leave_type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True) leave_type_support_document = fields.Boolean(related="holiday_status_id.support_document") @@ -263,10 +272,11 @@ class HolidaysRequest(models.Model): request_unit_custom = fields.Boolean('Days-long custom hours', compute='_compute_request_unit_custom', store=True, readonly=False) # view is_hatched = fields.Boolean('Hatched', compute='_compute_is_hatched') + is_striked = fields.Boolean('Striked', compute='_compute_is_hatched') _sql_constraints = [ ('type_value', - "CHECK((holiday_type='employee' AND employee_id IS NOT NULL) or " + "CHECK((holiday_type='employee' AND (employee_id IS NOT NULL OR multi_employee IS TRUE)) or " "(holiday_type='company' AND mode_company_id IS NOT NULL) or " "(holiday_type='category' AND category_id IS NOT NULL) or " "(holiday_type='department' AND department_id IS NOT NULL) )", @@ -313,8 +323,46 @@ class HolidaysRequest(models.Model): @api.depends('holiday_status_id') def _compute_state(self): - for holiday in self: - holiday.state = 'confirm' if holiday.validation_type != 'no_validation' else 'draft' + for leave in self: + leave.state = 'confirm' if leave.validation_type != 'no_validation' else 'draft' + + @api.depends('holiday_status_id.requires_allocation', 'validation_type', 'employee_id', 'date_from', 'date_to') + def _compute_from_holiday_status_id(self): + invalid_self = self.filtered(lambda leave: not leave.date_to or not leave.date_from) + if invalid_self: + invalid_self.update({'holiday_allocation_id': False}) + self = self - invalid_self + if not self: + return + allocations = self.env['hr.leave.allocation'].search_read( + [ + ('holiday_status_id', 'in', self.holiday_status_id.ids), + ('employee_id', 'in', self.employee_id.ids), + '|', + ('date_to', '>=', min(self.mapped('date_to'))), + '&', + ('date_to', '=', False), + ('date_from', '<=', max(self.mapped('date_from'))), + ], ['id', 'date_from', 'date_to', 'holiday_status_id', 'employee_id'], order="date_to, id" + ) + allocations_dict = defaultdict(lambda: []) + for allocation in allocations: + allocations_dict[(allocation['holiday_status_id'][0], allocation['employee_id'][0])].append(allocation) + + for leave in self: + if leave.holiday_status_id.requires_allocation == 'yes' and leave.date_from and leave.date_to: + found_allocation = False + date_to = leave.date_to.replace(tzinfo=UTC).astimezone(timezone(leave.tz)).date() + date_from = leave.date_from.replace(tzinfo=UTC).astimezone(timezone(leave.tz)).date() + for allocation in allocations_dict[(leave.holiday_status_id.id, leave.employee_id.id)]: + date_to_check = allocation['date_to'] > date_to if allocation['date_to'] else False + date_from_check = allocation['date_to'] is False and allocation['date_from'] <= date_from + if (date_to_check or date_from_check): + found_allocation = allocation['id'] + break + leave.holiday_allocation_id = self.env['hr.leave.allocation'].browse(found_allocation) if found_allocation else False + else: + leave.holiday_allocation_id = False @api.depends('request_date_from_period', 'request_hour_from', 'request_hour_to', 'request_date_from', 'request_date_to', 'request_unit_half', 'request_unit_hours', 'request_unit_custom', 'employee_id') @@ -409,28 +457,37 @@ class HolidaysRequest(models.Model): if holiday.holiday_status_id or holiday.request_unit_half or holiday.request_unit_hours: holiday.request_unit_custom = False + @api.depends('employee_ids') + def _compute_from_employee_ids(self): + for holiday in self: + if len(holiday.employee_ids) == 1: + holiday.employee_id = holiday.employee_ids[0]._origin + else: + holiday.employee_id = False + holiday.multi_employee = (len(holiday.employee_ids) > 1) + @api.depends('holiday_type') def _compute_from_holiday_type(self): for holiday in self: if holiday.holiday_type == 'employee': - if not holiday.employee_id: - holiday.employee_id = self.env.user.employee_id + if not holiday.employee_ids: + holiday.employee_ids = self.env.user.employee_id holiday.mode_company_id = False holiday.category_id = False elif holiday.holiday_type == 'company': - holiday.employee_id = False + holiday.employee_ids = False if not holiday.mode_company_id: holiday.mode_company_id = self.env.company.id holiday.category_id = False elif holiday.holiday_type == 'department': - holiday.employee_id = False + holiday.employee_ids = False holiday.mode_company_id = False holiday.category_id = False elif holiday.holiday_type == 'category': - holiday.employee_id = False + holiday.employee_ids = False holiday.mode_company_id = False else: - holiday.employee_id = self.env.context.get('default_employee_id') or self.env.user.employee_id + holiday.employee_ids = self.env.context.get('default_employee_id') or self.env.user.employee_id @api.depends('employee_id') def _compute_from_employee_id(self): @@ -559,12 +616,14 @@ class HolidaysRequest(models.Model): @api.depends('state') def _compute_is_hatched(self): for holiday in self: + holiday.is_striked = holiday.state == 'refuse' holiday.is_hatched = holiday.state not in ['refuse', 'validate'] @api.depends('leave_type_support_document', 'attachment_ids') def _compute_supported_attachment_ids(self): for holiday in self: holiday.supported_attachment_ids = holiday.attachment_ids + holiday.supported_attachment_ids_count = len(holiday.attachment_ids.ids) def _inverse_supported_attachment_ids(self): for holiday in self: @@ -590,7 +649,7 @@ class HolidaysRequest(models.Model): def _check_holidays(self): mapped_days = self.mapped('holiday_status_id').get_employees_days(self.mapped('employee_id').ids) for holiday in self: - if holiday.holiday_type != 'employee' or not holiday.employee_id or holiday.holiday_status_id.allocation_type == 'no': + if holiday.holiday_type != 'employee' or not holiday.employee_id or holiday.holiday_status_id.requires_allocation == 'no': continue leave_days = mapped_days[holiday.employee_id.id][holiday.holiday_status_id.id] if float_compare(leave_days['remaining_leaves'], 0, precision_digits=2) == -1 or float_compare(leave_days['virtual_remaining_leaves'], 0, precision_digits=2) == -1: @@ -664,8 +723,10 @@ class HolidaysRequest(models.Model): target = leave.department_id.name elif leave.holiday_type == 'category': target = leave.category_id.name - else: + elif leave.employee_id: target = leave.employee_id.name + else: + target = ', '.join(leave.employee_ids.mapped('name')) if leave.leave_type_request_unit == 'hour': if self.env.context.get('hide_employee_name') and 'employee_id' in self.env.context.get('group_by', []): res.append(( @@ -717,34 +778,43 @@ class HolidaysRequest(models.Model): if employee.user_id: self.message_subscribe(partner_ids=employee.user_id.partner_id.ids) - @api.constrains('holiday_status_id', 'date_to', 'date_from') + @api.constrains('holiday_allocation_id') + def _check_allocation_id(self): + for leave in self: + if leave.holiday_status_id.requires_allocation == 'yes' and not leave.holiday_allocation_id: + raise ValidationError(_( + 'Could not find an allocation of type %(leave_type)s for the requested time period.', + leave_type=leave.holiday_status_id.display_name, + )) + + @api.constrains('holiday_allocation_id', 'date_to', 'date_from') def _check_leave_type_validity(self): for leave in self: - vstart = leave.holiday_status_id.validity_start - vstop = leave.holiday_status_id.validity_stop + vstart = leave.holiday_allocation_id.date_from + vstop = leave.holiday_allocation_id.date_to dfrom = leave.date_from dto = leave.date_to - if leave.holiday_status_id.validity_start and leave.holiday_status_id.validity_stop: + if vstart and vstop: if dfrom and dto and (dfrom.date() < vstart or dto.date() > vstop): raise ValidationError(_( '%(leave_type)s are only valid between %(start)s and %(end)s', leave_type=leave.holiday_status_id.display_name, - start=leave.holiday_status_id.validity_start, - end=leave.holiday_status_id.validity_stop + start=vstart, + end=vstop )) - elif leave.holiday_status_id.validity_start: + elif vstart: if dfrom and (dfrom.date() < vstart): raise ValidationError(_( '%(leave_type)s are only valid starting from %(date)s', leave_type=leave.holiday_status_id.display_name, - date=leave.holiday_status_id.validity_start + date=vstart )) - elif leave.holiday_status_id.validity_stop: + elif vstop: if dto and (dto.date() > vstop): raise ValidationError(_( '%(leave_type)s are only valid until %(date)s', leave_type=leave.holiday_status_id.display_name, - date=leave.holiday_status_id.validity_stop + date=vstop )) def _check_double_validation_rules(self, employees, state): @@ -788,6 +858,8 @@ class HolidaysRequest(models.Model): holidays = super(HolidaysRequest, self.with_context(mail_create_nosubscribe=True)).create(vals_list) + holidays.filtered(lambda holiday: not holiday.holiday_allocation_id).with_user(SUPERUSER_ID)._compute_from_holiday_status_id() + for holiday in holidays: if not self._context.get('leave_fast_create'): # Everything that is done here must be done using sudo because we might @@ -1019,94 +1091,107 @@ class HolidaysRequest(models.Model): raise UserError(_('Time off request must be confirmed in order to approve it.')) self.write({'state': 'validate'}) - self.filtered(lambda holiday: holiday.validation_type == 'both').write({'second_approver_id': current_employee.id}) - self.filtered(lambda holiday: holiday.validation_type != 'both').write({'first_approver_id': current_employee.id}) - for holiday in self.filtered(lambda holiday: holiday.holiday_type != 'employee'): - if holiday.holiday_type == 'category': - employees = holiday.category_id.employee_ids - elif holiday.holiday_type == 'company': - employees = self.env['hr.employee'].search([('company_id', '=', holiday.mode_company_id.id)]) + leaves_second_approver = self.env['hr.leave'] + leaves_first_approver = self.env['hr.leave'] + + for leave in self: + if leave.validation_type == 'both': + leaves_second_approver += leave else: - employees = holiday.department_id.member_ids + leaves_first_approver += leave - conflicting_leaves = self.env['hr.leave'].with_context( - tracking_disable=True, - mail_activity_automation_skip=True, - leave_fast_create=True - ).search([ - ('date_from', '<=', holiday.date_to), - ('date_to', '>', holiday.date_from), - ('state', 'not in', ['cancel', 'refuse']), - ('holiday_type', '=', 'employee'), - ('employee_id', 'in', employees.ids)]) + if leave.holiday_type != 'employee' or\ + (leave.holiday_type == 'employee' and len(leave.employee_ids) > 1): + if leave.holiday_type == 'employee': + employees = leave.employee_ids + elif leave.holiday_type == 'category': + employees = leave.category_id.employee_ids + elif leave.holiday_type == 'company': + employees = self.env['hr.employee'].search([('company_id', '=', leave.mode_company_id.id)]) + else: + employees = leave.department_id.member_ids - if conflicting_leaves: - # YTI: More complex use cases could be managed in master - if holiday.leave_type_request_unit != 'day' or any(l.leave_type_request_unit == 'hour' for l in conflicting_leaves): - raise ValidationError(_('You can not have 2 time off that overlaps on the same day.')) + conflicting_leaves = self.env['hr.leave'].with_context( + tracking_disable=True, + mail_activity_automation_skip=True, + leave_fast_create=True + ).search([ + ('date_from', '<=', leave.date_to), + ('date_to', '>', leave.date_from), + ('state', 'not in', ['cancel', 'refuse']), + ('holiday_type', '=', 'employee'), + ('employee_id', 'in', employees.ids)]) - # keep track of conflicting leaves states before refusal - target_states = {l.id: l.state for l in conflicting_leaves} - conflicting_leaves.action_refuse() - split_leaves_vals = [] - for conflicting_leave in conflicting_leaves: - if conflicting_leave.leave_type_request_unit == 'half_day' and conflicting_leave.request_unit_half: - continue + if conflicting_leaves: + # YTI: More complex use cases could be managed in master + if leave.leave_type_request_unit != 'day' or any(l.leave_type_request_unit == 'hour' for l in conflicting_leaves): + raise ValidationError(_('You can not have 2 time off that overlaps on the same day.')) - # Leaves in days - if conflicting_leave.date_from < holiday.date_from: - before_leave_vals = conflicting_leave.copy_data({ - 'date_from': conflicting_leave.date_from.date(), - 'date_to': holiday.date_from.date() + timedelta(days=-1), - 'state': target_states[conflicting_leave.id], - })[0] - before_leave = self.env['hr.leave'].new(before_leave_vals) - before_leave._compute_date_from_to() + # keep track of conflicting leaves states before refusal + target_states = {l.id: l.state for l in conflicting_leaves} + conflicting_leaves.action_refuse() + split_leaves_vals = [] + for conflicting_leave in conflicting_leaves: + if conflicting_leave.leave_type_request_unit == 'half_day' and conflicting_leave.request_unit_half: + continue - # Could happen for part-time contract, that time off is not necessary - # anymore. - # Imagine you work on monday-wednesday-friday only. - # You take a time off on friday. - # We create a company time off on friday. - # By looking at the last attendance before the company time off - # start date to compute the date_to, you would have a date_from > date_to. - # Just don't create the leave at that time. That's the reason why we use - # new instead of create. As the leave is not actually created yet, the sql - # constraint didn't check date_from < date_to yet. - if before_leave.date_from < before_leave.date_to: - split_leaves_vals.append(before_leave._convert_to_write(before_leave._cache)) - if conflicting_leave.date_to > holiday.date_to: - after_leave_vals = conflicting_leave.copy_data({ - 'date_from': holiday.date_to.date() + timedelta(days=1), - 'date_to': conflicting_leave.date_to.date(), - 'state': target_states[conflicting_leave.id], - })[0] - after_leave = self.env['hr.leave'].new(after_leave_vals) - after_leave._compute_date_from_to() - # Could happen for part-time contract, that time off is not necessary - # anymore. - if after_leave.date_from < after_leave.date_to: - split_leaves_vals.append(after_leave._convert_to_write(after_leave._cache)) + # Leaves in days + if conflicting_leave.date_from < leave.date_from: + before_leave_vals = conflicting_leave.copy_data({ + 'date_from': conflicting_leave.date_from.date(), + 'date_to': leave.date_from.date() + timedelta(days=-1), + 'state': target_states[conflicting_leave.id], + })[0] + before_leave = self.env['hr.leave'].new(before_leave_vals) + before_leave._compute_date_from_to() - split_leaves = self.env['hr.leave'].with_context( + # Could happen for part-time contract, that time off is not necessary + # anymore. + # Imagine you work on monday-wednesday-friday only. + # You take a time off on friday. + # We create a company time off on friday. + # By looking at the last attendance before the company time off + # start date to compute the date_to, you would have a date_from > date_to. + # Just don't create the leave at that time. That's the reason why we use + # new instead of create. As the leave is not actually created yet, the sql + # constraint didn't check date_from < date_to yet. + if before_leave.date_from < before_leave.date_to: + split_leaves_vals.append(before_leave._convert_to_write(before_leave._cache)) + if conflicting_leave.date_to > leave.date_to: + after_leave_vals = conflicting_leave.copy_data({ + 'date_from': leave.date_to.date() + timedelta(days=1), + 'date_to': conflicting_leave.date_to.date(), + 'state': target_states[conflicting_leave.id], + })[0] + after_leave = self.env['hr.leave'].new(after_leave_vals) + after_leave._compute_date_from_to() + # Could happen for part-time contract, that time off is not necessary + # anymore. + if after_leave.date_from < after_leave.date_to: + split_leaves_vals.append(after_leave._convert_to_write(after_leave._cache)) + + split_leaves = self.env['hr.leave'].with_context( + tracking_disable=True, + mail_activity_automation_skip=True, + leave_fast_create=True, + leave_skip_state_check=True + ).create(split_leaves_vals) + + split_leaves.filtered(lambda l: l.state in 'validate')._validate_leave_request() + + values = leave._prepare_employees_holiday_values(employees) + leaves = self.env['hr.leave'].with_context( tracking_disable=True, mail_activity_automation_skip=True, leave_fast_create=True, - leave_skip_state_check=True - ).create(split_leaves_vals) + leave_skip_state_check=True, + ).create(values) - split_leaves.filtered(lambda l: l.state in 'validate')._validate_leave_request() + leaves._validate_leave_request() - values = holiday._prepare_employees_holiday_values(employees) - leaves = self.env['hr.leave'].with_context( - tracking_disable=True, - mail_activity_automation_skip=True, - leave_fast_create=True, - leave_skip_state_check=True, - ).create(values) - - leaves._validate_leave_request() + leaves_second_approver.write({'second_approver_id': current_employee.id}) + leaves_first_approver.write({'first_approver_id': current_employee.id}) employee_requests = self.filtered(lambda hol: hol.holiday_type == 'employee') employee_requests._validate_leave_request() @@ -1140,6 +1225,18 @@ class HolidaysRequest(models.Model): self.activity_update() return True + def action_documents(self): + domain = [('id', 'in', self.attachment_ids.ids)] + return { + 'name': _("Supporting Documents"), + 'type': 'ir.actions.act_window', + 'res_model': 'ir.attachment', + 'context': {'create': False}, + 'view_mode': 'list', + 'domain': domain + } + + def _check_approval_update(self, state): """ Check if target state is achievable. """ if self.env.is_superuser(): diff --git a/addons/hr_holidays/models/hr_leave_accrual_plan.py b/addons/hr_holidays/models/hr_leave_accrual_plan.py new file mode 100644 index 00000000000..cb5b30f49ed --- /dev/null +++ b/addons/hr_holidays/models/hr_leave_accrual_plan.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class AccrualPlan(models.Model): + _name = "hr.leave.accrual.plan" + _description = "Accrual Plan" + + name = fields.Char('Name', required=True) + time_off_type_id = fields.Many2one('hr.leave.type', string="Time Off Type") + employees_count = fields.Integer("Employees", compute='_compute_employee_count') + level_ids = fields.One2many('hr.leave.accrual.level', 'accrual_plan_id') + allocation_ids = fields.One2many('hr.leave.allocation', 'accrual_plan_id') + transition_mode = fields.Selection([ + ('immediately', 'Immediately'), + ('end_of_accrual', "After this accrual's period")], + string="Level Transition", default="immediately", required=True, + help="""Immediately: When the date corresponds to the new level, your accrual is automatically computed, granted and you switch to new level + After this accrual's period: When the accrual is complete (a week, a month), and granted, you switch to next level if allocation date corresponds""") + + @api.depends('allocation_ids') + def _compute_employee_count(self): + allocations_read_group = self.env['hr.leave.allocation'].read_group( + [('accrual_plan_id', 'in', self.ids)], + ['accrual_plan_id', 'employee_count:count_distinct(employee_id)'], + ['accrual_plan_id'], + ) + allocations_dict = {res['accrual_plan_id'][0]: res['employee_count'] for res in allocations_read_group} + for plan in self: + plan.employees_count = allocations_dict.get(plan.id, 0) + + def action_open_accrual_plan_employees(self): + self.ensure_one() + + return { + 'name': _("Accrual Plan's Employees"), + 'type': 'ir.actions.act_window', + 'view_mode': 'kanban,tree,form', + 'res_model': 'hr.employee', + 'domain': [('id', 'in', self.allocation_ids.employee_id.ids)], + } diff --git a/addons/hr_holidays/models/hr_leave_accrual_plan_level.py b/addons/hr_holidays/models/hr_leave_accrual_plan_level.py new file mode 100644 index 00000000000..49dac51ddbf --- /dev/null +++ b/addons/hr_holidays/models/hr_leave_accrual_plan_level.py @@ -0,0 +1,291 @@ +# -*- 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'] +MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] + +def _get_date_check_month(year, month, day): + """ + Returns the last day of the month for the given date if the day is outside of the month's range, i.e 30 feb + """ + month_range = calendar.monthrange(year, month) + if day > month_range[1]: + return datetime.date(year, month, min(day, month_range[1])) + relativedelta(days=(day - month_range[1])) + return datetime.date(year, month, day) + +class AccrualPlanLevel(models.Model): + _name = "hr.leave.accrual.level" + _description = "Accrual Plan Level" + _order = 'sequence asc' + + sequence = fields.Integer( + string='sequence', compute='_compute_sequence', store=True, + help='Sequence is generated automatically by start time delta.') + level = fields.Integer(compute='_compute_level', help='Level computed through the sequence.') + accrual_plan_id = fields.Many2one('hr.leave.accrual.plan', "Accrual Plan", required=True) + start_count = fields.Integer( + "Start after", + help="The accrual starts after a defined period from the employee start date. This field define the number of days, month or years after which accrual is used.", default="1") + start_type = fields.Selection( + [('day', 'day(s)'), + ('month', 'month(s)'), + ('year', 'year(s)')], + default='day', string=" ", required=True, + help="This field define the unit of time after which the accrual starts.") + is_based_on_worked_time = fields.Boolean("Based on worked time") + + # Accrue of + added_value = fields.Float( + "Gain", required=True, + help="The number of days that will be incremented for every period") + added_value_type = fields.Selection( + [('days', 'Days'), + ('hours', 'Hours')], + default='days', required=True) + frequency = fields.Selection([ + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ('bimonthly', 'Twice a month'), + ('monthly', 'Monthly'), + ('biyearly', 'Twice a year'), + ('yearly', 'Yearly'), + ], default='daily', required=True, string="Frequency") + week_day = fields.Selection([ + ('mon', 'Monday'), + ('tue', 'Tuesday'), + ('wed', 'Wednesday'), + ('thu', 'Thursday'), + ('fri', 'Friday'), + ('sat', 'Saturday'), + ('sun', 'Sunday'), + ], default='mon', required=True, string="Allocation on") + first_day = fields.Integer(default=1) + second_day = fields.Integer(default=15) + first_month_day = fields.Integer(default=1) + first_month = fields.Selection([ + ('jan', 'January'), + ('feb', 'February'), + ('mar', 'March'), + ('apr', 'April'), + ('may', 'May'), + ('jun', 'June'), + ], default="jan") + second_month_day = fields.Integer(default=1) + second_month = fields.Selection([ + ('jul', 'July'), + ('aug', 'Augustus'), + ('sep', 'September'), + ('oct', 'October'), + ('nov', 'November'), + ('dec', 'December') + ], default="jul") + yearly_month = fields.Selection([ + ('jan', 'January'), + ('feb', 'February'), + ('mar', 'March'), + ('apr', 'April'), + ('may', 'May'), + ('jun', 'June'), + ('jul', 'July'), + ('aug', 'Augustus'), + ('sep', 'September'), + ('oct', 'October'), + ('nov', 'November'), + ('dec', 'December') + ], default="jan") + yearly_day = fields.Integer(default=1) + maximum_leave = fields.Float( + 'Limit to', required=False, default=100, + help="Choose a maximum limit of days for this accrual. 0 means no limit.") + parent_id = fields.Many2one( + 'hr.leave.accrual.level', string="Previous Level", + help="If this field is empty, this level is the first one.") + action_with_unused_accruals = fields.Selection( + [('postponed', 'Postponed to next year'), + ('lost', 'Lost')], + string="At the end of the year, unused accruals will be", + default='postponed', required='True') + + _sql_constraints = [ + ('check_dates', + "CHECK( (frequency = 'daily') or" + "(week_day IS NOT NULL AND frequency = 'weekly') or " + "(first_day > 0 AND second_day > first_day AND first_day <= 31 AND second_day <= 31 AND frequency = 'bimonthly') or " + "(first_day > 0 AND first_day <= 31 AND frequency = 'monthly')or " + "(first_month_day > 0 AND first_month_day <= 31 AND second_month_day > 0 AND second_month_day <= 31 AND frequency = 'biyearly') or " + "(yearly_day > 0 AND yearly_day <= 31 AND frequency = 'yearly'))", + "The dates you've set up aren't correct. Please check them."), + ('start_count_check', "CHECK( start_count >= 1 )", "You must start after more than 0 days."), + ('added_value_greater_than_zero', 'CHECK(added_value > 0)', 'You must give the gain greater than 0 in accrual plan levels.') + ] + + @api.depends('start_count', 'start_type') + def _compute_sequence(self): + # Not 100% accurate because of odd months/years, but good enough + start_type_multipliers = { + 'day': 1, + 'month': 30, + 'year': 365, + } + for level in self: + level.sequence = level.start_count * start_type_multipliers[level.start_type] + + @api.depends('sequence', 'accrual_plan_id') + def _compute_level(self): + #Mapped level_ids.ids ordered by sequence per plan + mapped_level_ids = {} + for plan in self.accrual_plan_id: + # We can not use .ids here because we also deal with NewIds + mapped_level_ids[plan] = [level.id for level in plan.level_ids.sorted('sequence')] + for level in self: + if level.accrual_plan_id: + level.level = mapped_level_ids[level.accrual_plan_id].index(level.id) + 1 + else: + level.level = 1 + + def _get_accrual_values(self, allocation_create_date): + """ + This method returns all the accrual linked to their accrual_plan with the updated dynamic parameters depending + on the date. + :return: dict: {accrual_id, accrual_start, accrual_stop, nextcall, sufficient_seniority} + where accrual_start and accrual_stop are start and stop of the current period + """ + today = fields.Date.context_today(self, ) + results = [] + for accrual in self: + seniority = allocation_create_date + get_timedelta(accrual.start_count, accrual.start_type) + frequency = accrual.frequency + if frequency == 'daily': + accrual_start = max(today, seniority.date()) + accrual_stop = accrual_start + relativedelta(days=1) + nextcall = accrual_stop + elif frequency == 'weekly': + min_accrual_date = max(today, seniority.date()) + if min_accrual_date.isoweekday() == DAYS.index(accrual.week_day): + accrual_stop = min_accrual_date + else: + accrual_stop = accrual._get_next_weekday(min_accrual_date, accrual.week_day) + accrual_start = accrual_stop - relativedelta(days=7) + nextcall = accrual._get_next_weekday(min_accrual_date, accrual.week_day) + elif frequency == 'bimonthly': + if today.day <= accrual.first_day: + accrual_start = datetime.date(today.year, today.month, accrual.second_day) - relativedelta(months=1) + accrual_stop = datetime.date(today.year, today.month, accrual.first_day) + nextcall = datetime.date(today.year, today.month, accrual.second_day) + else: + if today.day <= accrual.second_day: + accrual_start = datetime.date(today.year, today.month, accrual.first_day) + accrual_stop = datetime.date(today.year, today.month, accrual.second_day) + nextcall = datetime.date(today.year, today.month, accrual.first_day) + relativedelta(months=1) + else: + accrual_start = datetime.date(today.year, today.month, accrual.second_day) + accrual_stop = datetime.date(today.year, today.month, accrual.first_day) + relativedelta(months=1) + nextcall = datetime.date(today.year, today.month, accrual.second_day) + relativedelta(months=1) + elif frequency == 'monthly': + if today.day <= accrual.first_day: + accrual_start = datetime.date(today.year, today.month, accrual.first_day) - relativedelta(months=1) + accrual_stop = datetime.date(today.year, today.month, accrual.first_day) + nextcall = datetime.date(today.year, today.month, accrual.first_day) + relativedelta(months=1) + else: + accrual_start = datetime.date(today.year, today.month, accrual.first_day) + accrual_stop = datetime.date(today.year, today.month, accrual.first_day) + relativedelta(months=1) + nextcall = datetime.date(today.year, today.month, accrual.first_day) + relativedelta(months=2) + elif frequency == 'biyearly': + first_month = MONTHS.index(accrual.first_month) + 1 + second_month = MONTHS.index(accrual.second_month) + 1 + potential_first_accrual_date = datetime.date(today.year, first_month, accrual.first_month_day) + potential_second_accrual_date = datetime.date(today.year, second_month, accrual.second_month_day) + if today <= potential_first_accrual_date: + accrual_start = potential_second_accrual_date - relativedelta(years=1) + accrual_stop = potential_first_accrual_date + nextcall = potential_second_accrual_date + else: + if today <= potential_second_accrual_date: + accrual_start = potential_first_accrual_date + accrual_stop = potential_second_accrual_date + nextcall = potential_first_accrual_date + relativedelta(years=1) + else: + accrual_start = potential_second_accrual_date + accrual_stop = potential_first_accrual_date + relativedelta(years=1) + nextcall = potential_first_accrual_date + relativedelta(years=1) + elif frequency == 'yearly': + month = MONTHS.index(accrual.yearly_month) + 1 + potential_accrual_date = datetime.date(today.year, month, accrual.yearly_day) + if today <= potential_accrual_date: + accrual_start = potential_accrual_date - relativedelta(years=1) + accrual_stop = potential_accrual_date + nextcall = potential_accrual_date + relativedelta(years=1) + else: + accrual_start = potential_accrual_date + accrual_stop = potential_accrual_date + relativedelta(years=1) + nextcall = accrual_stop + + results.append({'accrual_level_id': accrual.id, + 'start_after': accrual.start_count, + 'accrual_start': datetime.datetime.combine(accrual_start, datetime.datetime.min.time()), + 'accrual_stop': datetime.datetime.combine(accrual_stop, datetime.datetime.min.time()), + 'nextcall': nextcall, + 'sufficient_seniority': seniority.date() <= today}) + return results + + def _get_next_date(self, last_call): + """ + Returns the next date with the given last call + """ + self.ensure_one() + if self.frequency == 'daily': + return last_call + relativedelta(days=1) + elif self.frequency == 'weekly': + return self._get_next_weekday(last_call, self.week_day) + elif self.frequency == 'bimonthly': + if last_call.day < self.first_day: + return _get_date_check_month(last_call.year, last_call.month, self.first_day) + elif last_call.day < self.second_day: + return _get_date_check_month(last_call.year, last_call.month, self.second_day) + else: + return _get_date_check_month(last_call.year, last_call.month, self.first_day) + relativedelta(months=1) + elif self.frequency == 'monthly': + if last_call.day < self.first_day: + return _get_date_check_month(last_call.year, last_call.month, self.first_day) + else: + return _get_date_check_month(last_call.year, last_call.month, self.first_day) + relativedelta(months=1) + elif self.frequency == 'biyearly': + first_month = MONTHS.index(self.first_month) + 1 + second_month = MONTHS.index(self.second_month) + 1 + if last_call < _get_date_check_month(last_call.year, first_month, self.first_month_day): + return _get_date_check_month(last_call.year, first_month, self.first_month_day) + elif last_call < _get_date_check_month(last_call.year, second_month, self.second_month_day): + return _get_date_check_month(last_call.year, second_month, self.second_month_day) + else: + return _get_date_check_month(last_call.year, first_month, self.first_month_day) + relativedelta(years=1) + elif self.frequency == 'yearly': + month = MONTHS.index(self.yearly_month) + 1 + if last_call < _get_date_check_month(last_call.year, month, self.yearly_day): + return _get_date_check_month(last_call.year, month, self.yearly_day) + else: + return _get_date_check_month(last_call.year, month, self.yearly_day) + relativedelta(years=1) + else: + return False + + @api.model + def _get_next_weekday(self, day, weekday): + """ + :param day: a datetime object + :param weekday: Weekday as a decimal number, where 0 is Sunday and 6 is Saturday. + :return: datetime of the next weekday + """ + daynames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] + weekday = daynames.index(weekday) + days_ahead = weekday - day.isoweekday() + if days_ahead <= 0: + days_ahead += 7 + return day + relativedelta(days=days_ahead) diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py index eda52005cbf..4a737ceca04 100644 --- a/addons/hr_holidays/models/hr_leave_allocation.py +++ b/addons/hr_holidays/models/hr_leave_allocation.py @@ -3,6 +3,7 @@ # Copyright (c) 2005-2006 Axelor SARL. (http://www.axelor.com) +from collections import defaultdict import logging from datetime import datetime, time @@ -13,6 +14,7 @@ from odoo.addons.resource.models.resource import HOURS_PER_DAY from odoo.exceptions import AccessError, UserError, ValidationError from odoo.tools.translate import _ 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__) @@ -28,15 +30,15 @@ class HolidaysAllocation(models.Model): def _default_holiday_status_id(self): if self.user_has_groups('hr_holidays.group_hr_holidays_user'): - domain = [('valid', '=', True), ('allocation_type', '!=', 'no')] + domain = [('has_valid_allocation', '=', True), ('requires_allocation', '=', 'yes')] else: - domain = [('valid', '=', True), ('allocation_type', '=', 'fixed_allocation')] + domain = [('has_valid_allocation', '=', True), ('requires_allocation', '=', 'yes'), ('employee_requests', '=', 'yes')] return self.env['hr.leave.type'].search(domain, limit=1) - def _holiday_status_id_domain(self): - if self.user_has_groups('hr_holidays.group_hr_holidays_manager'): - return [('valid', '=', True), ('allocation_type', '!=', 'no')] - return [('valid', '=', True), ('allocation_type', '=', 'fixed_allocation')] + def _domain_holiday_status_id(self): + if self.user_has_groups('hr_holidays.group_hr_holidays_user'): + return [] + return [('employee_requests', '=', 'yes')] name = fields.Char('Description', compute='_compute_description', inverse='_inverse_description', search='_search_description', compute_sudo=False) active = fields.Boolean(default=True) @@ -46,30 +48,28 @@ class HolidaysAllocation(models.Model): ('cancel', 'Cancelled'), ('confirm', 'To Approve'), ('refuse', 'Refused'), - ('validate1', 'Second Approval'), ('validate', 'Approved') - ], string='Status', readonly=True, tracking=True, copy=False, default='confirm', + ], string='Status', readonly=True, tracking=True, copy=False, default='draft', help="The status is set to 'To Submit', when an allocation request is created." + "\nThe status is 'To Approve', when an allocation request is confirmed by user." + "\nThe status is 'Refused', when an allocation request is refused by manager." + "\nThe status is 'Approved', when an allocation request is approved by manager.") - date_from = fields.Datetime( - 'Start Date', readonly=True, index=True, copy=False, default=fields.Date.context_today, - states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, tracking=True) - date_to = fields.Datetime( - 'End Date', compute='_compute_from_holiday_status_id', store=True, readonly=False, copy=False, tracking=True, + date_from = fields.Date('Start Date', index=True, copy=False, default=fields.Date.context_today, + states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, tracking=True, required=True) + date_to = fields.Date('End Date', copy=False, tracking=True, states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) holiday_status_id = fields.Many2one( - "hr.leave.type", compute='_compute_from_employee_id', store=True, string="Time Off Type", required=True, readonly=False, + "hr.leave.type", compute='_compute_holiday_status_id', store=True, string="Time Off Type", required=True, readonly=False, states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}, - domain=_holiday_status_id_domain, default=_default_holiday_status_id) + domain=_domain_holiday_status_id, + default=_default_holiday_status_id) employee_id = fields.Many2one( - 'hr.employee', compute='_compute_from_holiday_type', store=True, string='Employee', index=True, readonly=False, ondelete="restrict", tracking=True, - states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) - employee_company_id = fields.Many2one(related='employee_id.company_id', readonly=True) + 'hr.employee', compute='_compute_from_employee_ids', store=True, string='Employee', index=True, readonly=False, ondelete="restrict", tracking=True, + states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate': [('readonly', True)]}) + employee_company_id = fields.Many2one(related='employee_id.company_id', readonly=True, store=True) active_employee = fields.Boolean('Active Employee', related='employee_id.active', readonly=True) - manager_id = fields.Many2one('hr.employee', compute='_compute_from_employee_id', store=True, string='Manager') - notes = fields.Html('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}) + manager_id = fields.Many2one('hr.employee', compute='_compute_manager_id', store=True, string='Manager') + notes = fields.Text('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}) # duration number_of_days = fields.Float( 'Number of Days', compute='_compute_from_holiday_status_id', store=True, readonly=False, tracking=True, default=1, @@ -86,12 +86,9 @@ class HolidaysAllocation(models.Model): # details parent_id = fields.Many2one('hr.leave.allocation', string='Parent') linked_request_ids = fields.One2many('hr.leave.allocation', 'parent_id', string='Linked Requests') - first_approver_id = fields.Many2one( + approver_id = fields.Many2one( 'hr.employee', string='First Approval', readonly=True, copy=False, help='This area is automatically filled by the user who validates the allocation') - second_approver_id = fields.Many2one( - 'hr.employee', string='Second Approval', readonly=True, copy=False, - help='This area is automatically filled by the user who validates the allocation with second level (If allocation type need second validation)') 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') @@ -108,126 +105,49 @@ class HolidaysAllocation(models.Model): "\n- By Company: all employees of the specified company" "\n- By Department: all employees of the specified department" "\n- By Employee Tag: all employees of the specific employee group category") + employee_ids = fields.Many2many( + 'hr.employee', compute='_compute_from_holiday_type', store=True, string='Employees', readonly=False, + states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate': [('readonly', True)]}) + multi_employee = fields.Boolean( + compute='_compute_from_employee_ids', store=True, + help='Holds whether this allocation concerns more than 1 employee') mode_company_id = fields.Many2one( 'res.company', compute='_compute_from_holiday_type', store=True, string='Company Mode', readonly=False, - states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) + states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate': [('readonly', True)]}) department_id = fields.Many2one( 'hr.department', compute='_compute_department_id', store=True, string='Department', states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}) category_id = fields.Many2one( 'hr.employee.category', compute='_compute_from_holiday_type', store=True, string='Employee Tag', readonly=False, - states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) + states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate': [('readonly', True)]}) # 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=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}) - accrual_limit = fields.Integer('Balance limit', default=0, help="Maximum of allocation for accrual; 0 means no maximum.") - number_per_interval = fields.Float("Number of unit per interval", compute='_compute_from_holiday_status_id', store=True, readonly=False, - states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) - interval_number = fields.Integer("Number of unit between two intervals", compute='_compute_from_holiday_status_id', store=True, readonly=False, - states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) - unit_per_interval = fields.Selection([ - ('hours', 'Hours'), - ('days', 'Days') - ], compute='_compute_from_holiday_status_id', store=True, string="Unit of time added at each interval", readonly=False, - states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) - interval_unit = fields.Selection([ - ('days', 'Days'), - ('weeks', 'Weeks'), - ('months', 'Months'), - ('years', 'Years') - ], compute='_compute_from_holiday_status_id', store=True, string="Unit of time between two intervals", readonly=False, - states={'cancel': [('readonly', True)], 'refuse': [('readonly', True)], 'validate1': [('readonly', True)], 'validate': [('readonly', True)]}) - nextcall = fields.Date("Date of the next accrual allocation", default=False, 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', '=?', holiday_status_id)]", tracking=True) max_leaves = fields.Float(compute='_compute_leaves') leaves_taken = fields.Float(compute='_compute_leaves') + taken_leave_ids = fields.One2many('hr.leave', 'holiday_allocation_id', domain="[('state', 'in', ['confirm', 'validate1', 'validate'])]") _sql_constraints = [ ('type_value', - "CHECK( (holiday_type='employee' AND employee_id IS NOT NULL) or " + "CHECK( (holiday_type='employee' AND (employee_id IS NOT NULL OR multi_employee IS TRUE)) or " "(holiday_type='category' AND category_id IS NOT NULL) or " "(holiday_type='department' AND department_id IS NOT NULL) or " "(holiday_type='company' AND mode_company_id IS NOT NULL))", "The employee, department, company or employee category of this request is missing. Please make sure that your user login is linked to an employee."), - ('duration_check', "CHECK ( number_of_days >= 0 )", "The number of days must be greater than 0."), - ('number_per_interval_check', "CHECK(number_per_interval > 0)", "The number per interval should be greater than 0"), - ('interval_number_check', "CHECK(interval_number > 0)", "The interval number should be greater than 0"), + ('duration_check', "CHECK( ( number_of_days > 0 AND allocation_type='regular') or (allocation_type != 'regular'))", "The duration must be greater than 0."), ] - @api.model - def _update_accrual(self): - """ - Method called by the cron task in order to increment the number_of_days when - necessary. - """ - today = fields.Date.from_string(fields.Date.today()) - - holidays = self.search([('allocation_type', '=', 'accrual'), ('employee_id.active', '=', True), ('state', '=', 'validate'), ('holiday_type', '=', 'employee'), - '|', ('date_to', '=', False), ('date_to', '>', fields.Datetime.now()), - '|', ('nextcall', '=', False), ('nextcall', '<=', today)]) - - for holiday in holidays: - values = {} - - delta = relativedelta(days=0) - - if holiday.interval_unit == 'days': - delta = relativedelta(days=holiday.interval_number) - if holiday.interval_unit == 'weeks': - delta = relativedelta(weeks=holiday.interval_number) - if holiday.interval_unit == 'months': - delta = relativedelta(months=holiday.interval_number) - if holiday.interval_unit == 'years': - delta = relativedelta(years=holiday.interval_number) - - if holiday.nextcall: - values['nextcall'] = holiday.nextcall + delta - else: - values['nextcall'] = holiday.date_from - while values['nextcall'] <= datetime.combine(today, time(0, 0, 0)): - values['nextcall'] += delta - - period_start = datetime.combine(today, time(0, 0, 0)) - delta - period_end = datetime.combine(today, time(0, 0, 0)) - - # We have to check when the employee has been created - # in order to not allocate him/her too much leaves - start_date = holiday.employee_id._get_date_start_work() - # If employee is created after the period, we cancel the computation - if period_end <= start_date or period_end < holiday.date_from: - holiday.write(values) - continue - - # If employee created during the period, taking the date at which he has been created - if period_start <= start_date: - period_start = start_date - - employee = holiday.employee_id - worked = employee._get_work_days_data_batch( - period_start, period_end, - domain=[('holiday_id.holiday_status_id.unpaid', '=', True), ('time_type', '=', 'leave')] - )[employee.id]['days'] - left = employee._get_leave_days_data_batch( - period_start, period_end, - domain=[('holiday_id.holiday_status_id.unpaid', '=', True), ('time_type', '=', 'leave')] - )[employee.id]['days'] - prorata = worked / (left + worked) if worked else 0 - - days_to_give = holiday.number_per_interval - if holiday.unit_per_interval == 'hours': - # As we encode everything in days in the database we need to convert - # the number of hours into days for this we use the - # mean number of hours set on the employee's calendar - days_to_give = days_to_give / (employee.resource_calendar_id.hours_per_day or HOURS_PER_DAY) - - values['number_of_days'] = holiday.number_of_days + days_to_give * prorata - if holiday.accrual_limit > 0: - values['number_of_days'] = min(values['number_of_days'], holiday.accrual_limit) - - holiday.write(values) + @api.depends_context('uid') + def _compute_is_officer(self): + self.is_officer = self.user_has_groups("hr_holidays.group_hr_holidays_user") @api.depends_context('uid') def _compute_description(self): @@ -261,9 +181,8 @@ class HolidaysAllocation(models.Model): @api.depends('employee_id', 'holiday_status_id') def _compute_leaves(self): for allocation in self: - leave_type = allocation.holiday_status_id.with_context(employee_id=allocation.employee_id.id) - allocation.max_leaves = leave_type.max_leaves - allocation.leaves_taken = leave_type.leaves_taken + allocation.max_leaves = allocation.number_of_hours_display if allocation.type_request_unit == 'hour' else allocation.number_of_days + allocation.leaves_taken = sum(taken_leave.number_of_hours_display if taken_leave.leave_type_request_unit == 'hour' else taken_leave.number_of_days for taken_leave in allocation.taken_leave_ids) @api.depends('number_of_days') def _compute_number_of_days_display(self): @@ -303,37 +222,44 @@ class HolidaysAllocation(models.Model): def _compute_can_approve(self): for allocation in self: try: - if allocation.state == 'confirm' and allocation.validation_type == 'both': - allocation._check_approval_update('validate1') - else: + if allocation.state == 'confirm' and allocation.validation_type != 'no': allocation._check_approval_update('validate') except (AccessError, UserError): allocation.can_approve = False else: allocation.can_approve = True + @api.depends('employee_ids') + def _compute_from_employee_ids(self): + for allocation in self: + if len(allocation.employee_ids) == 1: + allocation.employee_id = allocation.employee_ids[0]._origin + else: + allocation.employee_id = False + allocation.multi_employee = (len(allocation.employee_ids) > 1) + @api.depends('holiday_type') def _compute_from_holiday_type(self): for allocation in self: if allocation.holiday_type == 'employee': - if not allocation.employee_id: - allocation.employee_id = self.env.user.employee_id + if not allocation.employee_ids: + allocation.employee_ids = self.env.user.employee_id allocation.mode_company_id = False allocation.category_id = False if allocation.holiday_type == 'company': - allocation.employee_id = False + allocation.employee_ids = False if not allocation.mode_company_id: allocation.mode_company_id = self.env.company allocation.category_id = False elif allocation.holiday_type == 'department': - allocation.employee_id = False + allocation.employee_ids = False allocation.mode_company_id = False allocation.category_id = False elif allocation.holiday_type == 'category': - allocation.employee_id = False + allocation.employee_ids = False allocation.mode_company_id = False - elif not allocation.employee_id and not allocation._origin.employee_id: - allocation.employee_id = self.env.context.get('default_employee_id') or self.env.user.employee_id + else: + allocation.employee_ids = self.env.context.get('default_employee_id') or self.env.user.employee_id @api.depends('holiday_type', 'employee_id') def _compute_department_id(self): @@ -347,47 +273,207 @@ class HolidaysAllocation(models.Model): allocation.department_id = False @api.depends('employee_id') - def _compute_from_employee_id(self): - default_holiday_status_id = self._default_holiday_status_id() + def _compute_manager_id(self): + for allocation in self: + allocation.manager_id = allocation.employee_id and allocation.employee_id.parent_id + + @api.depends('employee_id', 'accrual_plan_id') + def _compute_holiday_status_id(self): + default_holiday_status_id = None + holiday_status_by_accrual = {accrual.id: accrual.time_off_type_id for accrual in self.accrual_plan_id} for holiday in self: - holiday.manager_id = holiday.employee_id and holiday.employee_id.parent_id if holiday.employee_id.user_id != self.env.user and holiday._origin.employee_id != holiday.employee_id: holiday.holiday_status_id = False - elif not holiday.holiday_status_id and not holiday._origin.holiday_status_id: - holiday.holiday_status_id = default_holiday_status_id + elif not holiday.holiday_status_id: + holiday_status = None + if holiday.accrual_plan_id: + holiday_status = holiday_status_by_accrual.get(holiday.accrual_plan_id.id) + if holiday_status: + holiday.holiday_status_id = holiday_status + elif not holiday._origin.holiday_status_id: + if not default_holiday_status_id: # fetch when we need it + default_holiday_status_id = self._default_holiday_status_id() + holiday.holiday_status_id = default_holiday_status_id - @api.depends('holiday_status_id', 'allocation_type', 'number_of_hours_display', 'number_of_days_display') + @api.depends('holiday_status_id', 'allocation_type', 'number_of_hours_display', 'number_of_days_display', 'date_to') def _compute_from_holiday_status_id(self): + accrual_allocations = self.filtered(lambda alloc: alloc.allocation_type == 'accrual' and not alloc.accrual_plan_id and alloc.holiday_status_id) + accruals_dict = {} + default_accrual_plan = None + if accrual_allocations: + accruals_read_group = self.env['hr.leave.accrual.plan'].read_group( + [('time_off_type_id', 'in', accrual_allocations.holiday_status_id.ids)], + ['time_off_type_id', 'ids:array_agg(id)'], + ['time_off_type_id'], + ) + accruals_dict = {res['time_off_type_id'][0]: res['ids'] for res in accruals_read_group} for allocation in self: allocation.number_of_days = allocation.number_of_days_display if allocation.type_request_unit == 'hour': allocation.number_of_days = allocation.number_of_hours_display / (allocation.employee_id.sudo().resource_calendar_id.hours_per_day or HOURS_PER_DAY) - - # set default values - if not allocation.interval_number and not allocation._origin.interval_number: - allocation.interval_number = 1 - if not allocation.number_per_interval and not allocation._origin.number_per_interval: - allocation.number_per_interval = 1 - if not allocation.unit_per_interval and not allocation._origin.unit_per_interval: - allocation.unit_per_interval = 'hours' - if not allocation.interval_unit and not allocation._origin.interval_unit: - allocation.interval_unit = 'weeks' - - if allocation.holiday_status_id.validity_stop and allocation.date_to: - new_date_to = datetime.combine(allocation.holiday_status_id.validity_stop, time.max) - if new_date_to < allocation.date_to: - allocation.date_to = new_date_to - - if allocation.allocation_type == 'accrual': - if allocation.holiday_status_id.request_unit == 'hour': - allocation.unit_per_interval = 'hours' + if allocation.accrual_plan_id.time_off_type_id != allocation.holiday_status_id: + allocation.accrual_plan_id = False + if allocation.allocation_type == 'accrual' and not allocation.accrual_plan_id: + accrual_plan = None + if allocation.holiday_status_id: + accrual_plan = accruals_dict.get(allocation.holiday_status_id.id, [False])[0] else: - allocation.unit_per_interval = 'days' + if not default_accrual_plan: # fetch the first accrual plan only when we need + default_accrual_plan = self.env['hr.leave.accrual.plan'].search([], limit=1) + accrual_plan = default_accrual_plan + allocation.accrual_plan_id = accrual_plan + + #TODO: remove after impl prorata in _process_accrual_plan_level + def _compute_accrual_days(self, first_day, start_date, end_date): + """ + Increase the number of days depending on the period, the start date of the employee and how many days he worked according to his + work entry. + This method takes also in account if the employee started during the period to apply a prorata + """ + for allocation in self.filtered(lambda i: i.employee_id and i.accrual_plan_id): + start_date_prorata = 1.0 + if first_day > start_date: + # The employee was created during the period after the start_date + # This prorata depends on the number of days worked during this period + worked_days = end_date - first_day + period_days = end_date - start_date + start_date_prorata = worked_days.days / period_days.days + # We calculate from the first_day to end_date + start_date = first_day + if end_date <= first_day or end_date < datetime.combine(allocation.date_from, time(0, 0, 0)): + # Stop the calculation if + # * the user was created after the period + # * the period ends before the allocation is valid + return + # The prorata depends if the employee took holidays during the period. + # Unpaid holidays and work_entry_type.leave_right = False decrease the amount of holidays + if allocation.accrual_level_id.is_based_on_worked_time: + worked = allocation.employee_id._get_work_days_data_batch(start_date, end_date, calendar=allocation.employee_id.resource_calendar_id)\ + [allocation.employee_id.id]['days'] + left = allocation.employee_id.sudo()._get_leave_days_data_batch(start_date, end_date, + domain=[('time_type', '=', 'leave')])[allocation.employee_id.id]['days'] + work_entry_prorata = worked / (left + worked) if worked else 0 + # Calculate the number of days that could be added + added_value = work_entry_prorata * start_date_prorata * allocation.accrual_level_id.added_value else: - allocation.interval_number = 1 - allocation.interval_unit = 'weeks' - allocation.number_per_interval = 1 - allocation.unit_per_interval = 'hours' + added_value = allocation.accrual_level_id.added_value + number_of_days = min(allocation.number_of_days + added_value, allocation.accrual_level_id.maximum_leave) + allocation.write({'number_of_days': number_of_days}) + # Do not send a message if the maximum number of days is already reached + + def _end_of_year_accrual(self): + # to override in payroll + today = fields.Date.today() + for allocation in self: + current_level = allocation._get_current_accrual_plan_level_id(today) + if current_level and 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}) + + def _get_current_accrual_plan_level_id(self, date): + """ + Returns the accrual_plan_level for the given date of the record's accrual plan + """ + self.ensure_one() + if not self.accrual_plan_id.level_ids: + return False + # Sort by sequence which should be equivalent to the level + level_ids = self.accrual_plan_id.level_ids.sorted('sequence') + current_level = False + current_level_idx = -1 + for idx, level in enumerate(level_ids): + if date >= self.date_from + get_timedelta(level.start_count, level.start_type): + current_level = level + current_level_idx = idx + # If transition_mode is set to `immediately` or we are currently on the first level + # the current_level is simply the first level in the list. + if current_level_idx <= 0 or self.accrual_plan_id.transition_mode == "immediately": + return current_level + # In this case we have to verify that the 'previous level' is not the current one due to `end_of_accrual` + level_start_date = self.date_from + get_timedelta(current_level.start_count, current_level.start_type) + previous_level = level_ids[current_level_idx - 1] + # If the next date from the current level's start date is before the last call of the previous level + # return the previous level + if current_level._get_next_date(level_start_date) < previous_level._get_next_date(level_start_date): + return previous_level + return current_level + + def _get_first_accrual_plan_level_start_date(self): + self.ensure_one() + level = self.accrual_plan_id.level_ids.sorted('sequence')[0] + if not level: + return False + return self.date_from + get_timedelta(level.start_count, level.start_type) + + def _process_accrual_plan_level(self, level, start_date, end_date): + """ + Returns the added days for that level + """ + self.ensure_one() + #TODO: ask gmf about prorata stuff see `_compute_accrual_days` + 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()) + worked = self.employee_id._get_work_days_data_batch(start_dt, end_dt, calendar=self.employee_id.resource_calendar_id)\ + [self.employee_id.id]['days'] + left = self.employee_id.sudo()._get_leave_days_data_batch(start_dt, end_dt, + domain=[('time_type', '=', 'leave')])[self.employee_id.id]['days'] + work_entry_prorata = worked / (left + worked) if worked else 0 + added_value = work_entry_prorata * level.added_value + 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': + added_value = added_value / (self.employee_id.sudo().resource_id.calendar_id.hours_per_day or HOURS_PER_DAY) + return added_value + + def _process_accrual_plans(self): + """ + 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 + """ + today = fields.Date.today() + for allocation in self: + if not allocation.nextcall: + current_level = allocation._get_current_accrual_plan_level_id(today) + if not current_level: + # Accrual plan is not configured properly or has not started + continue + allocation.lastcall = max(allocation.lastcall, allocation.date_from + get_timedelta(current_level.start_count, current_level.start_type)) + allocation.nextcall = current_level._get_next_date(allocation.lastcall) + days_added_per_level = defaultdict(lambda: 0) + while allocation.nextcall <= today: + current_level = allocation._get_current_accrual_plan_level_id(allocation.nextcall) + nextcall = current_level._get_next_date(allocation.nextcall) + days_added_per_level[current_level] += allocation._process_accrual_plan_level(current_level, allocation.lastcall, allocation.nextcall) + allocation.lastcall = allocation.nextcall + allocation.nextcall = nextcall + 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 + # 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)}) + + @api.model + def _update_accrual(self): + """ + 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)) + if today.day == 1 and today.month == 1: + 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())]) + end_of_year_allocations._end_of_year_accrual() + end_of_year_allocations.flush() + 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() #################################################### # ORM Overrides methods @@ -402,8 +488,10 @@ class HolidaysAllocation(models.Model): target = allocation.department_id.name elif allocation.holiday_type == 'category': target = allocation.category_id.name + elif allocation.employee_id: + target = allocation.employee_id.name else: - target = allocation.employee_id.sudo().name + target = ', '.join(allocation.employee_ids.sudo().mapped('name')) res.append( (allocation.id, @@ -421,20 +509,6 @@ class HolidaysAllocation(models.Model): if employee.user_id: self.message_subscribe(partner_ids=employee.user_id.partner_id.ids) - @api.constrains('holiday_status_id') - def _check_leave_type_validity(self): - for allocation in self: - if allocation.holiday_status_id.validity_stop: - vstop = allocation.holiday_status_id.validity_stop - today = fields.Date.today() - - if vstop < today: - raise ValidationError(_( - 'You can allocate %(allocation_type)s only before %(date)s.', - allocation_type=allocation.holiday_status_id.display_name, - date=allocation.holiday_status_id.validity_stop - )) - @api.model_create_multi def create(self, vals_list): """ Override to avoid automatic logging of creation """ @@ -442,6 +516,9 @@ class HolidaysAllocation(models.Model): employee_id = values.get('employee_id', False) if not values.get('department_id'): values.update({'department_id': self.env['hr.employee'].browse(employee_id).department_id.id}) + # default `lastcall` to `nextcall` + if 'date_from' in values and 'lastcall' not in values: + values['lastcall'] = values['date_from'] holidays = super(HolidaysAllocation, self.with_context(mail_create_nosubscribe=True)).create(vals_list) for holiday in holidays: partners_to_subscribe = set() @@ -453,6 +530,8 @@ class HolidaysAllocation(models.Model): holiday.message_subscribe(partner_ids=tuple(partners_to_subscribe)) if not self._context.get('import_file'): holiday.activity_update() + if holiday.validation_type == 'no': + holiday.state = 'validate' return holidays def write(self, values): @@ -476,9 +555,9 @@ class HolidaysAllocation(models.Model): # Business methods #################################################### - def _prepare_holiday_values(self, employee): + def _prepare_holiday_values(self, employees): self.ensure_one() - values = { + return [{ 'name': self.name, 'holiday_type': 'employee', 'holiday_status_id': self.holiday_status_id.id, @@ -486,23 +565,19 @@ class HolidaysAllocation(models.Model): 'number_of_days': self.number_of_days, 'parent_id': self.id, 'employee_id': employee.id, + 'state': 'confirm', 'allocation_type': self.allocation_type, 'date_from': self.date_from, 'date_to': self.date_to, - 'interval_unit': self.interval_unit, - 'interval_number': self.interval_number, - 'number_per_interval': self.number_per_interval, - 'unit_per_interval': self.unit_per_interval, - } - return values + 'accrual_plan_id': self.accrual_plan_id.id, + } for employee in employees] def action_draft(self): if any(holiday.state not in ['confirm', 'refuse'] for holiday in self): raise UserError(_('Allocation request state must be "Refused" or "To Approve" in order to be reset to Draft.')) self.write({ 'state': 'draft', - 'first_approver_id': False, - 'second_approver_id': False, + 'approver_id': False, }) linked_requests = self.mapped('linked_request_ids') if linked_requests: @@ -518,29 +593,14 @@ class HolidaysAllocation(models.Model): self.activity_update() return res - def action_approve(self): - # if validation_type == 'both': this method is the first approval approval - # if validation_type != 'both': this method calls action_validate() below - if any(holiday.state != 'confirm' for holiday in self): - raise UserError(_('Allocation request must be confirmed ("To Approve") in order to approve it.')) - - current_employee = self.env.user.employee_id - - self.filtered(lambda hol: hol.validation_type == 'both').write({'state': 'validate1', 'first_approver_id': current_employee.id}) - self.filtered(lambda hol: not hol.validation_type == 'both').action_validate() - self.activity_update() - def action_validate(self): current_employee = self.env.user.employee_id for holiday in self: - if holiday.state not in ['confirm', 'validate1']: + if holiday.state != 'confirm': raise UserError(_('Allocation request must be confirmed in order to approve it.')) holiday.write({'state': 'validate'}) - if holiday.validation_type == 'both': - holiday.write({'second_approver_id': current_employee.id}) - else: - holiday.write({'first_approver_id': current_employee.id}) + holiday.write({'approver_id': current_employee.id}) holiday._action_validate_create_childs() self.activity_update() @@ -548,22 +608,26 @@ class HolidaysAllocation(models.Model): def _action_validate_create_childs(self): childs = self.env['hr.leave.allocation'] - if self.state == 'validate' and self.holiday_type in ['category', 'department', 'company']: - if self.holiday_type == 'category': + # In the case we are in holiday_type `employee` and there is only one employee we can keep the same allocation + # Otherwise we do need to create an allocation for all employees to have a behaviour that is in line + # with the other holiday_type + if self.state == 'validate' and (self.holiday_type in ['category', 'department', 'company'] or + (self.holiday_type == 'employee' and len(self.employee_ids) > 1)): + if self.holiday_type == 'employee': + employees = self.employee_ids + elif self.holiday_type == 'category': employees = self.category_id.employee_ids elif self.holiday_type == 'department': employees = self.department_id.member_ids else: employees = self.env['hr.employee'].search([('company_id', '=', self.mode_company_id.id)]) - for employee in employees: - childs += self.with_context( - mail_notify_force_send=False, - mail_activity_automation_skip=True - ).create(self._prepare_holiday_values(employee)) - # TODO is it necessary to interleave the calls? - childs.action_approve() - if childs and self.validation_type == 'both': + allocation_create_vals = self._prepare_holiday_values(employees) + childs += self.with_context( + mail_notify_force_send=False, + mail_activity_automation_skip=True + ).create(allocation_create_vals) + if childs and self.validation_type != 'no': childs.action_validate() return childs @@ -572,9 +636,7 @@ class HolidaysAllocation(models.Model): if any(holiday.state not in ['confirm', 'validate', 'validate1'] for holiday in self): raise UserError(_('Allocation request must be confirmed or validated in order to refuse it.')) - validated_holidays = self.filtered(lambda hol: hol.state == 'validate1') - validated_holidays.write({'state': 'refuse', 'first_approver_id': current_employee.id}) - (self - validated_holidays).write({'state': 'refuse', 'second_approver_id': current_employee.id}) + self.write({'state': 'refuse', 'approver_id': current_employee.id}) # If a category that created several holidays, cancel all related linked_requests = self.mapped('linked_request_ids') if linked_requests: @@ -622,6 +684,13 @@ class HolidaysAllocation(models.Model): if not is_officer: raise UserError(_('Only a Time off Approver can apply the second approval on allocation requests.')) + @api.onchange('allocation_type') + def _onchange_allocation_type(self): + if self.allocation_type == 'accrual': + self.number_of_days = 0.0 + elif not self.number_of_days_display: + self.number_of_days = 1.0 + # ------------------------------------------------------------ # Activity methods # ------------------------------------------------------------ diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py index e6f6ab72ee5..5063168e76b 100644 --- a/addons/hr_holidays/models/hr_leave_type.py +++ b/addons/hr_holidays/models/hr_leave_type.py @@ -9,7 +9,6 @@ import logging from collections import defaultdict from odoo import api, fields, models -from odoo.exceptions import ValidationError from odoo.osv import expression from odoo.tools.translate import _ from odoo.tools.float_utils import float_round @@ -26,10 +25,9 @@ class HolidaysType(models.Model): def _model_sorting_key(self, leave_type): remaining = leave_type.virtual_remaining_leaves > 0 taken = leave_type.leaves_taken > 0 - return -1*leave_type.sequence, leave_type.allocation_type == 'fixed' and remaining, leave_type.allocation_type == 'fixed_allocation' and remaining, taken + return -1*leave_type.sequence, leave_type.employee_requests == 'no' and remaining, leave_type.employee_requests == 'yes' and remaining, taken name = fields.Char('Time Off Type', required=True, translate=True) - code = fields.Char('Code') sequence = fields.Integer(default=100, help='The type with the smallest sequence is the default value in time off request') create_calendar_meeting = fields.Boolean(string="Display Time Off in Calendar", default=True) @@ -50,7 +48,9 @@ class HolidaysType(models.Model): ('lavender', 'Lavender'), ('wheat', 'Wheat'), ('ivory', 'Ivory')], string='Color in Report', required=True, default='red', - help='This color will be used in the time off summary located in Reporting > Time off by Department.') + help='This color will be used in the time off summary located in Reporting > Time off by Department.') + color = fields.Integer(string='Color', help="The color selected here will be used in every screen with the time off type.") + icon_id = fields.Many2one('ir.attachment', string='Cover Image', domain="[('res_model', '=', 'hr.leave.type'), ('res_field', '=', 'icon_id')]") active = fields.Boolean('Active', default=True, help="If the active field is set to false, it will allow you to hide the time off type without removing it.") max_leaves = fields.Float(compute='_compute_leaves', string='Maximum Allowed', search='_search_max_leaves', @@ -72,70 +72,86 @@ class HolidaysType(models.Model): group_days_leave = fields.Float( compute='_compute_group_days_leave', string='Group Time Off') company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company) - responsible_id = fields.Many2one('res.users', 'Responsible', + responsible_id = fields.Many2one( + 'res.users', 'Responsible Time Off Officer', domain=lambda self: [('groups_id', 'in', self.env.ref('hr_holidays.group_hr_holidays_user').id)], - help="This user will be responsible for approving this type of time off. " - "This is only used when validation is 'By Time Off Officer' or 'By Employee's Manager and Time Off Officer'",) + help="Choose the Time Off Officer who will be notified to approve allocation or Time Off request") leave_validation_type = fields.Selection([ ('no_validation', 'No Validation'), ('hr', 'By Time Off Officer'), ('manager', "By Employee's Approver"), ('both', "By Employee's Approver and Time Off Officer")], default='hr', string='Leave Validation') + requires_allocation = fields.Selection([ + ('yes', 'Yes'), + ('no', 'No Limit')], default="yes", required=True, string='Requires allocation') + employee_requests = fields.Selection([ + ('yes', 'Extra Days Requests Allowed'), + ('no', 'Not Allowed')], default="no", required=True, string="Employee Requests") allocation_validation_type = fields.Selection([ - ('hr', 'By Time Off Officer'), - ('manager', "By Employee's Approver"), - ('both', "By Employee's Approver and Time Off Officer")], default='manager', string='Allocation Validation') - allocation_type = fields.Selection([ - ('no', 'No Limit'), - ('fixed_allocation', 'Allow Employees Requests'), - ('fixed', 'Set by Time Off Officer')], - default='no', string='Mode', - help='\tNo Limit: no allocation by default, users can freely request time off; ' - '\tAllow Employees Requests: allocated by HR and users can request time off and allocations; ' - '\tSet by Time Off Officer: allocated by HR and cannot be bypassed; users can request time off;') - validity_start = fields.Date("From", - help='Adding validity to types of time off so that it cannot be selected outside this time period') - validity_stop = fields.Date("To") - valid = fields.Boolean(compute='_compute_valid', search='_search_valid', help='This indicates if it is still possible to use this type of leave') + ('no', 'No validation needed'), + ('officer', 'Approved by Time Off Officer'), + ('set', "Set by Time Off Officer")], default='officer', string='Approval') + has_valid_allocation = fields.Boolean(compute='_compute_valid', search='_search_valid', help='This indicates if it is still possible to use this type of leave') time_type = fields.Selection([('leave', 'Time Off'), ('other', 'Other')], default='leave', string="Kind of Leave", help="Whether this should be computed as a holiday or as work time (eg: formation)") request_unit = fields.Selection([ - ('day', 'Day'), ('half_day', 'Half Day'), ('hour', 'Hours')], - default='day', string='Take Time Off in', required=True) + ('day', 'Day'), + ('half_day', 'Half Day'), + ('hour', 'Hours')], default='day', string='Take Time Off in', required=True) unpaid = fields.Boolean('Is Unpaid', default=False) leave_notif_subtype_id = fields.Many2one('mail.message.subtype', string='Time Off Notification Subtype', default=lambda self: self.env.ref('hr_holidays.mt_leave', raise_if_not_found=False)) allocation_notif_subtype_id = fields.Many2one('mail.message.subtype', string='Allocation Notification Subtype', default=lambda self: self.env.ref('hr_holidays.mt_leave_allocation', raise_if_not_found=False)) support_document = fields.Boolean(string='Supporting Document') + accruals_ids = fields.One2many('hr.leave.accrual.plan', 'time_off_type_id') + accrual_count = fields.Float(compute="_compute_accrual_count", string="Accruals count") - @api.constrains('validity_start', 'validity_stop') - def _check_validity_dates(self): - for leave_type in self: - if leave_type.validity_start and leave_type.validity_stop and \ - leave_type.validity_start > leave_type.validity_stop: - raise ValidationError(_("End of validity period should be greater than start of validity period")) - - @api.depends('validity_start', 'validity_stop') - def _compute_valid(self): - dt = self._context.get('default_date_from') or fields.Date.context_today(self) - - for holiday_type in self: - if holiday_type.validity_start and holiday_type.validity_stop: - holiday_type.valid = ((dt < holiday_type.validity_stop) and (dt > holiday_type.validity_start)) - elif holiday_type.validity_start and (dt > holiday_type.validity_start): - holiday_type.valid = False - else: - holiday_type.valid = True + @api.model def _search_valid(self, operator, value): - dt = self._context.get('default_date_from', False) + """ Returns leave_type ids for which a valid allocation exists + or that don't need an allocation + return [('id', domain_operator, [x['id'] for x in res])] + """ + date_to = self._context.get('default_date_from') or fields.Date.today().strftime('%Y-1-1') + date_from = self._context.get('default_date_to') or fields.Date.today().strftime('%Y-12-31') - if not dt: - return [] - signs = ['>=', '<='] if operator == '=' else ['<=', '>='] + if not isinstance(value, bool): + raise ValueError('Invalid value: %s' % (value)) + if operator not in ['=', '!=']: + raise ValueError('Invalid operator: %s' % (operator)) + new_operator = 'in' if operator == '=' else 'not in' - return ['|', ('validity_stop', operator, False), '&', - ('validity_stop', signs[0] if value else signs[1], dt), - ('validity_start', signs[1] if value else signs[0], dt)] + query = ''' + SELECT + holiday_status_id + FROM + hr_leave_allocation alloc + WHERE + alloc.date_to >= %s OR alloc.date_to IS NULL AND + alloc.date_from <= %s + ''' + + self._cr.execute(query, (date_to, date_from)) + + 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()) + date_from = self._context.get('default_date_from', fields.Datetime.today()) + for holiday_type in self: + if holiday_type.requires_allocation: + allocation = self.env['hr.leave.allocation'].search([ + ('holiday_status_id', '=', holiday_type.id), + '|', + ('date_to', '>=', date_to), + '&', + ('date_to', '=', False), + ('date_from', '<=', date_from)]) + holiday_type.has_valid_allocation = bool(allocation) + else: + holiday_type.has_valid_allocation = True def _search_max_leaves(self, operator, value): value = float(value) @@ -172,7 +188,7 @@ class HolidaysType(models.Model): valid_leave_types = self.env['hr.leave.type'] for leave_type in leave_types: - if leave_type.allocation_type != 'no': + if leave_type.requires_allocation == "yes": if operator == '>' and leave_type.virtual_remaining_leaves > value: valid_leave_types |= leave_type elif operator == '<' and leave_type.virtual_remaining_leaves < value: @@ -250,8 +266,7 @@ class HolidaysType(models.Model): @api.model def get_days_all_request(self): - leave_types = sorted(self.search([]).filtered(lambda x: ((not x.validity_stop or x.validity_stop >= fields.Date.today()) - and (x.virtual_remaining_leaves or x.max_leaves))), key=self._model_sorting_key, reverse=True) + leave_types = sorted(self.search([]).filtered(lambda x: ((x.virtual_remaining_leaves or x.max_leaves))), key=self._model_sorting_key, reverse=True) return [lt._get_days_request() for lt in leave_types] def _get_days_request(self): @@ -263,7 +278,8 @@ class HolidaysType(models.Model): 'leaves_taken': ('%.2f' % self.leaves_taken).rstrip('0').rstrip('.'), 'virtual_leaves_taken': ('%.2f' % self.virtual_leaves_taken).rstrip('0').rstrip('.'), 'request_unit': self.request_unit, - }, self.allocation_type, self.validity_stop, self.id) + 'icon': self.sudo().icon_id.url, + }, self.requires_allocation, self.id) def _get_contextual_employee_id(self): if 'employee_id' in self._context: @@ -290,18 +306,16 @@ class HolidaysType(models.Model): holiday_status.virtual_leaves_taken = result.get('virtual_leaves_taken', 0) def _compute_group_days_allocation(self): + date_from = fields.Datetime.to_string(datetime.datetime.now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)) domain = [ ('holiday_status_id', 'in', self.ids), - ('holiday_type', '!=', 'employee'), ('state', '=', 'validate'), - ] - domain2 = [ '|', - ('date_from', '>=', fields.Datetime.to_string(datetime.datetime.now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0))), + ('date_from', '>=', date_from), ('date_from', '=', False), ] grouped_res = self.env['hr.leave.allocation'].read_group( - expression.AND([domain, domain2]), + domain, ['holiday_status_id', 'number_of_days'], ['holiday_status_id'], ) @@ -311,7 +325,7 @@ class HolidaysType(models.Model): def _compute_group_days_leave(self): grouped_res = self.env['hr.leave'].read_group( - [('holiday_status_id', 'in', self.ids), ('holiday_type', '=', 'employee'), ('state', '=', 'validate'), + [('holiday_status_id', 'in', self.ids), ('state', '=', 'validate'), ('date_from', '>=', fields.Datetime.to_string(datetime.datetime.now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)))], ['holiday_status_id'], ['holiday_status_id'], @@ -320,6 +334,12 @@ class HolidaysType(models.Model): for allocation in self: allocation.group_days_leave = grouped_dict.get(allocation.id, 0) + def _compute_accrual_count(self): + accrual_allocations = self.env['hr.leave.allocation'].read_group([('employee_id', '!=', False), ('accrual_plan_id', '!=', False), ('state', '=', 'validate')], ['id'], ['holiday_status_id']) + mapped_data = dict((data['holiday_status_id'][0], data['holiday_status_id_count']) for data in accrual_allocations) + for leave_type in self: + leave_type.accrual_count = mapped_data.get(leave_type.id, 0) + def name_get(self): if not self._context.get('employee_id'): # leave counts is based on employee_id, would be inaccurate if not based on correct employee @@ -327,7 +347,7 @@ class HolidaysType(models.Model): res = [] for record in self: name = record.name - if record.allocation_type != 'no': + if record.requires_allocation == "yes": name = "%(name)s (%(count)s)" % { 'name': name, 'count': _('%g remaining out of %g') % ( @@ -362,16 +382,14 @@ class HolidaysType(models.Model): def action_see_days_allocated(self): self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id("hr_holidays.hr_leave_allocation_action_all") - domain = [ + date_from = fields.Datetime.to_string( + datetime.datetime.now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)) + action['domain'] = [ ('holiday_status_id', 'in', self.ids), - ('holiday_type', '!=', 'employee'), - ] - domain2 = [ '|', - ('date_from', '>=', fields.Datetime.to_string(datetime.datetime.now().replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0))), + ('date_from', '>=', date_from), ('date_from', '=', False), ] - action['domain'] = expression.AND([domain, domain2]) action['context'] = { 'default_holiday_type': 'department', 'default_holiday_status_id': self.ids[0], @@ -389,3 +407,10 @@ class HolidaysType(models.Model): 'default_holiday_status_id': self.ids[0], } return action + + def action_see_accrual_plans(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("hr.holidays.open_view_accrual_plans") + action['context'] = { + 'default_time_off_type_id': self.id, + } diff --git a/addons/hr_holidays/report/__init__.py b/addons/hr_holidays/report/__init__.py index c59b1e68b62..aafcb0afab5 100644 --- a/addons/hr_holidays/report/__init__.py +++ b/addons/hr_holidays/report/__init__.py @@ -4,3 +4,4 @@ from . import holidays_summary_report from . import hr_leave_report from . import hr_leave_report_calendar +from . import hr_leave_employee_type_report diff --git a/addons/hr_holidays/report/hr_leave_employee_type_report.py b/addons/hr_holidays/report/hr_leave_employee_type_report.py new file mode 100644 index 00000000000..549f5ef5389 --- /dev/null +++ b/addons/hr_holidays/report/hr_leave_employee_type_report.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, tools, _ + + +class LeaveReport(models.Model): + _name = "hr.leave.employee.type.report" + _description = 'Time Off Summary / Report' + _auto = False + _order = "date_from DESC, employee_id" + + employee_id = fields.Many2one('hr.employee', string="Employee", readonly=True) + active_employee = fields.Boolean(related='employee_id.active', readonly=True) + number_of_days = fields.Float('Number of Days', readonly=True, group_operator="sum") + department_id = fields.Many2one('hr.department', string='Department', readonly=True) + leave_type = fields.Many2one("hr.leave.type", string="Leave Type", readonly=True) + holiday_status = fields.Selection([ + ('taken', 'Taken'), #taken = validated + ('left', 'Left'), + ('planned', 'Planned') + ]) + state = fields.Selection([ + ('draft', 'To Submit'), + ('cancel', 'Cancelled'), + ('confirm', 'To Approve'), + ('refuse', 'Refused'), + ('validate1', 'Second Approval'), + ('validate', 'Approved') + ], string='Status', readonly=True) + date_from = fields.Datetime('Start Date', readonly=True) + date_to = fields.Datetime('End Date', readonly=True) + company_id = fields.Many2one('res.company', string="Company", readonly=True) + + def init(self): + tools.drop_view_if_exists(self._cr, 'hr_leave_employee_type_report') + + self._cr.execute(""" + CREATE or REPLACE view hr_leave_employee_type_report as ( + SELECT row_number() over(ORDER BY leaves.employee_id) as id, + leaves.employee_id as employee_id, + leaves.number_of_days as number_of_days, + leaves.department_id as department_id, + leaves.leave_type as leave_type, + leaves.holiday_status as holiday_status, + leaves.state as state, + leaves.date_from as date_from, + leaves.date_to as date_to, + leaves.company_id as company_id + FROM (SELECT + allocation.employee_id as employee_id, + CASE + WHEN request.number_of_days > 0 THEN allocation.number_of_days - request.number_of_days + ELSE allocation.number_of_days + END as number_of_days, + allocation.department_id as department_id, + allocation.holiday_status_id as leave_type, + allocation.state as state, + allocation.date_from as date_from, + allocation.date_to as date_to, + 'left' as holiday_status, + allocation.employee_company_id as company_id + FROM hr_leave_allocation as allocation + LEFT JOIN + (SELECT holiday_status_id, employee_id, sum(number_of_days) as number_of_days + FROM hr_leave GROUP BY holiday_status_id, employee_id) request + on (allocation.employee_id=request.employee_id and allocation.holiday_status_id = request.holiday_status_id) + UNION ALL SELECT + request.employee_id as employee_id, + request.number_of_days as number_of_days, + request.department_id as department_id, + request.holiday_status_id as leave_type, + request.state as state, + request.date_from as date_from, + request.date_to as date_to, + CASE + WHEN request.state IN ('validate1', 'validate') THEN 'taken' + WHEN request.state = 'confirm' THEN 'planned' + END as holiday_status, + request.employee_company_id as company_id + FROM hr_leave as request) leaves + ); + """) + + @api.model + def action_time_off_analysis(self): + domain = [] + if self.env.context.get('active_ids'): + domain = [('employee_id', 'in', self.env.context.get('active_ids', []))] + + return { + 'name': _('Time Off Analysis'), + 'type': 'ir.actions.act_window', + 'res_model': 'hr.leave.employee.type.report', + 'view_mode': 'pivot', + 'search_view_id': [self.env.ref('hr_holidays.view_search_hr_holidays_employee_type_report').id], + 'domain': domain, + 'context': { + 'search_default_year': True, + 'search_default_company': True, + 'search_default_employee': True, + 'group_expand': True, + } + } diff --git a/addons/hr_holidays/report/hr_leave_employee_type_report.xml b/addons/hr_holidays/report/hr_leave_employee_type_report.xml new file mode 100644 index 00000000000..15ddf1b329e --- /dev/null +++ b/addons/hr_holidays/report/hr_leave_employee_type_report.xml @@ -0,0 +1,42 @@ + + + + hr.holidays.filter + hr.leave.employee.type.report + + + + + + + + + + + + + + hr.leave.employee.type.report.view.pivot + hr.leave.employee.type.report + + + + + + + + + + + + Time off Analysis by Employee and Time Off Type + + + code + + + action = model.action_time_off_analysis() + + + + diff --git a/addons/hr_holidays/report/hr_leave_report.py b/addons/hr_holidays/report/hr_leave_report.py index 7608068d846..28b6c43356f 100644 --- a/addons/hr_holidays/report/hr_leave_report.py +++ b/addons/hr_holidays/report/hr_leave_report.py @@ -36,7 +36,7 @@ class LeaveReport(models.Model): ], string='Allocation Mode', readonly=True) date_from = fields.Datetime('Start Date', readonly=True) date_to = fields.Datetime('End Date', readonly=True) - payslip_status = fields.Boolean('Reported in last payslips', readonly=True) + company_id = fields.Many2one('res.company', string="Company", readonly=True) def init(self): tools.drop_view_if_exists(self._cr, 'hr_leave_report') @@ -49,7 +49,7 @@ class LeaveReport(models.Model): leaves.category_id as category_id, leaves.department_id as department_id, leaves.holiday_status_id as holiday_status_id, leaves.state as state, leaves.holiday_type as holiday_type, leaves.date_from as date_from, - leaves.date_to as date_to, leaves.payslip_status as payslip_status + leaves.date_to as date_to, leaves.company_id from (select allocation.employee_id as employee_id, allocation.private_name as name, @@ -61,8 +61,8 @@ class LeaveReport(models.Model): allocation.holiday_type, null as date_from, null as date_to, - FALSE as payslip_status, - 'allocation' as leave_type + 'allocation' as leave_type, + allocation.employee_company_id as company_id from hr_leave_allocation as allocation union all select request.employee_id as employee_id, @@ -75,8 +75,8 @@ class LeaveReport(models.Model): request.holiday_type, request.date_from as date_from, request.date_to as date_to, - request.payslip_status as payslip_status, - 'request' as leave_type + 'request' as leave_type, + request.employee_company_id as company_id from hr_leave as request) leaves ); """) diff --git a/addons/hr_holidays/report/hr_leave_report_calendar.py b/addons/hr_holidays/report/hr_leave_report_calendar.py index fdd2839508e..79ae1dae7ec 100644 --- a/addons/hr_holidays/report/hr_leave_report_calendar.py +++ b/addons/hr_holidays/report/hr_leave_report_calendar.py @@ -19,6 +19,7 @@ class LeaveReportCalendar(models.Model): duration = fields.Float(string='Duration', readonly=True) employee_id = fields.Many2one('hr.employee', readonly=True) department_id = fields.Many2one('hr.department', readonly=True) + job_id = fields.Many2one('hr.job', readonly=True) company_id = fields.Many2one('res.company', readonly=True) state = fields.Selection([ ('draft', 'To Submit'), @@ -30,6 +31,7 @@ class LeaveReportCalendar(models.Model): ], readonly=True) is_hatched = fields.Boolean('Hatched', readonly=True) + is_striked = fields.Boolean('Striked', readonly=True) def init(self): tools.drop_view_if_exists(self._cr, 'hr_leave_report_calendar') @@ -43,11 +45,13 @@ class LeaveReportCalendar(models.Model): hl.state AS state, hl.department_id AS department_id, em.company_id AS company_id, + em.job_id AS job_id, CASE WHEN hl.holiday_type = 'employee' THEN rr.tz ELSE %s END AS tz, - state != 'validate' as is_hatched + state = 'refuse' as is_striked, + state not in ('validate', 'refuse') as is_hatched FROM hr_leave hl LEFT JOIN hr_employee em ON em.id = hl.employee_id diff --git a/addons/hr_holidays/report/hr_leave_report_calendar.xml b/addons/hr_holidays/report/hr_leave_report_calendar.xml index 54d58cd6480..341761d99ce 100644 --- a/addons/hr_holidays/report/hr_leave_report_calendar.xml +++ b/addons/hr_holidays/report/hr_leave_report_calendar.xml @@ -1,17 +1,5 @@ - - All Time Off - hr.leave.report.calendar - calendar - - - - [('employee_id.active','=',True)] - {'hide_employee_name': 1} - - hr.leave.report.calendar.view hr.leave.report.calendar @@ -47,17 +35,28 @@ + + + + + + + - + + All Time Off + hr.leave.report.calendar calendar + [('employee_id.active','=',True)] + {'hide_employee_name': 1} diff --git a/addons/hr_holidays/report/hr_leave_reports.xml b/addons/hr_holidays/report/hr_leave_reports.xml index bf2120b983c..432b2edec41 100644 --- a/addons/hr_holidays/report/hr_leave_reports.xml +++ b/addons/hr_holidays/report/hr_leave_reports.xml @@ -28,6 +28,7 @@ + @@ -52,58 +53,20 @@ - - - report.hr.holidays.report.leave_all.kanban + + report.hr.holidays.report.leave_all.pivot hr.leave.report - - + + + + + - - - -
-
- - - - -
- - - - -
-
- -
-
-
-
-
-
- - - - - - - - - -
from
to
-
-
- days -
-
-
-
-
-
+ +
diff --git a/addons/hr_holidays/security/hr_holidays_security.xml b/addons/hr_holidays/security/hr_holidays_security.xml index 30a8adb8e45..e065effb03e 100644 --- a/addons/hr_holidays/security/hr_holidays_security.xml +++ b/addons/hr_holidays/security/hr_holidays_security.xml @@ -12,7 +12,7 @@
- All Approver + Time Off Officer @@ -161,14 +161,15 @@ Allocations: base.group_user create/write [ - ('holiday_status_id.allocation_type', '=', 'fixed_allocation'), + ('holiday_status_id.requires_allocation', '=', 'yes'), + ('holiday_status_id.employee_requests', '=', 'yes'), ('holiday_type', '=', 'employee'), '|', '&', ('employee_id.user_id', '=', user.id), - ('state', 'not in', ['validate', 'validate1']), + ('state', '!=', 'validate'), '&', - ('validation_type', 'in', ['manager', 'both']), + ('validation_type', 'in', ['officer', 'set']), ('employee_id.leave_manager_id', '=', user.id), ] diff --git a/addons/hr_holidays/security/ir.model.access.csv b/addons/hr_holidays/security/ir.model.access.csv index bfc2e5fa19d..95236f4c0b4 100644 --- a/addons/hr_holidays/security/ir.model.access.csv +++ b/addons/hr_holidays/security/ir.model.access.csv @@ -16,3 +16,8 @@ access_calendar_attendee_hr_user,calendar.attendee.hr.user,calendar.model_calend access_mail_activity_type_holidays_manager,mail.activity.type.holidays.manager,mail.model_mail_activity_type,hr_holidays.group_hr_holidays_manager,1,1,1,1 access_hr_holidays_summary_employee,access.hr.holidays.summary.employee,model_hr_holidays_summary_employee,hr_holidays.group_hr_holidays_user,1,1,1,0 access_hr_leave_report_calendar,access_hr_leave_report_calendar,model_hr_leave_report_calendar,base.group_user,1,0,0,0 +access_hr_leave_employee_type_report,access_hr_leave_employee_type_report,model_hr_leave_employee_type_report,base.group_user,1,0,0,0 +access_hr_leave_accrual_plan_user,hr.leave.accrual.plan.user,model_hr_leave_accrual_plan,hr_holidays.group_hr_holidays_user,1,0,0,0 +access_hr_leave_accrual_level_user,hr.leave.accrual.level.user,model_hr_leave_accrual_level,hr_holidays.group_hr_holidays_user,1,0,0,0 +access_hr_leave_accrual_plan_manager,hr.leave.accrual.plan.manager,model_hr_leave_accrual_plan,hr_holidays.group_hr_holidays_manager,1,1,1,1 +access_hr_leave_accrual_level_manager,hr.leave.accrual.level.manager,model_hr_leave_accrual_level,hr_holidays.group_hr_holidays_manager,1,1,1,1 diff --git a/addons/hr_holidays/static/src/img/icons/Annual_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Annual_Time_Off.svg new file mode 100644 index 00000000000..635e671f2f2 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Annual_Time_Off.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Annual_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Annual_Time_Off_2.svg new file mode 100644 index 00000000000..950bec8ddfa --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Annual_Time_Off_2.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Annual_Time_Off_3.svg b/addons/hr_holidays/static/src/img/icons/Annual_Time_Off_3.svg new file mode 100644 index 00000000000..322adf39f98 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Annual_Time_Off_3.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off.svg new file mode 100644 index 00000000000..89da61ee05c --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_2.svg new file mode 100644 index 00000000000..315e777bf4c --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_3.svg b/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_3.svg new file mode 100644 index 00000000000..b7d30cf84b5 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Compensatory_Time_Off_3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Credit_Time.svg b/addons/hr_holidays/static/src/img/icons/Credit_Time.svg new file mode 100644 index 00000000000..5ea32df72f0 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Credit_Time.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Credit_Time_2.svg b/addons/hr_holidays/static/src/img/icons/Credit_Time_2.svg new file mode 100644 index 00000000000..454ba0ea918 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Credit_Time_2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Extra_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Extra_Time_Off.svg new file mode 100644 index 00000000000..9c6004932f2 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Extra_Time_Off.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Extra_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Extra_Time_Off_2.svg new file mode 100644 index 00000000000..d2874f2988b --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Extra_Time_Off_2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off.svg new file mode 100644 index 00000000000..640ea4234c1 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_2.svg new file mode 100644 index 00000000000..88a85f39130 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_3.svg b/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_3.svg new file mode 100644 index 00000000000..3d9488db857 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Maternity_Time_Off_3.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Paid_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Paid_Time_Off.svg new file mode 100644 index 00000000000..ecc9c806e86 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Paid_Time_Off.svg @@ -0,0 +1,15 @@ + + + diff --git a/addons/hr_holidays/static/src/img/icons/Paid_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Paid_Time_Off_2.svg new file mode 100644 index 00000000000..82e382aceac --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Paid_Time_Off_2.svg @@ -0,0 +1,15 @@ + + + diff --git a/addons/hr_holidays/static/src/img/icons/Paid_Time_Off_3.svg b/addons/hr_holidays/static/src/img/icons/Paid_Time_Off_3.svg new file mode 100644 index 00000000000..0b104f47e4b --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Paid_Time_Off_3.svg @@ -0,0 +1,12 @@ + + + diff --git a/addons/hr_holidays/static/src/img/icons/Parental_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Parental_Time_Off.svg new file mode 100644 index 00000000000..94738e4a172 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Parental_Time_Off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Parental_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Parental_Time_Off_2.svg new file mode 100644 index 00000000000..53219853355 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Parental_Time_Off_2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday.svg b/addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday.svg new file mode 100644 index 00000000000..627ff661771 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday_2.svg b/addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday_2.svg new file mode 100644 index 00000000000..24f285ec03d --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Recovery_Bank_Holiday_2.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Refused.svg b/addons/hr_holidays/static/src/img/icons/Refused.svg new file mode 100644 index 00000000000..5425b105e1c --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Refused.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/hr_holidays/static/src/img/icons/Sick_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Sick_Time_Off.svg new file mode 100644 index 00000000000..d848f464d0a --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Sick_Time_Off.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Sick_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Sick_Time_Off_2.svg new file mode 100644 index 00000000000..cf4cdc4b49e --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Sick_Time_Off_2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Small_Unemployement.svg b/addons/hr_holidays/static/src/img/icons/Small_Unemployement.svg new file mode 100644 index 00000000000..9f3b1328725 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Small_Unemployement.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Small_Unemployement_2.svg b/addons/hr_holidays/static/src/img/icons/Small_Unemployement_2.svg new file mode 100644 index 00000000000..81896d90a21 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Small_Unemployement_2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Small_Unemployement_3.svg b/addons/hr_holidays/static/src/img/icons/Small_Unemployement_3.svg new file mode 100644 index 00000000000..db2578e9c43 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Small_Unemployement_3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/To_Approve.svg b/addons/hr_holidays/static/src/img/icons/To_Approve.svg new file mode 100644 index 00000000000..69e5c7dd634 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/To_Approve.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/hr_holidays/static/src/img/icons/Training_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Training_Time_Off.svg new file mode 100644 index 00000000000..1bd009cfaac --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Training_Time_Off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Training_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Training_Time_Off_2.svg new file mode 100644 index 00000000000..514bdb09193 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Training_Time_Off_2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off.svg new file mode 100644 index 00000000000..3da22e2f013 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off_2.svg new file mode 100644 index 00000000000..3523ee4c665 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Unpaid_Time_Off_2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons.svg b/addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons.svg new file mode 100644 index 00000000000..65395f6e198 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons_2.svg b/addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons_2.svg new file mode 100644 index 00000000000..b1990a03882 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Unpredictable_Reasons_2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Validated.svg b/addons/hr_holidays/static/src/img/icons/Validated.svg new file mode 100644 index 00000000000..445178b7d95 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Validated.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off.svg b/addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off.svg new file mode 100644 index 00000000000..0e6789f0e38 --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off_2.svg b/addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off_2.svg new file mode 100644 index 00000000000..88fe00d9d2b --- /dev/null +++ b/addons/hr_holidays/static/src/img/icons/Work_Accident_Time_Off_2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/hr_holidays/static/src/js/radio_image.js b/addons/hr_holidays/static/src/js/radio_image.js new file mode 100644 index 00000000000..5bf571614bf --- /dev/null +++ b/addons/hr_holidays/static/src/js/radio_image.js @@ -0,0 +1,37 @@ +/** @odoo-module **/ + +import { FieldRadio } from 'web.relational_fields'; +import FieldRegistry from 'web.field_registry'; + +import Core from 'web.core'; +const QWeb = Core.qweb; + +const FieldRadioImage = FieldRadio.extend({ + _render() { + const self = this; + let currentValue; + if (this.field.type === 'many2one') { + currentValue = this.value && this.value.data.id; + } else { + currentValue = this.value; + } + this.$el.empty(); + this.$el.attr('role', 'radiogroup') + .attr('aria-label', this.string); + _.each(this.values, function (value, index) { + if (self.mode === 'edit' || value[0] === currentValue) { + self.$el.append(QWeb.render('FieldRadioIcon.button', { + checked: value[0] === currentValue, + id: self.unique_id + '_' + value[0], + index: index, + name: self.unique_id, + value: value, + disabled: self.hasReadonlyModifier, + mode: self.mode, + })); + } + }); + } +}); + +FieldRegistry.add('radio_image', FieldRadioImage); diff --git a/addons/hr_holidays/static/src/js/time_off_calendar.js b/addons/hr_holidays/static/src/js/time_off_calendar.js index 9675e23c45e..92a88f8d91c 100644 --- a/addons/hr_holidays/static/src/js/time_off_calendar.js +++ b/addons/hr_holidays/static/src/js/time_off_calendar.js @@ -32,13 +32,6 @@ odoo.define('hr_holidays.dashboard.view_custo', function(require) { }, }); - var TimeoffCalendarModel = CalendarModel.extend({ - - _getFilterDomain: function() { - return this._super.apply(this, arguments).concat([["state", "!=", "refuse"]]); - }, - }); - var TimeOffCalendarController = CalendarController.extend({ events: _.extend({}, CalendarController.prototype.events, { 'click .btn-time-off': '_onNewTimeOff', @@ -69,7 +62,7 @@ odoo.define('hr_holidays.dashboard.view_custo', function(require) { $(QWeb.render('hr_holidays.dashboard.calendar.button', { time_off: _t('New Time Off'), - request: _t('New Allocation'), + request: _t('Allocation Request'), })).appendTo(this.$buttons); if ($node) { @@ -90,7 +83,7 @@ odoo.define('hr_holidays.dashboard.view_custo', function(require) { */ _onNewTimeOff: function () { var self = this; - + //use formViewId for this view? this.do_action('hr_holidays.hr_leave_action_my_request', { on_close: function () { self.reload(); @@ -118,9 +111,31 @@ odoo.define('hr_holidays.dashboard.view_custo', function(require) { } }); }, + + /** + * @override + */ + _setEventTitle: function () { + return _t('Time Off Request'); + }, }); var TimeOffPopoverRenderer = CalendarRenderer.extend({ + template: "TimeOff.CalendarView.extend", + /** + * We're overriding this to display the weeknumbers on the year view + * + * @override + * @private + */ + _getFullCalendarOptions: function () { + const oldOptions = this._super(...arguments); + // Parameters + oldOptions.views.dayGridYear.weekNumbers = true; + oldOptions.views.dayGridYear.weekNumbersWithinDays = false; + return oldOptions; + }, + config: _.extend({}, CalendarRenderer.prototype.config, { CalendarPopover: TimeOffCalendarPopover, }), @@ -147,20 +162,6 @@ odoo.define('hr_holidays.dashboard.view_custo', function(require) { var self = this; return this._super.apply(this, arguments).then(function () { self.$el.parent().find('.o_calendar_mini').hide(); - - // Check if there is a filter to display on the sidebar - // If there is no filter, hide the sidebar - const noFilters = !Object.values(self.state.filters).some(f => f.filters.length); - - // Remove the no data sidebar - self.$sidebar.find('#o_calendar_filter_no_data').remove(); - if (noFilters) { - // Show a special sidebar - self.$sidebar.html(QWeb.render('hr_holidays.calendar.sidebar.nofilter', { - title: 'Time Off Type', - description: '(no data)' - })); - } }); }, }); @@ -201,7 +202,7 @@ odoo.define('hr_holidays.dashboard.view_custo', function(require) { config: _.extend({}, CalendarView.prototype.config, { Controller: TimeOffCalendarController, Renderer: TimeOffCalendarRenderer, - Model: TimeoffCalendarModel, + Model: CalendarModel, }), }); @@ -210,6 +211,7 @@ odoo.define('hr_holidays.dashboard.view_custo', function(require) { */ var TimeOffCalendarAllView = CalendarView.extend({ config: _.extend({}, CalendarView.prototype.config, { + Controller: TimeOffCalendarController, Renderer: TimeOffPopoverRenderer, }), }); diff --git a/addons/hr_holidays/static/src/scss/accrual_plan_level.scss b/addons/hr_holidays/static/src/scss/accrual_plan_level.scss new file mode 100644 index 00000000000..ca723511e61 --- /dev/null +++ b/addons/hr_holidays/static/src/scss/accrual_plan_level.scss @@ -0,0 +1,107 @@ +$o-hr-holidays-border-color: gray('300'); + +.o_hr_holidays_hierarchy { + margin-left: -$o-horizontal-padding; + margin-right: -$o-horizontal-padding; + @include media-breakpoint-between(lg, xl, $o-extra-grid-breakpoints) { + margin-left: -$o-horizontal-padding*2; + margin-right: -$o-horizontal-padding*2; + } + margin-bottom:-24px; + padding: 10px 0px 24px 16px; + background-color: #F0F0F0; + box-shadow: 0px 1px 1px rgba(17, 17, 17, 0.23); + overflow-x: auto; + .o_hr_holidays_title { + padding: 0px 0px 0px 86px; + font-size: 18px; + } + + .o_hr_holidays_hierarchy_readonly { + padding: 40px 0px 0px 40px; + } + + .o_hr_holidays_plan_level_container { + .o-kanban-button-new { + padding: 2px 12px; + margin: 0px 0px 0px 44px; + border-radius: 25px; + } + + .o_kanban_view.o_kanban_ungrouped .o_kanban_record { + display: flex; + flex: 0 0 100%; + border: 0px; + padding: 0px 0px 0px 100px; + margin: 0px 0px 0px 0px; + background-color: transparent; + + // Timeline Border + &:before { + content: ''; + display: block; + @include o-position-absolute; + height: 100%; + margin-left: 8px; + border-left: 1px dashed darken($o-hr-holidays-border-color, 10%); + } + + // Whole record + .o_hr_holidays_body { + margin-left: 8px; + padding-top: 20px; + + // Left side 'Level' + .o_hr_holidays_timeline { + @include o-position-absolute($top: 32px, $left: 6px); + width: 90px; + padding: 3px 0px; + border-radius: 3px; + background-color: white; + box-shadow: 0 1px 2px rgba(0,0,0,.1); + } + + // Actual kanban card + .o_hr_holidays_card { + position: relative; + margin-left: 22px; + margin-right: 2px; + width: 500px; + border-radius: 3px; + background: white; + box-shadow: 0 1px 2px rgba(0,0,0,.1); + + // Triangle + &:before { + content: ''; + @include o-position-absolute($top: 12px, $left: -17px); + margin-left: 10px; + width: 14px; + height: 14px; + background: white; + border-bottom: 1px solid $o-hr-holidays-border-color; + transform: rotate(45deg); + } + + // Circle + &:after { + content: ''; + @include o-position-absolute($top: 14px, $left: -28px); + width: 12px; + height: 12px; + border: 2px solid $o-brand-primary; + border-radius: 10px; + background: #F0F0F0 + } + + .content { + position: relative; + background-color: white; + padding: 5px 7px; + font-size: 14px; + } + } + } + } + } +} diff --git a/addons/hr_holidays/static/src/scss/time_off.scss b/addons/hr_holidays/static/src/scss/time_off.scss index 2f8816e2fcd..e880cb0ea0d 100644 --- a/addons/hr_holidays/static/src/scss/time_off.scss +++ b/addons/hr_holidays/static/src/scss/time_off.scss @@ -1,26 +1,37 @@ .o_timeoff_calendar .o_content { .o_timeoff_container { - height: 6rem; + height: auto; box-shadow: inset 0 -1px 0 $border-color; - + .o_calendar_container { - height: calc(100% - 6rem); - } } .o_calendar_container { - height: 100%; + height: auto; } .o_timeoff_card { border-right: $border-color solid 2px; text-align: center; - margin-top: 4px; div { line-height: 1; } } + .fc-dayGridYear-view { + .fc-bgevent { + border-radius: 25px; + } + > .fc-month-container { + > .fc-month { + .fc-dayGridMonth-view { + .fc-has-event { + background-color: unset; + } + } + } + } + } + .o_timeoff_card_last { border-right: 0px; } @@ -29,6 +40,10 @@ font-size: 15px; } + .o_timeoff_huge { + font-size: 30px; + } + .o_timeoff_purple { color: $o-enterprise-color; } @@ -42,4 +57,7 @@ font-weight: bold; } } + .fc-week-number { + color: #adb5bd; + } } diff --git a/addons/hr_holidays/static/src/xml/time_off_calendar.xml b/addons/hr_holidays/static/src/xml/time_off_calendar.xml index ce8b7be8cd1..a4b9d8e365b 100644 --- a/addons/hr_holidays/static/src/xml/time_off_calendar.xml +++ b/addons/hr_holidays/static/src/xml/time_off_calendar.xml @@ -3,37 +3,32 @@
-
- +
+ + - - - -
- - / HoursDays Available + +
+ HOURSDAYS AVAILABLE
- HoursDays taken +
+ HOURSDAYS TAKEN
- - - (Expire on ) -
- + - + / HoursDaysAvailable @@ -41,9 +36,6 @@ HoursDaysTaken - - (Expire on ) - @@ -93,17 +85,26 @@
- -
-
-
-
-

- - -

-
-
+ +
+ + +
+
+
+ + + +
+
Legend
+ Validated
+ To Approve
+ Refused
+
+
+
+ diff --git a/addons/hr_holidays/tests/test_access_rights.py b/addons/hr_holidays/tests/test_access_rights.py index dc1df2cc908..2d97aaf9377 100644 --- a/addons/hr_holidays/tests/test_access_rights.py +++ b/addons/hr_holidays/tests/test_access_rights.py @@ -17,8 +17,7 @@ class TestHrHolidaysAccessRightsCommon(TestHrHolidaysCommon): self.leave_type = self.env['hr.leave.type'].create({ 'name': 'Unlimited', 'leave_validation_type': 'hr', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) self.rd_dept.manager_id = False self.hr_dept.manager_id = False @@ -36,29 +35,25 @@ class TestHrHolidaysAccessRightsCommon(TestHrHolidaysCommon): self.lt_no_validation = self.env['hr.leave.type'].create({ 'name': 'Validation = no_validation', 'leave_validation_type': 'hr', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) self.lt_validation_hr = self.env['hr.leave.type'].create({ 'name': 'Validation = HR', 'leave_validation_type': 'hr', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) self.lt_validation_manager = self.env['hr.leave.type'].create({ 'name': 'Validation = manager', 'leave_validation_type': 'hr', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) self.lt_validation_both = self.env['hr.leave.type'].create({ 'name': 'Validation = both', 'leave_validation_type': 'hr', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) self.draft_status = [ @@ -779,7 +774,7 @@ class TestMultiCompany(TestHrHolidaysCommon): 'name': 'Unlimited - Company New', 'company_id': self.new_company.id, 'leave_validation_type': 'hr', - 'allocation_type': 'no', + 'requires_allocation': 'no', }) self.rd_dept.manager_id = False self.hr_dept.manager_id = False diff --git a/addons/hr_holidays/tests/test_accrual_allocations.py b/addons/hr_holidays/tests/test_accrual_allocations.py index b46da059470..9c2e2fdbf45 100644 --- a/addons/hr_holidays/tests/test_accrual_allocations.py +++ b/addons/hr_holidays/tests/test_accrual_allocations.py @@ -1,251 +1,556 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import datetime, time +import datetime +from freezegun import freeze_time from dateutil.relativedelta import relativedelta from odoo import fields - -from odoo.tools import mute_logger +from odoo.tests import tagged from odoo.addons.hr_holidays.tests.common import TestHrHolidaysCommon +@tagged('post_install', '-at_install') class TestAccrualAllocations(TestHrHolidaysCommon): def setUp(self): super(TestAccrualAllocations, self).setUp() - - # Make sure we have the rights to create, validate and delete the leaves, leave types and allocations - LeaveType = self.env['hr.leave.type'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True) - - self.accrual_type = LeaveType.create({ - 'name': 'accrual', - 'allocation_type': 'fixed', - 'validity_start': False, + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Paid Time Off', + 'time_type': 'leave', + 'requires_allocation': 'yes', }) - self.unpaid_type = LeaveType.create({ - 'name': 'unpaid', - 'allocation_type': 'no', - 'unpaid': True, - 'validity_start': False, - }) - - self.set_employee_create_date(self.employee_emp_id, '2010-02-03 00:00:00') - self.set_employee_create_date(self.employee_hruser_id, '2010-02-03 00:00:00') - - def set_employee_create_date(self, id, newdate): + def setAllocationCreateDate(self, allocation_id, date): """ This method is a hack in order to be able to define/redefine the create_date - of the employees. + of the allocations. This is done in SQL because ORM does not allow to write onto the create_date field. """ self.env.cr.execute(""" UPDATE - hr_employee + hr_leave_allocation SET create_date = '%s' WHERE id = %s - """ % (newdate, id)) + """ % (date, allocation_id)) - def test_accrual_base_no_leaves(self): - """ Test if we can allocate some leaves accrually to an employee """ - alloc = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ - 'name': 'Accrual allocation for employee', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 1, - 'interval_number': 1, - 'unit_per_interval': 'days', - 'interval_unit': 'weeks', + def test_frequency_daily(self): + 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': 10000 + })], }) - alloc.action_approve() - alloc._update_accrual() - - self.assertEqual(alloc.number_of_days, 1, 'Employee should have been allocated one leave day') - - def test_accrual_base_leaves(self): - """ Test if the accrual allocation take the unpaid leaves into account when allocating leaves """ - alloc = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ - 'name': 'Accrual allocation for employee with leaves', - 'employee_id': self.employee_hruser_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 1, - 'interval_number': 1, - 'unit_per_interval': 'days', - 'interval_unit': 'weeks', - }) - - alloc.action_approve() - - employee = self.env['hr.employee'].browse(self.employee_hruser_id) - # Getting the previous work date - df = employee.resource_calendar_id.plan_days(-2, fields.Datetime.now()).date() - - leave = self.env['hr.leave'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ - 'name': 'Leave for hruser', - 'employee_id': self.employee_hruser_id, - 'holiday_status_id': self.unpaid_type.id, - 'date_from': datetime.combine(df, time(0, 0, 0)), - 'date_to': datetime.combine(df + relativedelta(days=1), time(0, 0, 0)), - 'number_of_days': 1, - }) - - leave.action_approve() - - alloc._update_accrual() - - self.assertEqual(alloc.number_of_days, .8, 'As employee took some unpaid leaves last week, he should be allocated only .8 days') - - def test_accrual_many(self): - """ - Test different configuration of accrual allocations - """ - Allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True) - - alloc_0 = Allocation.create({ - 'name': '1 day per 2 weeks', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 1, - 'interval_number': 2, - 'unit_per_interval': 'days', - 'interval_unit': 'weeks', - }) - - alloc_1 = Allocation.create({ - 'name': '4 hours per week', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 4, - 'interval_number': 1, - 'unit_per_interval': 'hours', - 'interval_unit': 'weeks', - }) - - alloc_2 = Allocation.create({ - 'name': '2 day per 1 month', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 2, - 'interval_number': 1, - 'unit_per_interval': 'days', - 'interval_unit': 'months', - }) - - alloc_3 = Allocation.create({ - 'name': '20 days per year', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 20, - 'interval_number': 1, - 'unit_per_interval': 'days', - 'interval_unit': 'years', - }) - - (alloc_0 | alloc_1 | alloc_2 | alloc_3).action_approve() - - Allocation._update_accrual() - - self.assertEqual(alloc_0.number_of_days, 1) - self.assertEqual(alloc_1.number_of_days, .5) - self.assertEqual(alloc_2.number_of_days, 2) - self.assertEqual(alloc_3.number_of_days, 20) - - def test_accrual_new_employee(self): - """ - Test if accrual allocation takes into account the creation date - of an employee - """ - Allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True) - - self.set_employee_create_date(self.employee_emp_id, fields.Datetime.to_string(fields.Datetime.now())) - - alloc_0 = Allocation.create({ - 'name': 'one shot one kill', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 1, - 'interval_number': 1, - 'unit_per_interval': 'days', - 'interval_unit': 'weeks', - }) - - alloc_0.action_approve() - - Allocation._update_accrual() - - self.assertEqual(alloc_0.number_of_days, 0, 'Employee is new he should not get any accrual leaves') - - def test_accrual_multi(self): - """ Test if the cron does not allocate leaves every time it's called but only when necessary """ - alloc = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ - 'name': '2 days per week', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'number_of_days': 0, - 'number_per_interval': 1, - 'interval_number': 2, - 'unit_per_interval': 'days', - 'interval_unit': 'weeks', - }) - alloc.action_approve() - alloc._update_accrual() - alloc._update_accrual() - - self.assertEqual(alloc.number_of_days, 1, 'Cron only allocates 1 days every two weeks') - - def test_accrual_validation(self): - """ - Test if cron does not allocate past it's validity date - """ - Allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True) - - alloc_0 = Allocation.create({ - 'name': '20 days per year', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'number_of_days': 0, - 'number_per_interval': 20, - 'interval_number': 1, - 'unit_per_interval': 'days', - 'interval_unit': 'years', - 'date_to': fields.Datetime.from_string('2015-02-03 00:00:00'), - }) - - alloc_0.action_approve() - - Allocation._update_accrual() - - self.assertEqual(alloc_0.number_of_days, 0, 'Cron validity passed, should not allocate any leave') - - def test_accrual_balance_limit(self): - """ Test if accrual allocation does not allocate more than the balance limit""" allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ - 'name': 'accrual 5 max', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.accrual_type.id, - 'allocation_type': 'accrual', - 'accrual_limit': 5, + '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, - 'number_per_interval': 6, - 'interval_number': 1, - 'unit_per_interval': 'days', - 'interval_unit': 'weeks', + 'allocation_type': 'accrual', }) - allocation.action_approve() + allocation.action_confirm() + allocation.action_validate() + self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.') + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.') allocation._update_accrual() + tomorrow = datetime.date.today() + relativedelta(days=2) + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.') - self.assertEqual(allocation.number_of_days, 5, 'Should have allocated only 5 days as balance limit is 5') + freezer = freeze_time(tomorrow) + freezer.start() + allocation._update_accrual() + nextcall = datetime.date.today() + relativedelta(days=1) + self.assertEqual(allocation.number_of_days, 1, 'There should be 1 day allocated.') + self.assertEqual(allocation.nextcall, nextcall, 'The next call date of the cron should be in 2 days.') + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 1, 'There should be only 1 day allocated.') + freezer.stop() + + def test_frequency_weekly(self): + 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': 'weekly', + 'maximum_leave': 10000 + })], + }) + allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ + 'name': 'Accrual allocation for employee', + 'accrual_plan_id': accrual_plan.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + allocation.action_confirm() + allocation.action_validate() + self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.') + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.') + allocation._update_accrual() + nextWeek = self.env['hr.leave.accrual.level']._get_next_weekday(datetime.date.today(), 'mon') + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.') + + freezer = freeze_time(nextWeek) + freezer.start() + allocation._update_accrual() + nextWeek = self.env['hr.leave.accrual.level']._get_next_weekday(datetime.date.today(), 'mon') + self.assertEqual(allocation.number_of_days, 1, 'There should be 1 day allocated.') + self.assertEqual(allocation.nextcall, nextWeek, 'The next call date of the cron should be in 2 weeks') + freezer.stop() + + def test_frequency_bimonthly(self): + freezer = freeze_time('2021-09-01') + freezer.start() + 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': 'bimonthly', + 'first_day': 1, + 'second_day': 15, + 'maximum_leave': 10000, + })], + }) + allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ + 'name': 'Accrual allocation for employee', + 'accrual_plan_id': accrual_plan.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + self.setAllocationCreateDate(allocation.id, '2021-09-01 00:00:00') + allocation.action_confirm() + allocation.action_validate() + self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.') + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.') + allocation._update_accrual() + next_date = datetime.date(2021, 9, 15) + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.') + freezer.stop() + + freezer = freeze_time(next_date) + freezer.start() + next_date = datetime.date(2021, 10, 1) + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 1, 'There should be 1 day allocated.') + self.assertEqual(allocation.nextcall, next_date, 'The next call date of the cron should be October 1st') + freezer.stop() + + def test_frequency_monthly(self): + freezer = freeze_time('2021-09-01') + freezer.start() + 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': 'monthly', + 'maximum_leave': 10000 + })], + }) + allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ + 'name': 'Accrual allocation for employee', + 'accrual_plan_id': accrual_plan.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + self.setAllocationCreateDate(allocation.id, '2021-09-01 00:00:00') + allocation.action_confirm() + allocation.action_validate() + self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.') + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.') + allocation._update_accrual() + next_date = datetime.date(2021, 10, 1) + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.') + freezer.stop() + + freezer = freeze_time(next_date) + freezer.start() + next_date = datetime.date(2021, 11, 1) + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 1, 'There should be 1 day allocated.') + self.assertEqual(allocation.nextcall, next_date, 'The next call date of the cron should be November 1st') + freezer.stop() + + def test_frequency_biyearly(self): + freezer = freeze_time('2021-09-01') + freezer.start() + 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': 'biyearly', + 'maximum_leave': 10000, + })], + }) + #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, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + self.setAllocationCreateDate(allocation.id, '2021-09-01 00:00:00') + allocation.action_confirm() + allocation.action_validate() + self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.') + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.') + allocation._update_accrual() + next_date = datetime.date(2022, 1, 1) + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.') + freezer.stop() + + freezer = freeze_time(next_date) + freezer.start() + next_date = datetime.date(2022, 7, 1) + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 1, 'There should be 1 day allocated.') + self.assertEqual(allocation.nextcall, next_date, 'The next call date of the cron should be July 1st') + freezer.stop() + + def test_frequency_yearly(self): + freezer = freeze_time('2021-09-01') + freezer.start() + 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': 'yearly', + 'maximum_leave': 10000, + })], + }) + #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, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + self.setAllocationCreateDate(allocation.id, '2021-09-01 00:00:00') + allocation.action_confirm() + allocation.action_validate() + self.assertFalse(allocation.nextcall, 'There should be no nextcall set on the allocation.') + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet.') + allocation._update_accrual() + next_date = datetime.date(2022, 1, 1) + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.') + freezer.stop() + + freezer = freeze_time(next_date) + freezer.start() + next_date = datetime.date(2023, 1, 1) + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 1, 'There should be 1 day allocated.') + self.assertEqual(allocation.nextcall, next_date, 'The next call date of the cron should be January 1st 2023') + freezer.stop() + + def test_check_gain(self): + # 2 accruals, one based on worked time, one not + # check gain + freezer = freeze_time('2021-08-30') + freezer.start() + calendar_emp = self.env['resource.calendar'].create({ + 'name': '40 Hours', + 'tz': self.employee_emp.tz, + 'attendance_ids': [ + (0, 0, { + 'name': '%s_%d' % ('40 Hours', index), + 'hour_from': 8, + 'hour_to': 12, + 'dayofweek': str(index), + 'day_period': 'morning' + }, { + 'name': '%s_%d' % ('40 Hours', index), + 'hour_from': 13, + 'hour_to': 18, + 'dayofweek': str(index), + 'day_period': 'afternoon' + }) for index in range(5) + ], + }) + self.employee_emp.resource_calendar_id = calendar_emp.id + + 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, { + 'start_count': 1, + 'start_type': 'day', + 'added_value': 5, + 'added_value_type': 'days', + 'frequency': 'weekly', + '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', + 'level_ids': [(0, 0, { + 'start_count': 1, + 'start_type': 'day', + 'added_value': 5, + 'added_value_type': 'days', + 'frequency': 'weekly', + '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({ + 'name': 'Accrual allocation for employee', + 'accrual_plan_id': accrual_plan_not_based_on_worked_time.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + 'state': 'validate', + }) + allocation_worked_time = 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_based_on_worked_time.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + 'state': 'validate', + }) + self.setAllocationCreateDate(allocation_not_worked_time.id, '2021-08-01 00:00:00') + self.setAllocationCreateDate(allocation_worked_time.id, '2021-08-01 00:00:00') + holiday_type = self.env['hr.leave.type'].create({ + 'name': 'Paid Time Off', + 'requires_allocation': 'no', + 'responsible_id': self.user_hrmanager_id, + }) + leave = self.env['hr.leave'].create({ + 'name': 'leave', + 'employee_id': self.employee_emp.id, + 'holiday_status_id': holiday_type.id, + 'date_from': '2021-09-02 00:00:00', + 'date_to': '2021-09-02 23:59:59', + }) + leave.action_validate() + self.assertFalse(allocation_not_worked_time.nextcall, 'There should be no nextcall set on the allocation.') + self.assertFalse(allocation_worked_time.nextcall, 'There should be no nextcall set on the allocation.') + self.assertEqual(allocation_not_worked_time.number_of_days, 0, 'There should be no days allocated yet.') + self.assertEqual(allocation_worked_time.number_of_days, 0, 'There should be no days allocated yet.') + freezer.stop() + next_date = datetime.date(2021, 9, 6) + freezer = freeze_time(next_date) + freezer.start() + # next_date = datetime.date(2021, 9, 13) + self.env['hr.leave.allocation']._update_accrual() + self.assertEqual(allocation_not_worked_time.number_of_days, 5, 'There should be 5 days allocated.') + # 3.75 -> starts 1 day after allocation date -> 31/08-3/09 => 4 days - 1 days time off => (3 / 4) * 5 days + self.assertEqual(allocation_worked_time.number_of_days, 3.75, 'There should be 5 days allocated.') + self.assertEqual(allocation_not_worked_time.nextcall, datetime.date(2021, 9, 13), 'The next call date of the cron should be the September 13th') + self.assertEqual(allocation_worked_time.nextcall, datetime.date(2021, 9, 13), 'The next call date of the cron should be the September 13th') + freezer.stop() + + freezer = freeze_time(next_date + relativedelta(days=7)) + freezer.start() + next_date = datetime.date(2021, 9, 20) + self.env['hr.leave.allocation']._update_accrual() + self.assertEqual(allocation_not_worked_time.number_of_days, 10, 'There should be 10 days allocated.') + self.assertEqual(allocation_not_worked_time.nextcall, next_date, 'The next call date of the cron should be September 20th') + self.assertEqual(allocation_worked_time.number_of_days, 8.75, 'There should be 9 days allocated.') + self.assertEqual(allocation_worked_time.nextcall, next_date, 'The next call date of the cron should be September 20th') + freezer.stop() + + def test_check_max_value(self): + 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': 1, + })], + }) + allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ + 'name': 'Accrual allocation for employee', + 'accrual_plan_id': accrual_plan.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + allocation.action_confirm() + allocation.action_validate() + allocation._update_accrual() + tomorrow = datetime.date.today() + relativedelta(days=2) + self.assertEqual(allocation.number_of_days, 0, 'There should be no days allocated yet. The accrual starts tomorrow.') + + freezer = freeze_time(tomorrow) + freezer.start() + allocation._update_accrual() + nextcall = datetime.date.today() + relativedelta(days=1) + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 1, 'There should be only 1 day allocated.') + freezer.stop() + + freezer = freeze_time(nextcall) + freezer.start() + allocation._update_accrual() + nextcall = datetime.date.today() + relativedelta(days=1) + #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.') + freezer.stop() + + def test_accrual_transition_immediately(self): + #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, { + 'start_count': 1, + 'start_type': 'day', + 'added_value': 1, + 'added_value_type': 'days', + 'frequency': 'weekly', + 'maximum_leave': 1, + }), (0, 0, { + 'start_count': 10, + 'start_type': 'day', + 'added_value': 1, + 'added_value_type': 'days', + 'frequency': 'weekly', + 'maximum_leave': 1, + })], + }) + allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ + 'name': 'Accrual allocation for employee', + 'accrual_plan_id': accrual_plan.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + allocation.action_confirm() + allocation.action_validate() + next_date = datetime.date.today() + relativedelta(days=11) + second_level = self.env['hr.leave.accrual.level'].search([('accrual_plan_id', '=', accrual_plan.id), ('start_count', '=', 10)]) + self.assertEqual(allocation._get_current_accrual_plan_level_id(next_date), second_level, 'The second level should be selected') + + def test_accrual_transition_after_period(self): + # 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, { + 'start_count': 1, + 'start_type': 'day', + 'added_value': 1, + 'added_value_type': 'days', + 'frequency': 'weekly', + 'maximum_leave': 1, + }), (0, 0, { + 'start_count': 10, + 'start_type': 'day', + 'added_value': 1, + 'added_value_type': 'days', + 'frequency': 'weekly', + 'maximum_leave': 1, + })], + }) + allocation = self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).with_context(tracking_disable=True).create({ + 'name': 'Accrual allocation for employee', + 'accrual_plan_id': accrual_plan.id, + 'employee_id': self.employee_emp.id, + 'holiday_status_id': self.leave_type.id, + 'number_of_days': 0, + 'allocation_type': 'accrual', + }) + allocation.action_confirm() + allocation.action_validate() + next_date = datetime.date.today() + relativedelta(days=11) + second_level = self.env['hr.leave.accrual.level'].search([('accrual_plan_id', '=', accrual_plan.id), ('start_count', '=', 10)]) + self.assertEqual(allocation._get_current_accrual_plan_level_id(next_date), second_level, 'The second level should be selected') + + def test_unused_accrual_lost(self): + #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', + 'level_ids': [(0, 0, { + 'start_count': 1, + 'start_type': 'day', + 'added_value': 1, + 'added_value_type': 'days', + 'frequency': 'daily', + 'maximum_leave': 1, + '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': 10, + 'allocation_type': 'accrual', + }) + allocation.action_confirm() + allocation.action_validate() + + freezer = freeze_time('2022-01-01') + freezer.start() + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 0, 'There number of days should be reset') + freezer.stop() + + def test_unused_accrual_postponed(self): + # 1 accrual with 2 levels and level transition after + # This also tests retroactivity + 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', + })], + }) + 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() + + freezer = freeze_time('2022-01-01') + freezer.start() + allocation._update_accrual() + self.assertEqual(allocation.number_of_days, 25, 'The maximum number of days should be reached and kept.') + freezer.stop() diff --git a/addons/hr_holidays/tests/test_allocation_access_rights.py b/addons/hr_holidays/tests/test_allocation_access_rights.py index 2d4c973eafb..b68ffc200dc 100644 --- a/addons/hr_holidays/tests/test_allocation_access_rights.py +++ b/addons/hr_holidays/tests/test_allocation_access_rights.py @@ -4,6 +4,7 @@ from odoo import tests from odoo.addons.hr_holidays.tests.common import TestHrHolidaysCommon from odoo.exceptions import AccessError, UserError +import time @tests.tagged('access_rights', 'post_install', '-at_install') @@ -16,31 +17,33 @@ class TestAllocationRights(TestHrHolidaysCommon): self.employee_emp.parent_id = False self.employee_emp.leave_manager_id = False - self.lt_validation_hr = self.env['hr.leave.type'].create({ + self.lt_no_validation = self.env['hr.leave.type'].create({ 'name': 'Validation = HR', - 'allocation_validation_type': 'hr', - 'allocation_type': 'fixed_allocation', - 'validity_start': False, + 'allocation_validation_type': 'officer', + 'requires_allocation': 'no', + 'employee_requests': 'yes', }) self.lt_validation_manager = self.env['hr.leave.type'].create({ 'name': 'Validation = manager', - 'allocation_validation_type': 'manager', - 'allocation_type': 'fixed_allocation', - 'validity_start': False, + 'allocation_validation_type': 'officer', + 'requires_allocation': 'yes', + 'employee_requests': 'yes', }) - self.lt_validation_both = self.env['hr.leave.type'].create({ - 'name': 'Validation = both', - 'allocation_validation_type': 'both', - 'allocation_type': 'fixed_allocation', - 'validity_start': False, + self.lt_allocation_manager = self.env['hr.leave.type'].create({ + 'name': 'Validation = manager', + 'allocation_validation_type': 'set', + 'requires_allocation': 'yes', + 'employee_requests': 'no', }) def request_allocation(self, user, values={}): values = dict(values, **{ 'name': 'Allocation', 'number_of_days': 1, + 'date_from': time.strftime('%Y-01-01'), + 'date_to': time.strftime('%Y-12-31'), }) return self.env['hr.leave.allocation'].with_user(user).create(values) @@ -51,18 +54,17 @@ class TestAccessRightsSimpleUser(TestAllocationRights): """ A simple user can request an allocation but not approve it """ values = { 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_employee.id, values) with self.assertRaises(UserError): - allocation.action_approve() + allocation.action_validate() def test_simple_user_request_fixed_allocation(self): - """ A simple user cannot request an allocation if set by HR """ - self.lt_validation_hr.allocation_type = 'fixed' + """ A simple user cannot request an allocation if employee requests is not enabled """ values = { 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_allocation_manager.id, } with self.assertRaises(AccessError): self.request_allocation(self.user_employee.id, values) @@ -71,12 +73,14 @@ class TestAccessRightsSimpleUser(TestAllocationRights): """ A simple user can reset to draft only his own allocation """ values = { 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_employee.id, values) - self.assertEqual(allocation.state, 'confirm') + self.assertEqual(allocation.state, 'draft') + allocation.action_confirm() + self.assertEqual(allocation.state, 'confirm', "It should be confirmed") allocation.action_draft() - self.assertEqual(allocation.state, 'draft', "It should be reset to draft state") + self.assertEqual(allocation.state, 'draft', "It should have been reset to draft") class TestAccessRightsEmployeeManager(TestAllocationRights): @@ -104,7 +108,8 @@ class TestAccessRightsEmployeeManager(TestAllocationRights): 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_employee.id, values) - allocation.action_approve() + allocation.action_confirm() + allocation.action_validate() self.assertEqual(allocation.state, 'validate', "The allocation should be validated") def test_manager_refuse_request_allocation(self): @@ -114,6 +119,7 @@ class TestAccessRightsEmployeeManager(TestAllocationRights): 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_employee.id, values) + allocation.action_confirm() allocation.action_refuse() self.assertEqual(allocation.state, 'refuse', "The allocation should be validated") @@ -134,61 +140,37 @@ class TestAccessRightsEmployeeManager(TestAllocationRights): 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_employee.id, values) - with self.assertRaises(UserError): - allocation.action_approve() - - def test_manager_only_first_approval(self): - """ A manager can only do the first approval """ - values = { - 'employee_id': self.managed_employee.id, - 'holiday_status_id': self.lt_validation_both.id, - } - allocation = self.request_allocation(self.user_employee.id, values) - allocation.action_approve() with self.assertRaises(UserError): allocation.action_validate() - class TestAccessRightsHolidayUser(TestAllocationRights): def test_holiday_user_request_allocation(self): """ A holiday user can request and approve an allocation for any employee """ values = { 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_hruser.id, values) - allocation.action_approve() + allocation.action_confirm() + allocation.action_validate() self.assertEqual(allocation.state, 'validate', "It should have been validated") def test_holiday_user_request_fixed_allocation(self): """ A holiday user can request and approve an allocation if set by HR """ - self.lt_validation_hr.allocation_type = 'fixed' values = { 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_allocation_manager.id, } allocation = self.request_allocation(self.user_hruser.id, values) - allocation.action_approve() - self.assertEqual(allocation.state, 'validate', "It should have been validated") - - def test_holiday_user_both_second_approval(self): - """ A holiday user can only do the second approval when double validation """ - values = { - 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_both.id, - } - allocation = self.request_allocation(self.user_hruser.id, values) - with self.assertRaises(UserError): - allocation.action_approve() - allocation.sudo().action_approve() # First approval by someone else + allocation.action_confirm() allocation.action_validate() self.assertEqual(allocation.state, 'validate', "It should have been validated") def test_holiday_user_batch_allocation(self): """ A holiday user cannot create a batch allocation """ values = { - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_validation_manager.id, 'holiday_type': 'company', 'mode_company_id': self.user_employee.company_id.id, } @@ -199,11 +181,12 @@ class TestAccessRightsHolidayUser(TestAllocationRights): """ A holiday user cannot approve his own allocation """ values = { 'employee_id': self.employee_hruser.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_hruser.id, values) + allocation.action_confirm() with self.assertRaises(UserError): - allocation.action_approve() + allocation.action_validate() class TestAccessRightsHolidayManager(TestAllocationRights): @@ -212,32 +195,22 @@ class TestAccessRightsHolidayManager(TestAllocationRights): """ A holiday manager can approve his own allocation """ values = { 'employee_id': self.employee_hrmanager.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_hrmanager.id, values) - allocation.action_approve() - self.assertEqual(allocation.state, 'validate', "It should have been validated") - - def test_holiday_manager_both_validation(self): - """ A holiday manager can perform both validation """ - values = { - 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_both.id, - } - allocation = self.request_allocation(self.user_hrmanager.id, values) - allocation.action_approve() - self.assertEqual(allocation.state, 'validate1', "It should have been validated one time") + allocation.action_confirm() allocation.action_validate() - self.assertEqual(allocation.state, 'validate', "It should have been completely validated") + self.assertEqual(allocation.state, 'validate', "It should have been validated") def test_holiday_manager_refuse_validated(self): """ A holiday manager can refuse a validated allocation """ values = { 'employee_id': self.employee_emp.id, - 'holiday_status_id': self.lt_validation_hr.id, + 'holiday_status_id': self.lt_validation_manager.id, } allocation = self.request_allocation(self.user_hrmanager.id, values) - allocation.action_approve() + allocation.action_confirm() + allocation.action_validate() self.assertEqual(allocation.state, 'validate', "It should have been validated") allocation.action_refuse() self.assertEqual(allocation.state, 'refuse', "It should have been refused") diff --git a/addons/hr_holidays/tests/test_automatic_leave_dates.py b/addons/hr_holidays/tests/test_automatic_leave_dates.py index d3774e0974f..17089dfe5e9 100644 --- a/addons/hr_holidays/tests/test_automatic_leave_dates.py +++ b/addons/hr_holidays/tests/test_automatic_leave_dates.py @@ -14,8 +14,7 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): self.leave_type = self.env['hr.leave.type'].create({ 'name': 'Automatic Test', 'time_type': 'leave', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) def test_no_attendances(self): diff --git a/addons/hr_holidays/tests/test_change_department.py b/addons/hr_holidays/tests/test_change_department.py index 83ebb1581ee..b68dd4804a5 100644 --- a/addons/hr_holidays/tests/test_change_department.py +++ b/addons/hr_holidays/tests/test_change_department.py @@ -14,8 +14,7 @@ class TestChangeDepartment(TestHrHolidaysCommon): HolidayStatusManagerGroup = self.env['hr.leave.type'].with_user(self.user_hrmanager_id) self.holidays_status_1 = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedHR', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) def create_holiday(name, start, end): diff --git a/addons/hr_holidays/tests/test_company_leave.py b/addons/hr_holidays/tests/test_company_leave.py index b5979dc19ab..02919e7509b 100644 --- a/addons/hr_holidays/tests/test_company_leave.py +++ b/addons/hr_holidays/tests/test_company_leave.py @@ -20,6 +20,7 @@ class TestCompanyLeave(TransactionCase): 'name': 'Bank Holiday', 'responsible_id': cls.env.user.id, 'company_id': cls.company.id, + 'requires_allocation': 'no', }) cls.paid_time_off = cls.env['hr.leave.type'].create({ @@ -27,6 +28,7 @@ class TestCompanyLeave(TransactionCase): 'request_unit': 'day', 'leave_validation_type': 'both', 'company_id': cls.company.id, + 'requires_allocation': 'no', }) cls.employee = cls.env['hr.employee'].create({ @@ -313,7 +315,7 @@ class TestCompanyLeave(TransactionCase): }) company_leave._compute_date_from_to() - with self.assertQueryCount(__system__=845, admin=865): + with self.assertQueryCount(__system__=846, admin=865): # Original query count: 1987 # Without tracking/activity context keys: 5154 company_leave.action_validate() diff --git a/addons/hr_holidays/tests/test_holidays_flow.py b/addons/hr_holidays/tests/test_holidays_flow.py index b63c4486a5d..987ef2084c7 100644 --- a/addons/hr_holidays/tests/test_holidays_flow.py +++ b/addons/hr_holidays/tests/test_holidays_flow.py @@ -25,19 +25,17 @@ class TestHolidaysFlow(TestHrHolidaysCommon): HolidayStatusManagerGroup = HolidaysStatus.with_user(self.user_hrmanager_id) HolidayStatusManagerGroup.create({ 'name': 'WithMeetingType', - 'allocation_type': 'no', + 'requires_allocation': 'no', }) self.holidays_status_hr = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedHR', - 'allocation_type': 'no', + 'requires_allocation': 'no', 'leave_validation_type': 'hr', - 'validity_start': False, }) self.holidays_status_manager = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedManager', - 'allocation_type': 'no', + 'requires_allocation': 'no', 'leave_validation_type': 'manager', - 'validity_start': False, }) HolidaysEmployeeGroup = Requests.with_user(self.user_employee_id) @@ -86,9 +84,10 @@ class TestHolidaysFlow(TestHrHolidaysCommon): holiday_status_paid_time_off = self.env['hr.leave.type'].create({ 'name': 'Paid Time Off', - 'allocation_type': 'fixed', + 'requires_allocation': 'yes', + 'employee_requests': 'no', + 'allocation_validation_type': 'set', 'leave_validation_type': 'both', - 'validity_start': time.strftime('%Y-%m-01'), 'responsible_id': self.env.ref('base.user_admin').id, }) @@ -99,12 +98,16 @@ class TestHolidaysFlow(TestHrHolidaysCommon): 'number_of_days': 20, 'employee_id': self.employee_emp_id, 'state': 'validate', + 'date_from': time.strftime('%Y-%m-01'), + 'date_to': time.strftime('%Y-12-31'), }, { 'name': 'Paid Time off for David', 'holiday_status_id': holiday_status_paid_time_off.id, 'number_of_days': 20, 'employee_id': self.ref('hr.employee_admin'), 'state': 'validate', + 'date_from': time.strftime('%Y-%m-01'), + 'date_to': time.strftime('%Y-12-31'), } ]) @@ -122,16 +125,15 @@ class TestHolidaysFlow(TestHrHolidaysCommon): HolidayStatusManagerGroup = HolidaysStatus.with_user(self.user_hrmanager_id) HolidayStatusManagerGroup.create({ 'name': 'WithMeetingType', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) self.holidays_status_limited = HolidayStatusManagerGroup.create({ 'name': 'Limited', - 'allocation_type': 'fixed', - 'allocation_validation_type': 'both', + 'requires_allocation': 'yes', + 'employee_requests': 'no', + 'allocation_validation_type': 'set', 'leave_validation_type': 'both', - 'validity_start': False, }) HolidaysEmployeeGroup = Requests.with_user(self.user_employee_id) @@ -141,9 +143,11 @@ class TestHolidaysFlow(TestHrHolidaysCommon): 'employee_id': self.employee_emp_id, 'holiday_status_id': self.holidays_status_limited.id, 'number_of_days': 2, + 'state': 'confirm', + 'date_from': time.strftime('%Y-%m-01'), + 'date_to': time.strftime('%Y-12-31'), }) # HrUser validates the first step - aloc1_user_group.action_approve() # HrManager validates the second step aloc1_user_group.with_user(self.user_hrmanager_id).action_validate() @@ -165,11 +169,6 @@ class TestHolidaysFlow(TestHrHolidaysCommon): hol_status_2_employee_group.invalidate_cache() _check_holidays_status(hol_status_2_employee_group, 2.0, 0.0, 2.0, 1.0) - # HrManager validates the first step - hol2_user_group.with_user(self.user_hrmanager_id).action_approve() - self.assertEqual(hol2.state, 'validate1', - 'hr_holidays: first validation should lead to validate1 state') - # HrManager validates the second step hol2_user_group.with_user(self.user_hrmanager_id).action_validate() self.assertEqual(hol2.state, 'validate', @@ -216,7 +215,6 @@ class TestHolidaysFlow(TestHrHolidaysCommon): hol3.action_confirm() self.assertEqual(hol3.state, 'confirm', 'hr_holidays: confirming should lead to confirm state') # I validate the holiday request by clicking on "To Approve" button. - hol3.action_approve() hol3.action_validate() self.assertEqual(hol3.state, 'validate', 'hr_holidays: validation should lead to validate state') # Check left days for casual leave: 19 days left @@ -243,9 +241,10 @@ class TestHolidaysFlow(TestHrHolidaysCommon): holiday_status_paid_time_off = self.env['hr.leave.type'].create({ 'name': 'Paid Time Off', - 'allocation_type': 'fixed', + 'requires_allocation': 'yes', + 'employee_requests': 'no', + 'allocation_validation_type': 'set', 'leave_validation_type': 'both', - 'validity_start': time.strftime('%Y-%m-01'), 'responsible_id': self.env.ref('base.user_admin').id, }) @@ -255,6 +254,8 @@ class TestHolidaysFlow(TestHrHolidaysCommon): 'number_of_days': 20, 'employee_id': self.ref('hr.employee_admin'), 'state': 'validate', + 'date_from': time.strftime('%Y-%m-01'), + 'date_to': time.strftime('%Y-12-31'), }) leave_vals = { diff --git a/addons/hr_holidays/tests/test_hr_leave_type.py b/addons/hr_holidays/tests/test_hr_leave_type.py index 852603a2d06..6a35e412ed0 100644 --- a/addons/hr_holidays/tests/test_hr_leave_type.py +++ b/addons/hr_holidays/tests/test_hr_leave_type.py @@ -15,8 +15,7 @@ class TestHrLeaveType(TestHrHolidaysCommon): leave_type = self.env['hr.leave.type'].create({ 'name': 'Paid Time Off', 'time_type': 'leave', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) leave_1 = self.env['hr.leave'].create({ @@ -39,5 +38,5 @@ class TestHrLeaveType(TestHrHolidaysCommon): with self.assertRaises(AccessError): self.env['hr.leave.type'].with_user(self.user_hruser_id).create({ 'name': 'UserCheats', - 'allocation_type': 'no', + 'requires_allocation': 'no', }) diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py index c0c4f3ea79f..7c3be743969 100644 --- a/addons/hr_holidays/tests/test_leave_requests.py +++ b/addons/hr_holidays/tests/test_leave_requests.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime, date +import time from dateutil.relativedelta import relativedelta from pytz import timezone, UTC @@ -34,22 +35,19 @@ class TestLeaveRequests(TestHrHolidaysCommon): self.holidays_type_1 = LeaveType.create({ 'name': 'NotLimitedHR', - 'allocation_type': 'no', + 'requires_allocation': 'no', 'leave_validation_type': 'hr', - 'validity_start': False, }) self.holidays_type_2 = LeaveType.create({ 'name': 'Limited', - 'allocation_type': 'fixed_allocation', + 'requires_allocation': 'yes', + 'employee_requests': 'yes', 'leave_validation_type': 'hr', - 'validity_start': False, }) self.holidays_type_3 = LeaveType.create({ 'name': 'TimeNotLimited', - 'allocation_type': 'no', + 'requires_allocation': 'no', 'leave_validation_type': 'manager', - 'validity_start': fields.Datetime.from_string('2017-01-01 00:00:00'), - 'validity_stop': fields.Datetime.from_string('2017-06-01 00:00:00'), }) self.set_employee_create_date(self.employee_emp_id, '2010-02-03 00:00:00') @@ -111,8 +109,11 @@ class TestLeaveRequests(TestHrHolidaysCommon): 'employee_id': self.employee_emp_id, 'holiday_status_id': self.holidays_type_2.id, 'number_of_days': 2, + 'state': 'confirm', + 'date_from': time.strftime('%Y-1-1'), + 'date_to': time.strftime('%Y-12-31'), }) - aloc1_user_group.action_approve() + aloc1_user_group.action_validate() holiday_status = self.holidays_type_2.with_user(self.user_employee_id) self._check_holidays_status(holiday_status, 2.0, 0.0, 2.0, 2.0) @@ -137,10 +138,21 @@ class TestLeaveRequests(TestHrHolidaysCommon): @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_accrual_validity_time_valid(self): """ Employee ask leave during a valid validity time """ + + self.env['hr.leave.allocation'].with_user(self.user_hrmanager_id).create({ + 'name': 'Sick Time Off', + 'holiday_status_id': self.holidays_type_2.id, + 'employee_id': self.employee_emp.id, + 'date_from': fields.Datetime.from_string('2017-01-01 00:00:00'), + 'date_to': fields.Datetime.from_string('2017-06-01 00:00:00'), + 'number_of_days': 10, + 'state': 'validate', + }) + self.env['hr.leave'].with_user(self.user_employee_id).create({ 'name': 'Valid time period', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_type_3.id, + 'holiday_status_id': self.holidays_type_2.id, 'date_from': fields.Datetime.from_string('2017-03-03 06:00:00'), 'date_to': fields.Datetime.from_string('2017-03-11 19:00:00'), 'number_of_days': 1, @@ -148,12 +160,12 @@ class TestLeaveRequests(TestHrHolidaysCommon): @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_accrual_validity_time_not_valid(self): - """ Employee ask leav during a not valid validity time """ + """ Employee ask leave when there's no valid allocation """ with self.assertRaises(ValidationError): - self.env['hr.leave'].with_user(self.user_employee_id).create({ + self.env['hr.leave'].with_user(self.user_employee).create({ 'name': 'Sick Time Off', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_type_3.id, + 'holiday_status_id': self.holidays_type_2.id, 'date_from': fields.Datetime.from_string('2017-07-03 06:00:00'), 'date_to': fields.Datetime.from_string('2017-07-11 19:00:00'), 'number_of_days': 1, @@ -182,6 +194,8 @@ class TestLeaveRequests(TestHrHolidaysCommon): allocation_form = Form(self.env['hr.leave.allocation'].with_user(self.user_employee)) allocation_form.name = 'New Allocation Request' allocation_form.holiday_status_id = self.holidays_type_2 + allocation_form.date_from = date(2019, 5, 6) + allocation_form.date_to = date(2019, 5, 6) allocation = allocation_form.save() @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') @@ -307,8 +321,11 @@ class TestLeaveRequests(TestHrHolidaysCommon): 'holiday_status_id': leave_type.id, 'number_of_days': 20, 'employee_id': employee.id, + 'state': 'confirm', + 'date_from': time.strftime('2018-1-1'), + 'date_to': time.strftime('%Y-1-1'), }) - allocation.action_approve() + allocation.action_validate() leave1 = self.env['hr.leave'].create({ 'name': 'Holiday 1 week', @@ -374,7 +391,7 @@ class TestLeaveRequests(TestHrHolidaysCommon): 'name': 'Sick', 'request_unit': 'hour', 'leave_validation_type': 'both', - 'allocation_type': 'no', + 'requires_allocation': 'no', }) leave1 = self.env['hr.leave'].create({ 'name': 'Sick 1 week during christmas snif', @@ -428,13 +445,13 @@ class TestLeaveRequests(TestHrHolidaysCommon): # 12 13 14 15 16 17 18 # 19 20 21 22 23 24 25 # 26 27 28 29 30 31 - local_date_from = datetime(2019, 1, 1, 7, 0, 0) - local_date_to = datetime(2019, 1, 1, 19, 0, 0) + local_date_from = datetime(2020, 1, 1, 7, 0, 0) + local_date_to = datetime(2020, 1, 1, 19, 0, 0) for tz in timezones_to_test: self._test_leave_with_tz(tz, local_date_from, local_date_to, 1) # We, Th, Fr, Mo, Tu, We => 6 days - local_date_from = datetime(2019, 1, 1, 7, 0, 0) - local_date_to = datetime(2019, 1, 8, 19, 0, 0) + local_date_from = datetime(2020, 1, 1, 7, 0, 0) + local_date_to = datetime(2020, 1, 8, 19, 0, 0) for tz in timezones_to_test: - self._test_leave_with_tz(tz, local_date_from, local_date_to, 6) + self._test_leave_with_tz(tz, local_date_from, local_date_to, 6)#TODO JUD check why this fails diff --git a/addons/hr_holidays/tests/test_out_of_office.py b/addons/hr_holidays/tests/test_out_of_office.py index 42301a085a5..a71fd55b3fc 100644 --- a/addons/hr_holidays/tests/test_out_of_office.py +++ b/addons/hr_holidays/tests/test_out_of_office.py @@ -18,8 +18,7 @@ class TestOutOfOffice(TestHrHolidaysCommon): self.leave_type = self.env['hr.leave.type'].create({ 'name': 'Legal Leaves', 'time_type': 'leave', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) def test_leave_ooo(self): @@ -66,8 +65,7 @@ class TestOutOfOfficePerformance(TestHrHolidaysCommon, TransactionCaseWithUserDe self.leave_type = self.env['hr.leave.type'].create({ 'name': 'Legal Leaves', 'time_type': 'leave', - 'allocation_type': 'no', - 'validity_start': False, + 'requires_allocation': 'no', }) self.leave_date_end = (datetime.today() + relativedelta(days=3)) self.leave = self.env['hr.leave'].create({ diff --git a/addons/hr_holidays/views/hr_holidays_views.xml b/addons/hr_holidays/views/hr_holidays_views.xml index 7ff814f70b5..46292c6724d 100644 --- a/addons/hr_holidays/views/hr_holidays_views.xml +++ b/addons/hr_holidays/views/hr_holidays_views.xml @@ -28,21 +28,21 @@ @@ -65,7 +65,7 @@ id="menu_hr_holidays_report" name="Reporting" parent="menu_hr_holidays_root" - groups="hr_holidays.group_hr_holidays_manager" + groups="hr_holidays.group_hr_holidays_user" sequence="4"/> + + + + + + + + + hr.leave.accrual.level.form + hr.leave.accrual.level + +
+ + + + + +
+
+
+ + hr.leave.accrual.plan.tree + hr.leave.accrual.plan + + + + + + + + + + hr.leave.accrual.plan.form + hr.leave.accrual.plan + +
+ +
+ +
+ + + + + + + + + + + + + + +
+
Rules
+
+

No rules added

+

+ Click on the 'Edit' button to add new rules. +

+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ Level +
+ + + + + + + + +
+
+ +
+
+
+ + Starts after allocation start date + + + Starts immediately after allocation start date + +
+
+ Adds + + + on + + + on the and on the + + + on the and on the + + + on + +
+
+ Limit of +
+
+ At the end of the year, unused accruals will be postponedlost +
+
+
+
+
+
+
+
+
+
+
+
+ + + Accrual Plans + ir.actions.act_window + hr.leave.accrual.plan + tree,form + + +
diff --git a/addons/hr_holidays/views/hr_leave_allocation_views.xml b/addons/hr_holidays/views/hr_leave_allocation_views.xml index 1ece8001a32..b4229d1a4e3 100644 --- a/addons/hr_holidays/views/hr_leave_allocation_views.xml +++ b/addons/hr_holidays/views/hr_leave_allocation_views.xml @@ -9,8 +9,7 @@ - - + @@ -41,11 +40,13 @@ ]"/> - + + + @@ -70,9 +71,8 @@ class="oe_stat_button" icon="fa-calendar" type="action" - attrs="{'invisible': [('holiday_type', '!=', 'employee')]}" - name="%(hr_leave_action_action_approve_department)d" - context="{'search_default_employee_id': [employee_id], 'search_default_holiday_status_id': [holiday_status_id], 'default_holiday_status_id': holiday_status_id}" + attrs="{'invisible': ['|', '|', ('id', '=', False), ('holiday_type', '!=', 'employee'), ('employee_id', '=', False)]}" + name="%(hr_leave_action_holiday_allocation_id)d" help="Time off Taken/Total Allocated">
@@ -92,19 +92,31 @@ - + + + +
+
+
+ + +
-
-
+ attrs="{'readonly': ['|', '|', ('type_request_unit', '=', 'hour'), ('state', 'not in', ('draft', 'confirm')), ('allocation_type', '=', 'accrual')], 'invisible': [('type_request_unit', '=', 'hour')]}"/> + attrs="{'readonly': ['|', '|', ('type_request_unit', '!=', 'hour'), ('state', 'not in', ('draft', 'confirm')), ('allocation_type', '=', 'accrual')], 'invisible': [('type_request_unit', '!=', 'hour')]}"/> Days Hours
@@ -133,11 +145,9 @@ 16 -
- - -
@@ -438,29 +461,6 @@ list code - action = records.action_approve() + action = records.action_validate() - - - hr.leave.view.form.dashboard - hr.leave.allocation - - primary - 100 - - - 1 - -
- 1 -
- -
-
-
-
-
- diff --git a/addons/hr_holidays/views/hr_leave_type_views.xml b/addons/hr_holidays/views/hr_leave_type_views.xml index 1ba99173d09..e5d61e974c3 100644 --- a/addons/hr_holidays/views/hr_leave_type_views.xml +++ b/addons/hr_holidays/views/hr_leave_type_views.xml @@ -7,7 +7,6 @@ - @@ -22,16 +21,22 @@
- - +
@@ -40,42 +45,33 @@

- + +

Time Off Requests

- - -
- - - - - - - + + 'invisible': [('leave_validation_type', 'in', ['no_validation', 'manager']), '|', ('requires_allocation', '=', 'no'), ('allocation_validation_type', '=', 'officer')]}"/> + - - - - - - - - - - - - - + +

Allocation Requests

+ + + +
+
+ + + + + +

Visual

+ + +
@@ -111,12 +107,9 @@ - - - - - - + + +
diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index 9efb92b4467..ee551f354b8 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -1,17 +1,6 @@ - - Report to Payslip - - - code - - if records: - records.write({'payslip_status': True}) - - - Manager Approval @@ -64,13 +53,12 @@ - - - - + @@ -83,11 +71,13 @@ + + @@ -106,6 +96,7 @@ +
@@ -150,7 +141,7 @@
-
+
@@ -161,6 +152,11 @@ Refuse
+
+ +
@@ -205,6 +201,7 @@
+
-