diff --git a/addons/hr_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml index c2657a02958..6f19eb002b0 100644 --- a/addons/hr_contract/views/hr_contract_views.xml +++ b/addons/hr_contract/views/hr_contract_views.xml @@ -303,12 +303,4 @@ parent="hr.menu_human_resources_configuration" sequence="3"/> - - diff --git a/addons/hr_work_entry_contract/__init__.py b/addons/hr_work_entry_contract/__init__.py new file mode 100644 index 00000000000..cab77a8991a --- /dev/null +++ b/addons/hr_work_entry_contract/__init__.py @@ -0,0 +1,5 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/addons/hr_work_entry_contract/__manifest__.py b/addons/hr_work_entry_contract/__manifest__.py new file mode 100644 index 00000000000..ebf60f36bef --- /dev/null +++ b/addons/hr_work_entry_contract/__manifest__.py @@ -0,0 +1,29 @@ +#-*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Work Entries - Contract', + 'category': 'Human Resources/Employees', + 'sequence': 39, + 'summary': 'Manage work entries', + 'description': "", + 'installable': True, + 'depends': [ + 'hr_work_entry', + 'hr_contract', + ], + 'data': [ + 'security/hr_work_entry_security.xml', + 'security/ir.model.access.csv', + 'wizard/create_company_global_time_off_views.xml', + 'data/hr_work_entry_data.xml', + 'views/hr_work_entry_template.xml', + 'views/hr_work_entry_views.xml', + 'views/resource_views.xml', + 'wizard/hr_work_entry_regeneration_wizard_views.xml', + ], + 'demo': [ + 'data/hr_work_entry_demo.xml', + ], + +} diff --git a/addons/hr_work_entry_contract/data/hr_work_entry_data.xml b/addons/hr_work_entry_contract/data/hr_work_entry_data.xml new file mode 100644 index 00000000000..de99639dc5c --- /dev/null +++ b/addons/hr_work_entry_contract/data/hr_work_entry_data.xml @@ -0,0 +1,53 @@ + + + + + + + Generic Time Off + LEAVE100 + 3 + True + + + + Compensatory Time Off + LEAVE105 + 3 + True + + + + Home Working + WORK110 + 2 + True + + + + Unpaid + 5 + LEAVE90 + True + + + + Sick Time Off + LEAVE110 + True + 5 + + + + + LEAVE120 + True + 5 + + + + False + + + + diff --git a/addons/hr_work_entry_contract/data/hr_work_entry_demo.xml b/addons/hr_work_entry_contract/data/hr_work_entry_demo.xml new file mode 100644 index 00000000000..6e87a077ba5 --- /dev/null +++ b/addons/hr_work_entry_contract/data/hr_work_entry_demo.xml @@ -0,0 +1,18 @@ + + + + + + Extra Hours + WORK300 + 8 + + + + Long Term Time Off + LEAVE200 + True + 4 + + + diff --git a/addons/hr_work_entry_contract/i18n/hr_work_entry_contract.pot b/addons/hr_work_entry_contract/i18n/hr_work_entry_contract.pot new file mode 100644 index 00000000000..2b420a947ea --- /dev/null +++ b/addons/hr_work_entry_contract/i18n/hr_work_entry_contract.pot @@ -0,0 +1,350 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_work_entry_contract +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server saas~14.1+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-01-12 07:57+0000\n" +"PO-Revision-Date: 2021-01-12 07:57+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: hr_work_entry_contract +#: code:addons/hr_work_entry_contract/models/hr_work_entry.py:0 +#, python-format +msgid "%s does not have a contract from %s to %s." +msgstr "" + +#. module: hr_work_entry_contract +#: code:addons/hr_work_entry_contract/models/hr_work_entry.py:0 +#, python-format +msgid "" +"%s has multiple contracts from %s to %s. A work entry cannot overlap " +"multiple contracts." +msgstr "" + +#. module: hr_work_entry_contract +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.hr_work_entry_regeneration_wizard +msgid "" +"You are not " +"allowed to regenerate validated work entries" +msgstr "" + +#. module: hr_work_entry_contract +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.hr_work_entry_regeneration_wizard +msgid "" +msgstr "" + +#. module: hr_work_entry_contract +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.create_company_global_time_off_view_form +msgid " to " +msgstr "" + +#. module: hr_work_entry_contract +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.create_company_global_time_off_view_form +msgid "Cancel" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__company_id +msgid "Company" +msgstr "" + +#. module: hr_work_entry_contract +#: model:hr.work.entry.type,name:hr_work_entry_contract.work_entry_type_compensatory +msgid "Compensatory Time Off" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_hr_payroll_configuration +msgid "Configuration" +msgstr "" + +#. module: hr_work_entry_contract +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.create_company_global_time_off_view_form +msgid "Confirm" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_work_entry_conflicts +msgid "Conflicts" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry__contract_id +msgid "Contract" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.actions.act_window,name:hr_work_entry_contract.action_create_company_global_time_off +#: model:ir.model,name:hr_work_entry_contract.model_create_company_global_time_off +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.create_company_global_time_off_view_form +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.resource_calendar_view_form +msgid "Create Company Public Time Off" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__create_uid +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__create_date +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__display_name +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_contract__display_name +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_employee__display_name +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry__display_name +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__display_name +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_type__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__earliest_available_date_message +msgid "Earliest Available Date Message" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__earliest_available_date +msgid "Earliest date" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model,name:hr_work_entry_contract.model_hr_employee +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry__employee_id +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__employee_id +msgid "Employee" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model,name:hr_work_entry_contract.model_hr_contract +msgid "Employee Contract" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__date_to +msgid "End Date" +msgstr "" + +#. module: hr_work_entry_contract +#. openerp-web +#: code:addons/hr_work_entry_contract/static/src/js/work_entries_calendar.js:0 +#, python-format +msgid "Everybody's work entries" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__date_from +msgid "From" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_contract__date_generated_from +msgid "Generated From" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_contract__date_generated_to +msgid "Generated To" +msgstr "" + +#. module: hr_work_entry_contract +#: model:hr.work.entry.type,name:hr_work_entry_contract.work_entry_type_leave +msgid "Generic Time Off" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model,name:hr_work_entry_contract.model_hr_work_entry +msgid "HR Work Entry" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model,name:hr_work_entry_contract.model_hr_work_entry_type +msgid "HR Work Entry Type" +msgstr "" + +#. module: hr_work_entry_contract +#: model:hr.work.entry.type,name:hr_work_entry_contract.work_entry_type_home_working +msgid "Home Working" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__id +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_contract__id +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_employee__id +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry__id +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__id +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_type__id +msgid "ID" +msgstr "" + +#. module: hr_work_entry_contract +#: code:addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py:0 +#, python-format +msgid "" +"In order to regenerate the work entries, you need to provide the wizard with" +" an employee_id, a date_from and a date_to. In addition to that, the time " +"interval defined by date_from and date_to must not contain any validated " +"work entries." +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off____last_update +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_contract____last_update +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_employee____last_update +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry____last_update +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard____last_update +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_type____last_update +msgid "Last Modified on" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__write_uid +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__write_date +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__latest_available_date_message +msgid "Latest Available Date Message" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__latest_available_date +msgid "Latest date" +msgstr "" + +#. module: hr_work_entry_contract +#: model:hr.work.entry.type,name:hr_work_entry_contract.work_entry_type_legal_leave +msgid "Legal Leaves 2021" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_hr_payroll_root +msgid "Payroll" +msgstr "" + +#. module: hr_work_entry_contract +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.create_company_global_time_off_view_form +msgid "Period" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__name +msgid "Reason" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model,name:hr_work_entry_contract.model_hr_work_entry_regeneration_wizard +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.hr_work_entry_regeneration_wizard +msgid "Regenerate Employee Work Entries" +msgstr "" + +#. module: hr_work_entry_contract +#. openerp-web +#: code:addons/hr_work_entry_contract/static/src/js/work_entries_controller_mixin.js:0 +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.hr_work_entry_regeneration_wizard +#, python-format +msgid "Regenerate Work Entries" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__search_criteria_completed +msgid "Search Criteria Completed" +msgstr "" + +#. module: hr_work_entry_contract +#: model:hr.work.entry.type,name:hr_work_entry_contract.work_entry_type_sick_leave +msgid "Sick Time Off" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__date_from +msgid "Start Date" +msgstr "" + +#. module: hr_work_entry_contract +#: code:addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py:0 +#, python-format +msgid "" +"The from date must be >= '%(earliest_available_date)s' and the to date must " +"be <= '%(latest_available_date)s', which correspond to the generated work " +"entries time interval." +msgstr "" + +#. module: hr_work_entry_contract +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.hr_work_entry_contract_view_form_inherit +msgid "This work entry cannot be validated. The work entry type is undefined." +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_type__is_leave +msgid "Time Off" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__date_to +msgid "To" +msgstr "" + +#. module: hr_work_entry_contract +#: model:hr.work.entry.type,name:hr_work_entry_contract.work_entry_type_unpaid_leave +msgid "Unpaid" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__valid +msgid "Valid" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_hr_payroll_work_entries_root +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_hr_work_entry_confirguration +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_work_entry +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.hr_work_entry_gantt +#: model_terms:ir.ui.view,arch_db:hr_work_entry_contract.hr_work_entry_regeneration_wizard +msgid "Work Entries" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_hr_work_entry_regeneration_wizard__validated_work_entry_ids +msgid "Work Entries Within Interval" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.actions.act_window,name:hr_work_entry_contract.hr_work_entry_regeneration_wizard_action +msgid "Work Entry Regeneration" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.model.fields,field_description:hr_work_entry_contract.field_create_company_global_time_off__work_entry_type_id +msgid "Work Entry Type" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_hr_work_entry_type_view +msgid "Work Entry Types" +msgstr "" + +#. module: hr_work_entry_contract +#: model:ir.ui.menu,name:hr_work_entry_contract.menu_resource_calendar_view +msgid "Working Times" +msgstr "" diff --git a/addons/hr_work_entry_contract/models/__init__.py b/addons/hr_work_entry_contract/models/__init__.py new file mode 100644 index 00000000000..314239dad3e --- /dev/null +++ b/addons/hr_work_entry_contract/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_contract +from . import hr_employee +from . import hr_work_entry +from . import hr_work_intervals diff --git a/addons/hr_work_entry_contract/models/hr_contract.py b/addons/hr_work_entry_contract/models/hr_contract.py new file mode 100644 index 00000000000..7b733ef283b --- /dev/null +++ b/addons/hr_work_entry_contract/models/hr_contract.py @@ -0,0 +1,254 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +from datetime import datetime +from odoo import fields, models +from odoo.addons.resource.models.resource import datetime_to_string, string_to_datetime, Intervals +from odoo.osv import expression + +import pytz + +class HrContract(models.Model): + _inherit = 'hr.contract' + _description = 'Employee Contract' + + date_generated_from = fields.Datetime(string='Generated From', readonly=True, required=True, + default=lambda self: datetime.now().replace(hour=0, minute=0, second=0), copy=False) + date_generated_to = fields.Datetime(string='Generated To', readonly=True, required=True, + default=lambda self: datetime.now().replace(hour=0, minute=0, second=0), copy=False) + + def _get_default_work_entry_type(self): + return self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False) + + def _get_leave_work_entry_type_dates(self, leave, date_from, date_to): + return self._get_leave_work_entry_type(leave) + + def _get_leave_work_entry_type(self, leave): + return leave.work_entry_type_id + + # Is used to add more values, for example leave_id (in hr_work_entry_holidays) + def _get_more_vals_leave_interval(self, interval, leaves): + return [] + + def _get_interval_leave_work_entry_type(self, interval, leaves): + # returns the work entry time related to the leave that + # includes the whole interval. + # Overriden in hr_work_entry_contract_holiday to select the + # global time off first (eg: Public Holiday > Home Working) + for leave in leaves: + if interval[0] >= leave[0] and interval[1] <= leave[1] and leave[2]: + interval_start = interval[0].astimezone(pytz.utc).replace(tzinfo=None) + interval_stop = interval[1].astimezone(pytz.utc).replace(tzinfo=None) + return self._get_leave_work_entry_type_dates(leave[2], interval_start, interval_stop) + return self.env.ref('hr_work_entry_contract.work_entry_type_leave') + + def _get_contract_work_entries_values(self, date_start, date_stop): + self.ensure_one() + contract_vals = [] + employee = self.employee_id + calendar = self.resource_calendar_id + resource = employee.resource_id + tz = pytz.timezone(calendar.tz) + start_dt = pytz.utc.localize(date_start) if not date_start.tzinfo else date_start + end_dt = pytz.utc.localize(date_stop) if not date_stop.tzinfo else date_stop + + attendances = calendar._attendance_intervals_batch( + start_dt, end_dt, resources=resource, tz=tz + )[resource.id] + + # Other calendars: In case the employee has declared time off in another calendar + # Example: Take a time off, then a credit time. + # YTI TODO: This mimics the behavior of _leave_intervals_batch, while waiting to be cleaned + # in master. + resources_list = [self.env['resource.resource'], resource] + resource_ids = [False, resource.id] + leave_domain = [ + ('time_type', '=', 'leave'), + # ('calendar_id', '=', self.id), --> Get all the time offs + ('resource_id', 'in', resource_ids), + ('date_from', '<=', datetime_to_string(end_dt)), + ('date_to', '>=', datetime_to_string(start_dt)), + ('company_id', '=', self.env.company.id), + ] + result = defaultdict(lambda: []) + tz_dates = {} + for leave in self.env['resource.calendar.leaves'].search(leave_domain): + for resource in resources_list: + if leave.resource_id.id not in [False, resource.id]: + continue + tz = tz if tz else pytz.timezone((resource or self).tz) + if (tz, start_dt) in tz_dates: + start = tz_dates[(tz, start_dt)] + else: + start = start_dt.astimezone(tz) + tz_dates[(tz, start_dt)] = start + if (tz, end_dt) in tz_dates: + end = tz_dates[(tz, end_dt)] + else: + end = end_dt.astimezone(tz) + tz_dates[(tz, end_dt)] = end + dt0 = string_to_datetime(leave.date_from).astimezone(tz) + dt1 = string_to_datetime(leave.date_to).astimezone(tz) + result[resource.id].append((max(start, dt0), min(end, dt1), leave)) + mapped_leaves = {r.id: Intervals(result[r.id]) for r in resources_list} + leaves = mapped_leaves[resource.id] + + real_attendances = attendances - leaves + real_leaves = attendances - real_attendances + + # A leave period can be linked to several resource.calendar.leave + split_leaves = [] + for leave_interval in leaves: + if leave_interval[2] and len(leave_interval[2]) > 1: + split_leaves += [(leave_interval[0], leave_interval[1], l) for l in leave_interval[2]] + else: + split_leaves += [(leave_interval[0], leave_interval[1], leave_interval[2])] + leaves = split_leaves + + # Attendances + default_work_entry_type = self._get_default_work_entry_type() + for interval in real_attendances: + work_entry_type_id = interval[2].mapped('work_entry_type_id')[:1] or default_work_entry_type + # All benefits generated here are using datetimes converted from the employee's timezone + contract_vals += [{ + 'name': "%s: %s" % (work_entry_type_id.name, employee.name), + 'date_start': interval[0].astimezone(pytz.utc).replace(tzinfo=None), + 'date_stop': interval[1].astimezone(pytz.utc).replace(tzinfo=None), + 'work_entry_type_id': work_entry_type_id.id, + 'employee_id': employee.id, + 'contract_id': self.id, + 'company_id': self.company_id.id, + 'state': 'draft', + }] + + for interval in real_leaves: + # Could happen when a leave is configured on the interface on a day for which the + # employee is not supposed to work, i.e. no attendance_ids on the calendar. + # In that case, do try to generate an empty work entry, as this would raise a + # sql constraint error + if interval[0] == interval[1]: # if start == stop + continue + leave_entry_type = self._get_interval_leave_work_entry_type(interval, leaves) + interval_start = interval[0].astimezone(pytz.utc).replace(tzinfo=None) + interval_stop = interval[1].astimezone(pytz.utc).replace(tzinfo=None) + contract_vals += [dict([ + ('name', "%s%s" % (leave_entry_type.name + ": " if leave_entry_type else "", employee.name)), + ('date_start', interval_start), + ('date_stop', interval_stop), + ('work_entry_type_id', leave_entry_type.id), + ('employee_id', employee.id), + ('company_id', self.company_id.id), + ('state', 'draft'), + ('contract_id', self.id), + ] + self._get_more_vals_leave_interval(interval, leaves))] + return contract_vals + + def _get_work_entries_values(self, date_start, date_stop): + """ + Generate a work_entries list between date_start and date_stop for one contract. + :return: list of dictionnary. + """ + vals_list = [] + for contract in self: + contract_vals = contract._get_contract_work_entries_values(date_start, date_stop) + + # If we generate work_entries which exceeds date_start or date_stop, we change boundaries on contract + if contract_vals: + date_stop_max = max([x['date_stop'] for x in contract_vals]) + if date_stop_max > contract.date_generated_to: + contract.date_generated_to = date_stop_max + + date_start_min = min([x['date_start'] for x in contract_vals]) + if date_start_min < contract.date_generated_from: + contract.date_generated_from = date_start_min + + vals_list += contract_vals + + return vals_list + + def _generate_work_entries(self, date_start, date_stop, force=False): + vals_list = [] + + date_start = fields.Datetime.to_datetime(date_start) + date_stop = datetime.combine(fields.Datetime.to_datetime(date_stop), datetime.max.time()) + + for contract in self: + contract_start = fields.Datetime.to_datetime(contract.date_start) + contract_stop = datetime.combine(fields.Datetime.to_datetime(contract.date_end or datetime.max.date()), + datetime.max.time()) + date_start_work_entries = max(date_start, contract_start) + date_stop_work_entries = min(date_stop, contract_stop) + if force: + vals_list.extend(contract._get_work_entries_values(date_start_work_entries, date_stop_work_entries)) + continue + + # In case the date_generated_from == date_generated_to, move it to the date_start to + # avoid trying to generate several months/years of history for old contracts for which + # we've never generated the work entries. + if contract.date_generated_from == contract.date_generated_to: + contract.write({ + 'date_generated_from': date_start, + 'date_generated_to': date_start, + }) + # For each contract, we found each interval we must generate + last_generated_from = min(contract.date_generated_from, contract_stop) + if last_generated_from > date_start_work_entries: + contract.date_generated_from = date_start_work_entries + vals_list.extend(contract._get_work_entries_values(date_start_work_entries, last_generated_from)) + + last_generated_to = max(contract.date_generated_to, contract_start) + if last_generated_to < date_stop_work_entries: + contract.date_generated_to = date_stop_work_entries + vals_list.extend(contract._get_work_entries_values(last_generated_to, date_stop_work_entries)) + + if not vals_list: + return self.env['hr.work.entry'] + + return self.env['hr.work.entry'].create(vals_list) + + def _remove_work_entries(self): + ''' Remove all work_entries that are outside contract period (function used after writing new start or/and end date) ''' + all_we_to_unlink = self.env['hr.work.entry'] + for contract in self: + date_start = fields.Datetime.to_datetime(contract.date_start) + if contract.date_generated_from < date_start: + we_to_remove = self.env['hr.work.entry'].search([('date_stop', '<=', date_start), ('contract_id', '=', contract.id)]) + if we_to_remove: + contract.date_generated_from = date_start + all_we_to_unlink |= we_to_remove + if not contract.date_end: + continue + date_end = datetime.combine(contract.date_end, datetime.max.time()) + if contract.date_generated_to > date_end: + we_to_remove = self.env['hr.work.entry'].search([('date_start', '>=', date_end), ('contract_id', '=', contract.id)]) + if we_to_remove: + contract.date_generated_to = date_end + all_we_to_unlink |= we_to_remove + all_we_to_unlink.unlink() + + def _cancel_work_entries(self): + if not self: + return + domain = [('state', '!=', 'validated')] + for contract in self: + date_start = fields.Datetime.to_datetime(contract.date_start) + contract_domain = [ + ('contract_id', '=', contract.id), + ('date_start', '>=', date_start), + ] + if contract.date_end: + date_end = datetime.combine(contract.date_end, datetime.max.time()) + contract_domain += [('date_stop', '<=', date_end)] + domain = expression.AND([domain, contract_domain]) + work_entries = self.env['hr.work.entry'].search(domain) + if work_entries: + work_entries.unlink() + + def write(self, vals): + result = super(HrContract, self).write(vals) + if vals.get('date_end') or vals.get('date_start'): + self.sudo()._remove_work_entries() + if vals.get('state') in ['draft', 'cancel']: + self._cancel_work_entries() + return result diff --git a/addons/hr_work_entry_contract/models/hr_employee.py b/addons/hr_work_entry_contract/models/hr_employee.py new file mode 100644 index 00000000000..05e8edc7fb7 --- /dev/null +++ b/addons/hr_work_entry_contract/models/hr_employee.py @@ -0,0 +1,20 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class HrEmployee(models.Model): + _inherit = 'hr.employee' + _description = 'Employee' + + def generate_work_entries(self, date_start, date_stop, force=False): + date_start = fields.Date.to_date(date_start) + date_stop = fields.Date.to_date(date_stop) + + if self: + current_contracts = self._get_contracts(date_start, date_stop, states=['open', 'close']) + else: + current_contracts = self._get_all_contracts(date_start, date_stop, states=['open', 'close']) + + return bool(current_contracts._generate_work_entries(date_start, date_stop, force)) diff --git a/addons/hr_work_entry_contract/models/hr_work_entry.py b/addons/hr_work_entry_contract/models/hr_work_entry.py new file mode 100644 index 00000000000..1e6c70b50ba --- /dev/null +++ b/addons/hr_work_entry_contract/models/hr_work_entry.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz + +from collections import defaultdict +from itertools import chain + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.addons.hr_work_entry_contract.models.hr_work_intervals import WorkIntervals + + +class HrWorkEntry(models.Model): + _inherit = 'hr.work.entry' + + contract_id = fields.Many2one('hr.contract', string="Contract", required=True) + employee_id = fields.Many2one(domain=[('contract_ids.state', 'in', ('open', 'pending'))]) + + def _init_column(self, column_name): + if column_name != 'contract_id': + super()._init_column(column_name) + else: + self.env.cr.execute(""" + UPDATE hr_work_entry AS _hwe + SET contract_id = result.contract_id + FROM ( + SELECT + hc.id AS contract_id, + array_agg(hwe.id) AS entry_ids + FROM + hr_work_entry AS hwe + LEFT JOIN + hr_contract AS hc + ON + hwe.employee_id=hc.employee_id AND + hc.state in ('open', 'close') AND + hwe.date_start >= hc.date_start AND + hwe.date_stop < COALESCE(hc.date_end + integer '1', '9999-12-31 23:59:59') + WHERE + hwe.contract_id IS NULL + GROUP BY + hwe.employee_id, hc.id + ) AS result + WHERE _hwe.id = ANY(result.entry_ids) + """) + + def _get_duration_is_valid(self): + return self.work_entry_type_id and self.work_entry_type_id.is_leave + + @api.onchange('employee_id', 'date_start', 'date_stop') + def _onchange_contract_id(self): + vals = { + 'employee_id': self.employee_id.id, + 'date_start': self.date_start, + 'date_stop': self.date_stop, + } + try: + res = self._set_current_contract(vals) + except ValidationError: + return + if res.get('contract_id'): + self.contract_id = res.get('contract_id') + + @api.depends('date_start', 'duration') + def _compute_date_stop(self): + for work_entry in self: + if work_entry._get_duration_is_valid(): + calendar = work_entry.contract_id.resource_calendar_id + if not calendar: + continue + work_entry.date_stop = calendar.plan_hours(work_entry.duration, work_entry.date_start, compute_leaves=True) + continue + super(HrWorkEntry, work_entry)._compute_date_stop() + + def _get_duration(self, date_start, date_stop): + if not date_start or not date_stop: + return 0 + if self._get_duration_is_valid(): + calendar = self.contract_id.resource_calendar_id + if not calendar: + return 0 + employee = self.contract_id.employee_id + contract_data = employee._get_work_days_data_batch( + date_start, date_stop, compute_leaves=False, calendar=calendar + )[employee.id] + return contract_data.get('hours', 0) + return super()._get_duration(date_start, date_stop) + + @api.model + def _set_current_contract(self, vals): + if not vals.get('contract_id') and vals.get('date_start') and vals.get('date_stop') and vals.get('employee_id'): + contract_start = fields.Datetime.to_datetime(vals.get('date_start')).date() + contract_end = fields.Datetime.to_datetime(vals.get('date_stop')).date() + employee = self.env['hr.employee'].browse(vals.get('employee_id')) + contracts = employee._get_contracts(contract_start, contract_end, states=['open', 'pending', 'close']) + if not contracts: + raise ValidationError(_("%s does not have a contract from %s to %s.") % (employee.name, contract_start, contract_end)) + elif len(contracts) > 1: + raise ValidationError(_("%s has multiple contracts from %s to %s. A work entry cannot overlap multiple contracts.") + % (employee.name, contract_start, contract_end)) + return dict(vals, contract_id=contracts[0].id) + return vals + + @api.model_create_multi + def create(self, vals_list): + vals_list = [self._set_current_contract(vals) for vals in vals_list] + work_entries = super().create(vals_list) + return work_entries + + def _check_if_error(self): + res = super()._check_if_error() + outside_calendar = self._mark_leaves_outside_schedule() + return res or outside_calendar + + def _get_leaves_entries_outside_schedule(self): + return self.filtered(lambda w: w.work_entry_type_id.is_leave and w.state not in ('validated', 'cancelled')) + + def _mark_leaves_outside_schedule(self): + """ + Check leave work entries in `self` which are completely outside + the contract's theoretical calendar schedule. Mark them as conflicting. + :return: leave work entries completely outside the contract's calendar + """ + work_entries = self._get_leaves_entries_outside_schedule() + entries_by_calendar = defaultdict(lambda: self.env['hr.work.entry']) + for work_entry in work_entries: + calendar = work_entry.contract_id.resource_calendar_id + entries_by_calendar[calendar] |= work_entry + + outside_entries = self.env['hr.work.entry'] + for calendar, entries in entries_by_calendar.items(): + datetime_start = min(entries.mapped('date_start')) + datetime_stop = max(entries.mapped('date_stop')) + + calendar_intervals = calendar._attendance_intervals_batch(pytz.utc.localize(datetime_start), pytz.utc.localize(datetime_stop))[False] + entries_intervals = entries._to_intervals() + overlapping_entries = self._from_intervals(entries_intervals & calendar_intervals) + outside_entries |= entries - overlapping_entries + outside_entries.write({'state': 'conflict'}) + return bool(outside_entries) + + def _to_intervals(self): + return WorkIntervals((w.date_start.replace(tzinfo=pytz.utc), w.date_stop.replace(tzinfo=pytz.utc), w) for w in self) + + @api.model + def _from_intervals(self, intervals): + return self.browse(chain.from_iterable(recs.ids for start, end, recs in intervals)) + + +class HrWorkEntryType(models.Model): + _inherit = 'hr.work.entry.type' + _description = 'HR Work Entry Type' + + is_leave = fields.Boolean(default=False, string="Time Off") diff --git a/addons/hr_work_entry_contract/models/hr_work_intervals.py b/addons/hr_work_entry_contract/models/hr_work_intervals.py new file mode 100644 index 00000000000..5a934f93e90 --- /dev/null +++ b/addons/hr_work_entry_contract/models/hr_work_intervals.py @@ -0,0 +1,97 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from itertools import chain + + +def _boundaries(intervals, opening, closing): + """ Iterate on the boundaries of intervals. """ + for start, stop, recs in intervals: + if start < stop: + yield (start, opening, recs) + yield (stop, closing, recs) + + +class WorkIntervals(object): + """ + This class is a modified copy of the ``Intervals`` class in the resource module. + A generic solution to handle intervals should probably be developped the day a similar + class is needed elsewhere. + + This implementation differs from the resource implementation in its management + of two continuous intervals. Here, continuous intervals are not merged together + while they are merged in resource. + e.g.: + In resource: (1, 4, rec1) and (4, 10, rec2) are merged into (1, 10, rec1 | rec2) + Here: they remain two different intervals. + To implement this behaviour, the main implementation change is the way boundaries are sorted. + """ + def __init__(self, intervals=()): + self._items = [] + if intervals: + # normalize the representation of intervals + append = self._items.append + starts = [] + recses = [] + for value, flag, recs in sorted(_boundaries(sorted(intervals), 'start', 'stop'), key=lambda i: i[0]): + if flag == 'start': + starts.append(value) + recses.append(recs) + else: + start = starts.pop() + if not starts: + append((start, value, recses[0].union(*recses))) + recses.clear() + + def __bool__(self): + return bool(self._items) + + def __len__(self): + return len(self._items) + + def __iter__(self): + return iter(self._items) + + def __reversed__(self): + return reversed(self._items) + + def __or__(self, other): + """ Return the union of two sets of intervals. """ + return WorkIntervals(chain(self._items, other._items)) + + def __and__(self, other): + """ Return the intersection of two sets of intervals. """ + return self._merge(other, False) + + def __sub__(self, other): + """ Return the difference of two sets of intervals. """ + return self._merge(other, True) + + def _merge(self, other, difference): + """ Return the difference or intersection of two sets of intervals. """ + result = WorkIntervals() + append = result._items.append + + # using 'self' and 'other' below forces normalization + bounds1 = _boundaries(sorted(self), 'start', 'stop') + bounds2 = _boundaries(sorted(other), 'switch', 'switch') + + start = None # set by start/stop + recs1 = None # set by start + enabled = difference # changed by switch + for value, flag, recs in sorted(chain(bounds1, bounds2), key=lambda i: i[0]): + if flag == 'start': + start = value + recs1 = recs + elif flag == 'stop': + if enabled and start < value: + append((start, value, recs1)) + start = None + else: + if not enabled and start is not None: + start = value + if enabled and start is not None and start < value: + append((start, value, recs1)) + enabled = not enabled + + return result diff --git a/addons/hr_work_entry_contract/security/hr_work_entry_security.xml b/addons/hr_work_entry_contract/security/hr_work_entry_security.xml new file mode 100644 index 00000000000..6f2c3b25f85 --- /dev/null +++ b/addons/hr_work_entry_contract/security/hr_work_entry_security.xml @@ -0,0 +1,10 @@ + + + + + HR Work Entry Contract: Multi Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + diff --git a/addons/hr_work_entry_contract/security/ir.model.access.csv b/addons/hr_work_entry_contract/security/ir.model.access.csv new file mode 100644 index 00000000000..bab45a98143 --- /dev/null +++ b/addons/hr_work_entry_contract/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_work_entry_regeneration_wizard,access_hr_work_entry_regeneration_wizard,model_hr_work_entry_regeneration_wizard,hr.group_hr_manager,1,1,1,1 +access_create_company_global_time_off,access_create_company_global_time_off,model_create_company_global_time_off,hr_contract.group_hr_contract_manager,1,1,1,0 \ No newline at end of file diff --git a/addons/hr_work_entry_contract/static/description/icon.png b/addons/hr_work_entry_contract/static/description/icon.png new file mode 100644 index 00000000000..41d2e5671ab Binary files /dev/null and b/addons/hr_work_entry_contract/static/description/icon.png differ diff --git a/addons/hr_work_entry_contract/static/description/icon.svg b/addons/hr_work_entry_contract/static/description/icon.svg new file mode 100644 index 00000000000..f8e0e969877 --- /dev/null +++ b/addons/hr_work_entry_contract/static/description/icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr_work_entry_contract/static/src/js/work_entries_calendar.js b/addons/hr_work_entry_contract/static/src/js/work_entries_calendar.js new file mode 100644 index 00000000000..404f9e3c6d1 --- /dev/null +++ b/addons/hr_work_entry_contract/static/src/js/work_entries_calendar.js @@ -0,0 +1,76 @@ +odoo.define('hr_work_entry_contract.work_entries_calendar', function(require) { + 'use strict'; + + var core = require('web.core'); + var WorkEntryControllerMixin = require('hr_work_entry_contract.WorkEntryControllerMixin'); + var CalendarController = require("web.CalendarController"); + var CalendarModel = require('web.CalendarModel'); + var CalendarRenderer = require('web.CalendarRenderer'); + var CalendarView = require('web.CalendarView'); + var viewRegistry = require('web.view_registry'); + + var _t = core._t; + + + var WorkEntryCalendarController = CalendarController.extend(WorkEntryControllerMixin, { + events: _.extend({}, WorkEntryControllerMixin.events, CalendarController.prototype.events), + + // Returns the records from the model + _fetchRecords: function () { + var self = this; + var records = _.filter(this.model.data.data, function (data) { + // Filter records that are not inside the current month + // (because in calendar view some days of prev. and next month are visible) + return data.record.date_start.isBefore(self.lastDay) && data.record.date_stop.isAfter(self.firstDay); + }); + return _.pluck(records, 'record'); + }, + _fetchFirstDay: function () { + return this.model.data.target_date.clone().startOf('month'); + }, + _fetchLastDay: function () { + return this.model.data.target_date.clone().endOf('month'); + }, + _displayWarning: function ($warning) { + this.$('.o_calendar_container').before($warning); + }, + }); + + var WorkEntryCalendarModel = CalendarModel.extend({ + /** + * Display everybody's work entries if no employee filter exists + * @private + * @override + * @param {any} filter + * @returns {Deferred} + */ + _loadFilter: function (filter) { + return this._super.apply(this, arguments).then(function () { + var filters = filter.filters; + var all_filter = filters[filters.length - 1]; + + if (all_filter) { + all_filter.label = _t("Everybody's work entries"); + + if (filter.write_model && filter.filters.length <= 1 && all_filter.active === undefined) { + filter.all = true; + all_filter.active = true; + } + } + + }); + } + }); + + var WorkEntryCalendarView = CalendarView.extend({ + config: _.extend({}, CalendarView.prototype.config, { + Controller: WorkEntryCalendarController, + Model: WorkEntryCalendarModel, + Renderer: CalendarRenderer, + }), + }); + + viewRegistry.add('work_entries_calendar', WorkEntryCalendarView); + + return WorkEntryCalendarController; +}); diff --git a/addons/hr_work_entry_contract/static/src/js/work_entries_controller_mixin.js b/addons/hr_work_entry_contract/static/src/js/work_entries_controller_mixin.js new file mode 100644 index 00000000000..f691a4cd1eb --- /dev/null +++ b/addons/hr_work_entry_contract/static/src/js/work_entries_controller_mixin.js @@ -0,0 +1,109 @@ +odoo.define('hr_work_entry_contract.WorkEntryControllerMixin', function(require) { + 'use strict'; + + var core = require('web.core'); + var time = require('web.time'); + + var _t = core._t; + var QWeb = core.qweb; + + /* + This mixin implements the behaviours necessary to generate and validate work entries and Payslips + It is intended to be used in a Controller and requires four methods to be defined on your Controller + + 1. _fetchRecords + Which should return a list of records containing at least the state and id fields + + 2. _fetchFirstDay + Which should return the first day for which we will generate the work entries, it should be a Moment instance + (Typically the first day of the current month) + + 3. _fetchLastDay + Same as _fetchFirstDay except that this is the last day of the period + + 4. _displayWarning + Which should insert in the DOM the warning rendered template received as argument. + + This mixin is responsible for rendering the buttons in the control panel and adds the two following methods + + 1. _generateWorkEntries + */ + + var WorkEntryControllerMixin = { + + /** + * @override + * @returns {Promise} + */ + _update: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.firstDay = self._fetchFirstDay().toDate(); + self.lastDay = self._fetchLastDay().toDate(); + var now = moment(); + if (self.firstDay > now) return Promise.resolve(); + return self._generateWorkEntries(); + }); + }, + + updateButtons: function() { + this._super.apply(this, arguments); + + if(!this.$buttons) { + return; + } + + this.$buttons.find('.btn-regenerate-work-entries').on('click', this._onRegenerateWorkEntries.bind(this)); + }, + + renderButtons: function($node) { + this._super.apply(this, arguments); + + if(this.$buttons) { + this.$buttons.append(this._renderWorkEntryButtons()); + } + }, + + /* + Private + */ + _renderWorkEntryButtons: function() { + return $('').append(QWeb.render('hr_work_entry.work_entry_button', { + button_text: _t("Regenerate Work Entries"), + event_class: 'btn-regenerate-work-entries', + })); + }, + + _generateWorkEntries: function () { + var self = this; + return this._rpc({ + model: 'hr.employee', + method: 'generate_work_entries', + args: [[], time.date_to_str(this.firstDay), time.date_to_str(this.lastDay)], + }).then(function (new_work_entries) { + if (new_work_entries) { + self.reload(); + } + }); + }, + + _regenerateWorkEntries: function () { + this.do_action('hr_work_entry_contract.hr_work_entry_regeneration_wizard_action', { + additional_context: { + date_start: time.date_to_str(this.firstDay), + date_end: time.date_to_str(this.lastDay), + }, + }); + }, + + _onRegenerateWorkEntries: function (e) { + e.preventDefault(); + e.stopImmediatePropagation(); + this._regenerateWorkEntries(); + }, + + }; + + return WorkEntryControllerMixin; + +}); diff --git a/addons/hr_work_entry_contract/tests/__init__.py b/addons/hr_work_entry_contract/tests/__init__.py new file mode 100644 index 00000000000..071f4c49264 --- /dev/null +++ b/addons/hr_work_entry_contract/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_work_entry +from . import test_work_intervals diff --git a/addons/hr_work_entry_contract/tests/common.py b/addons/hr_work_entry_contract/tests/common.py new file mode 100644 index 00000000000..3e9e38a22d3 --- /dev/null +++ b/addons/hr_work_entry_contract/tests/common.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta +from odoo.fields import Date +from odoo.tests.common import TransactionCase + + +class TestWorkEntryBase(TransactionCase): + + @classmethod + def setUpClass(cls): + super(TestWorkEntryBase, cls).setUpClass() + + cls.env.user.tz = 'Europe/Brussels' + cls.env.ref('resource.resource_calendar_std').tz = 'Europe/Brussels' + + cls.dep_rd = cls.env['hr.department'].create({ + 'name': 'Research & Development - Test', + }) + + # I create a new employee "Richard" + cls.richard_emp = cls.env['hr.employee'].create({ + 'name': 'Richard', + 'gender': 'male', + 'birthday': '1984-05-01', + 'country_id': cls.env.ref('base.be').id, + 'department_id': cls.dep_rd.id, + }) + + # I create a contract for "Richard" + cls.env['hr.contract'].create({ + 'date_end': Date.today() + relativedelta(years=2), + 'date_start': Date.to_date('2018-01-01'), + 'name': 'Contract for Richard', + 'wage': 5000.0, + 'employee_id': cls.richard_emp.id, + }) + + cls.work_entry_type = cls.env['hr.work.entry.type'].create({ + 'name': 'Extra attendance', + 'is_leave': False, + 'code': 'WORKTEST200', + }) + + cls.work_entry_type_unpaid = cls.env['hr.work.entry.type'].create({ + 'name': 'Unpaid Leave', + 'is_leave': True, + 'code': 'LEAVETEST300', + }) + + cls.work_entry_type_leave = cls.env['hr.work.entry.type'].create({ + 'name': 'Leave', + 'is_leave': True, + 'code': 'LEAVETEST100' + }) + + def create_work_entry(self, start, stop, work_entry_type=None): + work_entry_type = work_entry_type or self.work_entry_type + return self.env['hr.work.entry'].create({ + 'contract_id': self.richard_emp.contract_ids[0].id, + 'name': "Work entry %s-%s" % (start, stop), + 'date_start': start, + 'date_stop': stop, + 'employee_id': self.richard_emp.id, + 'work_entry_type_id': work_entry_type.id, + }) diff --git a/addons/hr_work_entry_contract/tests/test_work_entry.py b/addons/hr_work_entry_contract/tests/test_work_entry.py new file mode 100644 index 00000000000..7e5932f3fc8 --- /dev/null +++ b/addons/hr_work_entry_contract/tests/test_work_entry.py @@ -0,0 +1,182 @@ +# # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil.relativedelta import relativedelta +from psycopg2 import IntegrityError +import pytz + +from odoo.tests.common import tagged +from odoo.tools import mute_logger +from odoo.addons.hr_work_entry_contract.tests.common import TestWorkEntryBase + + +@tagged('work_entry') +class TestWorkEntry(TestWorkEntryBase): + def setUp(self): + super(TestWorkEntry, self).setUp() + self.tz = pytz.timezone(self.richard_emp.tz) + self.start = datetime(2015, 11, 1, 1, 0, 0) + self.end = datetime(2015, 11, 30, 23, 59, 59) + self.resource_calendar_id = self.env['resource.calendar'].create({'name': 'My Calendar'}) + contract = self.env['hr.contract'].create({ + 'date_start': self.start.date() - relativedelta(days=5), + 'name': 'dodo', + 'resource_calendar_id': self.resource_calendar_id.id, + 'wage': 1000, + 'employee_id': self.richard_emp.id, + 'state': 'open', + 'date_generated_from': self.end.date() + relativedelta(days=5), + }) + self.richard_emp.resource_calendar_id = self.resource_calendar_id + self.richard_emp.contract_id = contract + + def test_no_duplicate(self): + self.richard_emp.generate_work_entries(self.start, self.end) + pou1 = self.env['hr.work.entry'].search_count([]) + self.richard_emp.generate_work_entries(self.start, self.end) + pou2 = self.env['hr.work.entry'].search_count([]) + self.assertEqual(pou1, pou2, "Work entries should not be duplicated") + + def test_work_entry(self): + self.richard_emp.generate_work_entries(self.start, self.end) + attendance_nb = len(self.resource_calendar_id._attendance_intervals_batch(self.start.replace(tzinfo=pytz.utc), self.end.replace(tzinfo=pytz.utc))[False]) + work_entry_nb = self.env['hr.work.entry'].search_count([ + ('employee_id', '=', self.richard_emp.id), + ('date_start', '>=', self.start), + ('date_stop', '<=', self.end)]) + self.assertEqual(attendance_nb, work_entry_nb, "One work_entry should be generated for each calendar attendance") + + def test_approve_multiple_day_work_entry(self): + start = datetime(2015, 11, 1, 9, 0, 0) + end = datetime(2015, 11, 3, 18, 0, 0) + + work_entry = self.env['hr.work.entry'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, + 'date_start': start, + 'date_stop': end, + 'work_entry_type_id': self.work_entry_type.id, + }) + work_entry.action_validate() + work_entries = self.env['hr.work.entry'].search([('employee_id', '=', self.richard_emp.id)]) + self.assertTrue(all((b.state == 'validated' for b in work_entries)), "Work entries should be approved") + + def test_validate_conflict_work_entry(self): + start = datetime(2015, 11, 1, 9, 0, 0) + end = datetime(2015, 11, 1, 13, 0, 0) + work_entry1 = self.env['hr.work.entry'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'work_entry_type_id': self.env.ref('hr_work_entry.work_entry_type_attendance').id, + 'contract_id': self.richard_emp.contract_id.id, + 'date_start': start, + 'date_stop': end + relativedelta(hours=5), + }) + self.env['hr.work.entry'].create({ + 'name': '2', + 'employee_id': self.richard_emp.id, + 'work_entry_type_id': self.env.ref('hr_work_entry.work_entry_type_attendance').id, + 'contract_id': self.richard_emp.contract_id.id, + 'date_start': start + relativedelta(hours=3), + 'date_stop': end, + }) + self.assertFalse(work_entry1.action_validate(), "It should not validate work_entries conflicting with others") + self.assertEqual(work_entry1.state, 'conflict') + self.assertNotEqual(work_entry1.state, 'validated') + + def test_validate_undefined_work_entry(self): + work_entry1 = self.env['hr.work.entry'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, + 'date_start': self.start, + 'date_stop': self.end, + }) + self.assertFalse(work_entry1.action_validate(), "It should not validate work_entries without a type") + self.assertEqual(work_entry1.state, 'conflict', "It should change to conflict state") + work_entry1.work_entry_type_id = self.work_entry_type + self.assertTrue(work_entry1.action_validate(), "It should validate work_entries") + + def test_outside_calendar(self): + """ Test leave work entries outside schedule are conflicting """ + # Outside but not a leave + work_entry_1 = self.create_work_entry(datetime(2018, 10, 10, 3, 0), datetime(2018, 10, 10, 4, 0)) + # Outside and a leave + work_entry_2 = self.create_work_entry(datetime(2018, 10, 10, 1, 0), datetime(2018, 10, 10, 2, 0), work_entry_type=self.work_entry_type_leave) + # Overlapping and a leave + work_entry_3 = self.create_work_entry(datetime(2018, 10, 10, 7, 0), datetime(2018, 10, 10, 10, 0), work_entry_type=self.work_entry_type_leave) + # Overlapping and not a leave + work_entry_4 = self.create_work_entry(datetime(2018, 10, 10, 11, 0), datetime(2018, 10, 10, 13, 0)) + (work_entry_1 | work_entry_2 | work_entry_3 | work_entry_4)._mark_leaves_outside_schedule() + self.assertEqual(work_entry_2.state, 'conflict', "It should conflict") + self.assertNotEqual(work_entry_1.state, 'conflict', "It should not conflict") + self.assertNotEqual(work_entry_3.state, 'conflict', "It should not conflict") + self.assertNotEqual(work_entry_4.state, 'conflict', "It should not conflict") + + def test_write_conflict(self): + """ Test updating work entries dates recomputes conflicts """ + work_entry_1 = self.create_work_entry(datetime(2018, 10, 10, 9, 0), datetime(2018, 10, 10, 12, 0)) + work_entry_2 = self.create_work_entry(datetime(2018, 10, 10, 12, 0), datetime(2018, 10, 10, 18, 0)) + self.assertNotEqual(work_entry_1.state, 'conflict', "It should not conflict") + self.assertNotEqual(work_entry_2.state, 'conflict', "It should not conflict") + work_entry_1.date_stop = datetime(2018, 10, 10, 14, 0) + self.assertEqual(work_entry_1.state, 'conflict', "It should conflict") + self.assertEqual(work_entry_2.state, 'conflict', "It should conflict") + + work_entry_1.date_stop = datetime(2018, 10, 10, 12, 0) # cancel conflict + self.assertNotEqual(work_entry_1.state, 'conflict', "It should no longer conflict") + self.assertNotEqual(work_entry_2.state, 'conflict', "It should no longer conflict") + + def test_write_move(self): + """ Test completely moving a work entry recomputes conflicts """ + work_entry_1 = self.create_work_entry(datetime(2018, 10, 10, 9, 0), datetime(2018, 10, 10, 12, 0)) + work_entry_2 = self.create_work_entry(datetime(2018, 10, 18, 9, 0), datetime(2018, 10, 18, 12, 0)) + work_entry_3 = self.create_work_entry(datetime(2018, 10, 18, 10, 0), datetime(2018, 10, 18, 12, 0)) + work_entry_2.write({ + 'date_start': datetime(2018, 10, 10, 9, 0), + 'date_stop': datetime(2018, 10, 10, 10, 0), + }) + self.assertEqual(work_entry_1.state, 'conflict') + self.assertEqual(work_entry_2.state, 'conflict') + self.assertNotEqual(work_entry_3.state, 'conflict') + + def test_create_conflict(self): + """ Test creating a work entry recomputes conflicts """ + work_entry_1 = self.create_work_entry(datetime(2018, 10, 10, 9, 0), datetime(2018, 10, 10, 12, 0)) + self.assertNotEqual(work_entry_1.state, 'conflict', "It should not conflict") + work_entry_2 = self.create_work_entry(datetime(2018, 10, 10, 10, 0), datetime(2018, 10, 10, 18, 0)) + self.assertEqual(work_entry_1.state, 'conflict', "It should conflict") + self.assertEqual(work_entry_2.state, 'conflict', "It should conflict") + + def test_unarchive_conflict(self): + """ Test archive/unarchive a work entry recomputes conflicts """ + work_entry_1 = self.create_work_entry(datetime(2018, 10, 10, 9, 0), datetime(2018, 10, 10, 12, 0)) + work_entry_2 = self.create_work_entry(datetime(2018, 10, 10, 10, 0), datetime(2018, 10, 10, 18, 0)) + work_entry_2.active = False + self.assertNotEqual(work_entry_1.state, 'conflict', "It should not conflict") + self.assertEqual(work_entry_2.state, 'cancelled', "It should be cancelled") + work_entry_2.active = True + self.assertEqual(work_entry_1.state, 'conflict', "It should conflict") + self.assertEqual(work_entry_2.state, 'conflict', "It should conflict") + + def test_validated_no_conflict(self): + """ Test validating a work entry removes the conflict """ + work_entry_1 = self.create_work_entry(datetime(2018, 10, 10, 9, 0), datetime(2018, 10, 10, 12, 0)) + work_entry_1.state = 'validated' + work_entry_2 = self.create_work_entry(datetime(2018, 10, 10, 10, 0), datetime(2018, 10, 10, 18, 0)) + self.assertEqual(work_entry_1.state, 'conflict', "It should not conflict") + self.assertEqual(work_entry_2.state, 'conflict', "It should conflict") + + def test_no_overlap_sql_constraint(self): + """ Test _work_entries_no_validated_conflict sql constrains """ + work_entry_1 = self.create_work_entry(datetime(2018, 10, 10, 9, 0), datetime(2018, 10, 10, 12, 0)) + work_entry_1.state = 'validated' + work_entry_2 = self.create_work_entry(datetime(2018, 10, 10, 8, 0), datetime(2018, 10, 10, 10, 0)) + self.assertEqual(work_entry_1.state, 'conflict') + + with mute_logger('odoo.sql_db'): + with self.assertRaises(IntegrityError): + with self.cr.savepoint(): + (work_entry_1 + work_entry_2).write({'state': 'validated'}) diff --git a/addons/hr_work_entry_contract/tests/test_work_intervals.py b/addons/hr_work_entry_contract/tests/test_work_intervals.py new file mode 100644 index 00000000000..70543c27642 --- /dev/null +++ b/addons/hr_work_entry_contract/tests/test_work_intervals.py @@ -0,0 +1,70 @@ +from odoo.addons.hr_work_entry_contract.models.hr_work_intervals import WorkIntervals +from odoo.tests.common import TransactionCase + + +class TestIntervals(TransactionCase): + + def ints(self, pairs): + recs = self.env['base'] + return [(a, b, recs) for a, b in pairs] + + def test_union(self): + def check(a, b): + a, b = self.ints(a), self.ints(b) + self.assertEqual(list(WorkIntervals(a)), b) + + check([(1, 2), (3, 4)], [(1, 2), (3, 4)]) + check([(1, 2), (2, 4)], [(1, 2), (2, 4)]) + check([(1, 3), (2, 4)], [(1, 4)]) + check([(1, 4), (2, 3)], [(1, 4)]) + check([(1, 4), (1, 4)], [(1, 4)]) + check([(3, 4), (1, 2)], [(1, 2), (3, 4)]) + check([(2, 4), (1, 2)], [(1, 2), (2, 4)]) + check([(2, 4), (1, 3)], [(1, 4)]) + check([(2, 3), (1, 4)], [(1, 4)]) + + def test_intersection(self): + def check(a, b, c): + a, b, c = self.ints(a), self.ints(b), self.ints(c) + self.assertEqual(list(WorkIntervals(a) & WorkIntervals(b)), c) + + check([(10, 20)], [(5, 8)], []) + check([(10, 20)], [(5, 10)], []) + check([(10, 20)], [(5, 15)], [(10, 15)]) + check([(10, 20)], [(5, 20)], [(10, 20)]) + check([(10, 20)], [(5, 25)], [(10, 20)]) + check([(10, 20)], [(10, 15)], [(10, 15)]) + check([(10, 20)], [(10, 20)], [(10, 20)]) + check([(10, 20)], [(10, 25)], [(10, 20)]) + check([(10, 20)], [(15, 18)], [(15, 18)]) + check([(10, 20)], [(15, 20)], [(15, 20)]) + check([(10, 20)], [(15, 25)], [(15, 20)]) + check([(10, 20)], [(20, 25)], []) + check( + [(0, 5), (10, 15), (20, 25), (30, 35)], + [(6, 7), (9, 12), (13, 17), (22, 23), (24, 40)], + [(10, 12), (13, 15), (22, 23), (24, 25), (30, 35)], + ) + + def test_difference(self): + def check(a, b, c): + a, b, c = self.ints(a), self.ints(b), self.ints(c) + self.assertEqual(list(WorkIntervals(a) - WorkIntervals(b)), c) + + check([(10, 20)], [(5, 8)], [(10, 20)]) + check([(10, 20)], [(5, 10)], [(10, 20)]) + check([(10, 20)], [(5, 15)], [(15, 20)]) + check([(10, 20)], [(5, 20)], []) + check([(10, 20)], [(5, 25)], []) + check([(10, 20)], [(10, 15)], [(15, 20)]) + check([(10, 20)], [(10, 20)], []) + check([(10, 20)], [(10, 25)], []) + check([(10, 20)], [(15, 18)], [(10, 15), (18, 20)]) + check([(10, 20)], [(15, 20)], [(10, 15)]) + check([(10, 20)], [(15, 25)], [(10, 15)]) + check([(10, 20)], [(20, 25)], [(10, 20)]) + check( + [(0, 5), (10, 15), (20, 25), (30, 35)], + [(6, 7), (9, 12), (13, 17), (22, 23), (24, 40)], + [(0, 5), (12, 13), (20, 22), (23, 24)], + ) diff --git a/addons/hr_work_entry_contract/views/hr_work_entry_template.xml b/addons/hr_work_entry_contract/views/hr_work_entry_template.xml new file mode 100644 index 00000000000..cdef96f9d7c --- /dev/null +++ b/addons/hr_work_entry_contract/views/hr_work_entry_template.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/addons/hr_work_entry_contract/views/hr_work_entry_views.xml b/addons/hr_work_entry_contract/views/hr_work_entry_views.xml new file mode 100644 index 00000000000..1972add686e --- /dev/null +++ b/addons/hr_work_entry_contract/views/hr_work_entry_views.xml @@ -0,0 +1,42 @@ + + + + hr.work.entry.contract.view.calendar.inherit + hr.work.entry + + + + work_entries_calendar + + + + + + hr.work.entry.contract.view.form.inherit + hr.work.entry + + + +
+ +
+
+
+
+ + + hr.work.entry.type.contract.view.form.inherit + hr.work.entry.type + + + + + + + + + + +
diff --git a/addons/hr_work_entry_contract/views/resource_views.xml b/addons/hr_work_entry_contract/views/resource_views.xml new file mode 100644 index 00000000000..d0880c592b2 --- /dev/null +++ b/addons/hr_work_entry_contract/views/resource_views.xml @@ -0,0 +1,17 @@ + + + + + + resource.calendar.view.form.inherit + resource.calendar + + + +
+
+
+
+
+
diff --git a/addons/hr_work_entry_contract/wizard/__init__.py b/addons/hr_work_entry_contract/wizard/__init__.py new file mode 100644 index 00000000000..a94565eaac1 --- /dev/null +++ b/addons/hr_work_entry_contract/wizard/__init__.py @@ -0,0 +1,5 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_work_entry_regeneration_wizard +from . import create_company_global_time_off diff --git a/addons/hr_work_entry_contract/wizard/create_company_global_time_off.py b/addons/hr_work_entry_contract/wizard/create_company_global_time_off.py new file mode 100644 index 00000000000..b6de83f00f5 --- /dev/null +++ b/addons/hr_work_entry_contract/wizard/create_company_global_time_off.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class HrPayrollIndex(models.TransientModel): + _name = 'create.company.global.time.off' + _description = 'Create Company Public Time Off' + + name = fields.Char('Reason', required=True) + company_id = fields.Many2one('res.company', default=lambda self: self.env.company, required=True) + date_from = fields.Datetime('Start Date', required=True) + date_to = fields.Datetime('End Date', required=True) + work_entry_type_id = fields.Many2one('hr.work.entry.type', 'Work Entry Type', required=True) + + def action_confirm(self): + self.ensure_one() + self.env['resource.calendar'].search([('company_id', '=', self.company_id.id)]).write({ + 'leave_ids': [(0, 0, { + 'name': self.name, + 'date_from': self.date_from, + 'date_to': self.date_to, + 'time_type': 'leave', + 'work_entry_type_id': self.work_entry_type_id.id, + })] + }) diff --git a/addons/hr_work_entry_contract/wizard/create_company_global_time_off_views.xml b/addons/hr_work_entry_contract/wizard/create_company_global_time_off_views.xml new file mode 100644 index 00000000000..ad200ca6599 --- /dev/null +++ b/addons/hr_work_entry_contract/wizard/create_company_global_time_off_views.xml @@ -0,0 +1,34 @@ + + + + create.company.global.time.off.view.form + create.company.global.time.off + +
+ + + +
+
+
+
+
+ + + Create Company Public Time Off + create.company.global.time.off + tree,form + + new + +
diff --git a/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py b/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py new file mode 100644 index 00000000000..df9c8c7b2f7 --- /dev/null +++ b/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard.py @@ -0,0 +1,97 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class HrWorkEntryRegenerationWizard(models.TransientModel): + _name = 'hr.work.entry.regeneration.wizard' + _description = 'Regenerate Employee Work Entries' + + earliest_available_date = fields.Date('Earliest date', compute='_compute_earliest_available_date') + earliest_available_date_message = fields.Char(readonly=True, store=False, default='') + latest_available_date = fields.Date('Latest date', compute='_compute_latest_available_date') + latest_available_date_message = fields.Char(readonly=True, store=False, default='') + date_from = fields.Date('From', required=True, default=lambda self: self.env.context.get('date_start')) + date_to = fields.Date('To', required=True, default=lambda self: self.env.context.get('date_end')) + employee_id = fields.Many2one('hr.employee', 'Employee', required=True) + validated_work_entry_ids = fields.Many2many('hr.work.entry', string='Work Entries Within Interval', + compute='_compute_validated_work_entry_ids') + search_criteria_completed = fields.Boolean(compute='_compute_search_criteria_completed') + valid = fields.Boolean(compute='_compute_valid') + + @api.depends('employee_id') + def _compute_earliest_available_date(self): + for wizard in self: + dates = wizard.mapped('employee_id.contract_ids.date_generated_from') + wizard.earliest_available_date = min(dates) if dates else None + + @api.depends('employee_id') + def _compute_latest_available_date(self): + for wizard in self: + dates = wizard.mapped('employee_id.contract_ids.date_generated_to') + wizard.latest_available_date = max(dates) if dates else None + + @api.depends('date_from', 'date_to', 'employee_id') + def _compute_validated_work_entry_ids(self): + for wizard in self: + validated_work_entry_ids = self.env['hr.work.entry'] + if wizard.search_criteria_completed: + search_domain = [('employee_id', '=', self.employee_id.id), + ('date_start', '>=', self.date_from), + ('date_stop', '<=', self.date_to), + ('state', '=', 'validated')] + validated_work_entry_ids = self.env['hr.work.entry'].search(search_domain, order="date_start") + wizard.validated_work_entry_ids = validated_work_entry_ids + + @api.depends('validated_work_entry_ids') + def _compute_valid(self): + for wizard in self: + wizard.valid = wizard.search_criteria_completed and len(wizard.validated_work_entry_ids) == 0 + + @api.depends('date_from', 'date_to', 'employee_id') + def _compute_search_criteria_completed(self): + for wizard in self: + wizard.search_criteria_completed = wizard.date_from and wizard.date_to and wizard.employee_id and wizard.earliest_available_date and wizard.latest_available_date + + @api.onchange('date_from', 'date_to', 'employee_id') + def _check_dates(self): + for wizard in self: + wizard.earliest_available_date_message = '' + wizard.latest_available_date_message = '' + if wizard.search_criteria_completed: + if wizard.date_from > wizard.date_to: + date_from = wizard.date_from + wizard.date_from = wizard.date_to + wizard.date_to = date_from + if wizard.earliest_available_date and wizard.date_from < wizard.earliest_available_date: + wizard.date_from = wizard.earliest_available_date + wizard.earliest_available_date_message = 'The earliest available date is {date}' \ + .format(date=self._date_to_string(wizard.earliest_available_date)) + if wizard.latest_available_date and wizard.date_to > wizard.latest_available_date: + wizard.date_to = wizard.latest_available_date + wizard.latest_available_date_message = 'The latest available date is {date}' \ + .format(date=self._date_to_string(wizard.latest_available_date)) + + @api.model + def _date_to_string(self, date): + if not date: + return '' + user_date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format + return date.strftime(user_date_format) + + def regenerate_work_entries(self): + self.ensure_one() + if not self.valid: + raise ValidationError(_("In order to regenerate the work entries, you need to provide the wizard with an employee_id, a date_from and a date_to. In addition to that, the time interval defined by date_from and date_to must not contain any validated work entries.")) + + if self.date_from < self.earliest_available_date or self.date_to > self.latest_available_date: + raise ValidationError(_("The from date must be >= '%(earliest_available_date)s' and the to date must be <= '%(latest_available_date)s', which correspond to the generated work entries time interval.", earliest_available_date=self._date_to_string(self.earliest_available_date), latest_available_date=self._date_to_string(self.latest_available_date))) + work_entries = self.env['hr.work.entry'].search([('employee_id', '=', self.employee_id.id), + ('date_stop', '>=', self.date_from), + ('date_start', '<=', self.date_to)]) + work_entries.write({'active': False}) + self.employee_id.generate_work_entries(self.date_from, self.date_to, True) + action = self.env["ir.actions.actions"]._for_xml_id('hr_work_entry.hr_work_entry_action') + return action diff --git a/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard_views.xml b/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard_views.xml new file mode 100644 index 00000000000..42eba0a77af --- /dev/null +++ b/addons/hr_work_entry_contract/wizard/hr_work_entry_regeneration_wizard_views.xml @@ -0,0 +1,66 @@ + + + + hr_work_entry_regeneration_wizard + hr.work.entry.regeneration.wizard + +
+ + + + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+
+ + +
+
You are not allowed to regenerate validated work entries
+ + + + + + + + + +
+
+
+ +
+
+ + Work Entry Regeneration + hr.work.entry.regeneration.wizard + form + new + +
diff --git a/addons/hr_work_entry_holidays/__init__.py b/addons/hr_work_entry_holidays/__init__.py new file mode 100644 index 00000000000..416c1b9d7da --- /dev/null +++ b/addons/hr_work_entry_holidays/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models + +from odoo import api, SUPERUSER_ID + + +def _validate_existing_work_entry(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + env['hr.work.entry'].search([])._check_if_error() diff --git a/addons/hr_work_entry_holidays/__manifest__.py b/addons/hr_work_entry_holidays/__manifest__.py new file mode 100644 index 00000000000..c703b9a07cd --- /dev/null +++ b/addons/hr_work_entry_holidays/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + 'name': 'Time Off in Payslips', + 'version': '1.0', + 'category': 'Human Resources/Payroll', + 'sequence': 95, + 'summary': 'Manage Time Off in Payslips', + 'description': """ +Manage Time Off in Payslips +============================ + +This application allows you to integrate time off in payslips. + """, + 'depends': ['hr_holidays', 'hr_work_entry_contract'], + 'data': [ + 'views/hr_leave_views.xml', + ], + 'demo': ['data/hr_payroll_holidays_demo.xml'], + 'installable': True, + 'application': False, + 'auto_install': True, + 'post_init_hook': '_validate_existing_work_entry', +} diff --git a/addons/hr_work_entry_holidays/data/hr_payroll_holidays_data.xml b/addons/hr_work_entry_holidays/data/hr_payroll_holidays_data.xml new file mode 100644 index 00000000000..5487330c0f7 --- /dev/null +++ b/addons/hr_work_entry_holidays/data/hr_payroll_holidays_data.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/hr_work_entry_holidays/data/hr_payroll_holidays_demo.xml b/addons/hr_work_entry_holidays/data/hr_payroll_holidays_demo.xml new file mode 100644 index 00000000000..6dce0e9977d --- /dev/null +++ b/addons/hr_work_entry_holidays/data/hr_payroll_holidays_demo.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/hr_work_entry_holidays/i18n/hr_work_entry_holidays.pot b/addons/hr_work_entry_holidays/i18n/hr_work_entry_holidays.pot new file mode 100644 index 00000000000..4558d84da70 --- /dev/null +++ b/addons/hr_work_entry_holidays/i18n/hr_work_entry_holidays.pot @@ -0,0 +1,154 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_work_entry_holidays +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server saas~14.2+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-02-18 14:36+0000\n" +"PO-Revision-Date: 2021-02-18 14:36+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: hr_work_entry_holidays +#: model_terms:ir.actions.act_window,help:hr_work_entry_holidays.hr_leave_work_entry_action +msgid "" +"A great way to keep track on employee’s PTOs, sick days, and approval " +"status." +msgstr "" + +#. module: hr_work_entry_holidays +#: code:addons/hr_work_entry_holidays/models/hr_leave.py:0 +#, python-format +msgid "A leave cannot be set across multiple contracts." +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.actions.act_window,name:hr_work_entry_holidays.hr_leave_work_entry_action +msgid "All Time Off" +msgstr "" + +#. module: hr_work_entry_holidays +#: model_terms:ir.ui.view,arch_db:hr_work_entry_holidays.payroll_hr_work_entry_view_form_inherit +msgid "Approve Time Off" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_contract__display_name +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_leave__display_name +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_leave_type__display_name +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry__display_name +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry_type__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model,name:hr_work_entry_holidays.model_hr_contract +msgid "Employee Contract" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model,name:hr_work_entry_holidays.model_hr_work_entry +msgid "HR Work Entry" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model,name:hr_work_entry_holidays.model_hr_work_entry_type +msgid "HR Work Entry Type" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_contract__id +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_leave__id +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_leave_type__id +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry__id +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry_type__id +msgid "ID" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_contract____last_update +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_leave____last_update +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_leave_type____last_update +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry____last_update +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry_type____last_update +msgid "Last Modified on" +msgstr "" + +#. module: hr_work_entry_holidays +#: model_terms:ir.actions.act_window,help:hr_work_entry_holidays.hr_leave_work_entry_action +msgid "Meet the time off dashboard." +msgstr "" + +#. module: hr_work_entry_holidays +#: model_terms:ir.ui.view,arch_db:hr_work_entry_holidays.payroll_hr_work_entry_view_form_inherit +msgid "Refuse Time Off" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry__leave_state +msgid "Status" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model.fields,help:hr_work_entry_holidays.field_hr_work_entry__leave_state +msgid "" +"The status is set to 'To Submit', when a time off request is created.\n" +"The status is 'To Approve', when time off request is confirmed by user.\n" +"The status is 'Refused', when time off request is refused by manager.\n" +"The status is 'Approved', when time off request is approved by manager." +msgstr "" + +#. module: hr_work_entry_holidays +#: code:addons/hr_work_entry_holidays/models/hr_leave.py:0 +#, python-format +msgid "" +"There is no employee set on the time off. Please make sure you're logged in " +"the correct company." +msgstr "" + +#. module: hr_work_entry_holidays +#: model_terms:ir.ui.view,arch_db:hr_work_entry_holidays.payroll_hr_work_entry_view_form_inherit_contract +msgid "" +"This work entry cannot be validated. It is conflicting with at least one work entry.
\n" +" Two work entries of the same employee cannot overlap at the same time." +msgstr "" + +#. module: hr_work_entry_holidays +#: model_terms:ir.ui.view,arch_db:hr_work_entry_holidays.payroll_hr_work_entry_view_form_inherit_contract +msgid "" +"This work entry cannot be validated. There is a leave to approve (or refuse)" +" at the same time." +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model,name:hr_work_entry_holidays.model_hr_leave +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry__leave_id +msgid "Time Off" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model,name:hr_work_entry_holidays.model_hr_leave_type +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_work_entry_type__leave_type_ids +msgid "Time Off Type" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.ui.menu,name:hr_work_entry_holidays.menu_work_entry_leave_to_approve +msgid "Time Off to Report" +msgstr "" + +#. module: hr_work_entry_holidays +#: model_terms:ir.ui.view,arch_db:hr_work_entry_holidays.work_entry_type_leave_form_inherit +msgid "Work Entries" +msgstr "" + +#. module: hr_work_entry_holidays +#: model:ir.model.fields,field_description:hr_work_entry_holidays.field_hr_leave_type__work_entry_type_id +msgid "Work Entry Type" +msgstr "" diff --git a/addons/hr_work_entry_holidays/models/__init__.py b/addons/hr_work_entry_holidays/models/__init__.py new file mode 100644 index 00000000000..0aa5114f425 --- /dev/null +++ b/addons/hr_work_entry_holidays/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import hr_contract +from . import hr_leave +from . import hr_work_entry diff --git a/addons/hr_work_entry_holidays/models/hr_contract.py b/addons/hr_work_entry_holidays/models/hr_contract.py new file mode 100644 index 00000000000..fd52f6b964f --- /dev/null +++ b/addons/hr_work_entry_holidays/models/hr_contract.py @@ -0,0 +1,73 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz + +from datetime import date +from odoo import api, models + + +class HrContract(models.Model): + _inherit = 'hr.contract' + _description = 'Employee Contract' + + @api.constrains('date_start', 'date_end', 'state') + def _check_contracts(self): + self._get_leaves()._check_contracts() + + def _get_leaves(self): + return self.env['hr.leave'].search([ + ('employee_id', 'in', self.mapped('employee_id.id')), + ('date_from', '<=', max([end or date.max for end in self.mapped('date_end')])), + ('date_to', '>=', min(self.mapped('date_start'))), + ]) + + # override to add work_entry_type from leave + def _get_leave_work_entry_type(self, leave): + if leave.holiday_id: + return leave.holiday_id.holiday_status_id.work_entry_type_id + else: + return leave.work_entry_type_id + + # YTI TODO: Master remove the method (deprecated) + def _get_more_vals_leave(self, leave): + return [('leave_id', leave.holiday_id and leave.holiday_id.id)] + + def _get_more_vals_leave_interval(self, interval, leaves): + result = super()._get_more_vals_leave_interval(interval, leaves) + for leave in leaves: + if interval[0] >= leave[0] and interval[1] <= leave[1]: + result.append(('leave_id', leave[2].holiday_id.id)) + return result + + def _get_bypassing_work_entry_type(self): + return self.env['hr.work.entry.type'] + + def _get_interval_leave_work_entry_type(self, interval, leaves): + # returns the work entry time related to the leave that + # includes the whole interval. + # Overriden in hr_work_entry_contract_holiday to select the + # global time off first (eg: Public Holiday > Home Working) + interval_start = interval[0].astimezone(pytz.utc).replace(tzinfo=None) + interval_stop = interval[1].astimezone(pytz.utc).replace(tzinfo=None) + including_rcleaves = [l[2] for l in leaves if l[2] and interval_start >= l[2].date_from and interval_stop <= l[2].date_to] + including_global_rcleaves = [l for l in including_rcleaves if not l.holiday_id] + including_holiday_rcleaves = [l for l in including_rcleaves if l.holiday_id] + rc_leave = False + + # Example: In CP200: Long term sick > Public Holidays (which is global) + bypassing_work_entry_types = self._get_bypassing_work_entry_type() + if bypassing_work_entry_types: + bypassing_rc_leave = [l for l in including_holiday_rcleaves if l.holiday_id.holiday_status_id.work_entry_type_id in bypassing_work_entry_types] + else: + bypassing_rc_leave = [] + + if bypassing_rc_leave: + rc_leave = bypassing_rc_leave[0] + elif including_global_rcleaves: + rc_leave = including_global_rcleaves[0] + elif including_holiday_rcleaves: + rc_leave = including_holiday_rcleaves[0] + if rc_leave: + return self._get_leave_work_entry_type_dates(rc_leave, interval_start, interval_stop) + return self.env.ref('hr_work_entry_contract.work_entry_type_leave') diff --git a/addons/hr_work_entry_holidays/models/hr_leave.py b/addons/hr_work_entry_holidays/models/hr_leave.py new file mode 100644 index 00000000000..08f7fef5b62 --- /dev/null +++ b/addons/hr_work_entry_holidays/models/hr_leave.py @@ -0,0 +1,210 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +from datetime import datetime, date + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class HrLeaveType(models.Model): + _inherit = 'hr.leave.type' + + work_entry_type_id = fields.Many2one('hr.work.entry.type', string='Work Entry Type') + + +class HrLeave(models.Model): + _inherit = 'hr.leave' + + def _create_resource_leave(self): + """ + Add a resource leave in calendars of contracts running at the same period. + This is needed in order to compute the correct number of hours/days of the leave + according to the contract's calender. + """ + resource_leaves = super(HrLeave, self)._create_resource_leave() + for resource_leave in resource_leaves: + resource_leave.work_entry_type_id = resource_leave.holiday_id.holiday_status_id.work_entry_type_id.id + + resource_leave_values = [] + + for leave in self.filtered(lambda l: l.employee_id): + + contract = leave.employee_id.sudo()._get_contracts(leave.date_from, leave.date_to, states=['open']) + if contract and contract.resource_calendar_id != leave.employee_id.resource_calendar_id: + resource_leave_values += [{ + 'name': leave.name, + 'holiday_id': leave.id, + 'resource_id': leave.employee_id.resource_id.id, + 'work_entry_type_id': leave.holiday_status_id.work_entry_type_id.id, + 'time_type': leave.holiday_status_id.time_type, + 'date_from': max(leave.date_from, datetime.combine(contract.date_start, datetime.min.time())), + 'date_to': min(leave.date_to, datetime.combine(contract.date_end or date.max, datetime.max.time())), + 'calendar_id': contract.resource_calendar_id.id, + }] + + return resource_leaves | self.env['resource.calendar.leaves'].create(resource_leave_values) + + @api.constrains('date_from', 'date_to') + def _check_contracts(self): + """ + A leave cannot be set across multiple contracts. + Note: a leave can be across multiple contracts despite this constraint. + It happens if a leave is correctly created (not accross multiple contracts) but + contracts are later modifed/created in the middle of the leave. + """ + for holiday in self.filtered('employee_id'): + domain = [ + ('employee_id', '=', holiday.employee_id.id), + ('date_start', '<=', holiday.date_to), + '|', + ('state', 'not in', ['draft', 'cancel']), + '&', + ('state', '=', 'draft'), + ('kanban_state', '=', 'done'), + '|', + ('date_end', '>=', holiday.date_from), + '&', + ('date_end', '=', False), + ('state', '!=', 'close') + ] + nbr_contracts = self.env['hr.contract'].sudo().search_count(domain) + if nbr_contracts > 1: + contracts = self.env['hr.contract'].sudo().search(domain) + raise ValidationError(_('A leave cannot be set across multiple contracts.') + '\n' + ', '.join(contracts.mapped('name'))) + + def _cancel_work_entry_conflict(self): + """ + Creates a leave work entry for each hr.leave in self. + Check overlapping work entries with self. + Work entries completely included in a leave are archived. + e.g.: + |----- work entry ----|---- work entry ----| + |------------------- hr.leave ---------------| + || + vv + |----* work entry ****| + |************ work entry leave --------------| + """ + if not self: + return + + # 1. Create a work entry for each leave + work_entries_vals_list = [] + for leave in self: + contracts = leave.employee_id.sudo()._get_contracts(leave.date_from, leave.date_to, states=['open', 'close']) + for contract in contracts: + # Generate only if it has aleady been generated + if leave.date_to >= contract.date_generated_from and leave.date_from <= contract.date_generated_to: + work_entries_vals_list += contracts._get_work_entries_values(leave.date_from, leave.date_to) + + new_leave_work_entries = self.env['hr.work.entry'].create(work_entries_vals_list) + + if new_leave_work_entries: + # 2. Fetch overlapping work entries, grouped by employees + start = min(self.mapped('date_from'), default=False) + stop = max(self.mapped('date_to'), default=False) + work_entry_groups = self.env['hr.work.entry'].read_group([ + ('date_start', '<', stop), + ('date_stop', '>', start), + ('employee_id', 'in', self.employee_id.ids), + ], ['work_entry_ids:array_agg(id)', 'employee_id'], ['employee_id', 'date_start', 'date_stop'], lazy=False) + work_entries_by_employee = defaultdict(lambda: self.env['hr.work.entry']) + for group in work_entry_groups: + employee_id = group.get('employee_id')[0] + work_entries_by_employee[employee_id] |= self.env['hr.work.entry'].browse(group.get('work_entry_ids')) + + # 3. Archive work entries included in leaves + included = self.env['hr.work.entry'] + overlappping = self.env['hr.work.entry'] + for work_entries in work_entries_by_employee.values(): + # Work entries for this employee + new_employee_work_entries = work_entries & new_leave_work_entries + previous_employee_work_entries = work_entries - new_leave_work_entries + + # Build intervals from work entries + leave_intervals = new_employee_work_entries._to_intervals() + conflicts_intervals = previous_employee_work_entries._to_intervals() + + # Compute intervals completely outside any leave + # Intervals are outside, but associated records are overlapping. + outside_intervals = conflicts_intervals - leave_intervals + + overlappping |= self.env['hr.work.entry']._from_intervals(outside_intervals) + included |= previous_employee_work_entries - overlappping + overlappping.write({'leave_id': False}) + included.write({'active': False}) + + def write(self, vals): + if not self: + return True + skip_check = not bool({'employee_id', 'state', 'date_from', 'date_to'} & vals.keys()) + + start = min(self.mapped('date_from') + [fields.Datetime.from_string(vals.get('date_from', False)) or datetime.max]) + stop = max(self.mapped('date_to') + [fields.Datetime.from_string(vals.get('date_to', False)) or datetime.min]) + with self.env['hr.work.entry']._error_checking(start=start, stop=stop, skip=skip_check): + return super().write(vals) + + @api.model_create_multi + def create(self, vals_list): + start_dates = [v.get('date_from') for v in vals_list if v.get('date_from')] + stop_dates = [v.get('date_to') for v in vals_list if v.get('date_to')] + if any(vals.get('holiday_type', 'employee') == 'employee' and not vals.get('employee_id', False) for vals in vals_list): + raise ValidationError(_("There is no employee set on the time off. Please make sure you're logged in the correct company.")) + with self.env['hr.work.entry']._error_checking(start=min(start_dates, default=False), stop=max(stop_dates, default=False)): + return super().create(vals_list) + + def action_confirm(self): + start = min(self.mapped('date_from'), default=False) + stop = max(self.mapped('date_to'), default=False) + with self.env['hr.work.entry']._error_checking(start=start, stop=stop): + return super().action_confirm() + + def action_validate(self): + super(HrLeave, self).action_validate() + self.sudo()._cancel_work_entry_conflict() # delete preexisting conflicting work_entries + return True + + def action_refuse(self): + """ + Override to archive linked work entries and recreate attendance work entries + where the refused leave was. + """ + res = super(HrLeave, self).action_refuse() + work_entries = self.env['hr.work.entry'].sudo().search([('leave_id', 'in', self.ids)]) + + work_entries.write({'active': False}) + # Re-create attendance work entries + vals_list = [] + for work_entry in work_entries: + vals_list += work_entry.contract_id._get_work_entries_values(work_entry.date_start, work_entry.date_stop) + self.env['hr.work.entry'].create(vals_list) + return res + + def _get_number_of_days(self, date_from, date_to, employee_id): + """ If an employee is currently working full time but asks for time off next month + where he has a new contract working only 3 days/week. This should be taken into + account when computing the number of days for the leave (2 weeks leave = 6 days). + Override this method to get number of days according to the contract's calendar + at the time of the leave. + """ + days = super(HrLeave, self)._get_number_of_days(date_from, date_to, employee_id) + if employee_id: + employee = self.env['hr.employee'].browse(employee_id) + # Use sudo otherwise base users can't compute number of days + contracts = employee.sudo()._get_contracts(date_from, date_to, states=['open']) + contracts |= employee.sudo()._get_incoming_contracts(date_from, date_to) + calendar = contracts[:1].resource_calendar_id if contracts else None # Note: if len(contracts)>1, the leave creation will crash because of unicity constaint + return employee._get_work_days_data_batch(date_from, date_to, calendar=calendar)[employee.id] + + return days + + def _get_calendar(self): + self.ensure_one() + if self.date_from and self.date_to: + contracts = self.employee_id.sudo()._get_contracts(self.date_from, self.date_to, states=['open']) + contracts |= self.employee_id.sudo()._get_incoming_contracts(self.date_from, self.date_to) + contract_calendar = contracts[:1].resource_calendar_id if contracts else None + return contract_calendar or self.employee_id.resource_calendar_id or self.env.company.resource_calendar_id + return super()._get_calendar() diff --git a/addons/hr_work_entry_holidays/models/hr_work_entry.py b/addons/hr_work_entry_holidays/models/hr_work_entry.py new file mode 100644 index 00000000000..87add98f8ae --- /dev/null +++ b/addons/hr_work_entry_holidays/models/hr_work_entry.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class HrWorkEntry(models.Model): + _inherit = 'hr.work.entry' + + leave_id = fields.Many2one('hr.leave', string='Time Off') + leave_state = fields.Selection(related='leave_id.state') + + def _get_duration(self, date_start, date_stop): + if not date_start or not date_stop: + return 0 + if not self.work_entry_type_id and self.leave_id: + calendar = self.contract_id.resource_calendar_id + employee = self.contract_id.employee_id + contract_data = employee._get_work_days_data_batch( + date_start, date_stop, compute_leaves=False, calendar=calendar)[employee.id] + return contract_data.get('hours', 0) + return super()._get_duration(date_start, date_stop) + + def write(self, vals): + if 'state' in vals and vals['state'] == 'cancelled': + self.mapped('leave_id').filtered(lambda l: l.state != 'refuse').action_refuse() + return super().write(vals) + + def _reset_conflicting_state(self): + super()._reset_conflicting_state() + attendances = self.filtered(lambda w: w.work_entry_type_id and not w.work_entry_type_id.is_leave) + attendances.write({'leave_id': False}) + + def _check_if_error(self): + res = super()._check_if_error() + conflict_with_leaves = self._compute_conflicts_leaves_to_approve() + return res or conflict_with_leaves + + def _compute_conflicts_leaves_to_approve(self): + if not self: + return False + + self.flush(['date_start', 'date_stop', 'employee_id']) + self.env['hr.leave'].flush(['date_from', 'date_to', 'state', 'employee_id']) + + query = """ + SELECT + b.id AS work_entry_id, + l.id AS leave_id + FROM hr_work_entry b + INNER JOIN hr_leave l ON b.employee_id = l.employee_id + WHERE + b.active = TRUE AND + b.id IN %s AND + l.date_from < b.date_stop AND + l.date_to > b.date_start AND + l.state IN ('confirm', 'validate1'); + """ + self.env.cr.execute(query, [tuple(self.ids)]) + conflicts = self.env.cr.dictfetchall() + for res in conflicts: + self.browse(res.get('work_entry_id')).write({ + 'state': 'conflict', + 'leave_id': res.get('leave_id') + }) + return bool(conflicts) + + def action_approve_leave(self): + self.ensure_one() + if self.leave_id: + # Already confirmed once + if self.leave_id.state == 'validate1': + self.leave_id.action_validate() + # Still in confirmed state + else: + self.leave_id.action_approve() + # If double validation, still have to validate it again + if self.leave_id.validation_type == 'both': + self.leave_id.action_validate() + + def action_refuse_leave(self): + self.ensure_one() + leave_sudo = self.leave_id.sudo() + if leave_sudo: + leave_sudo.action_refuse() + + +class HrWorkEntryType(models.Model): + _inherit = 'hr.work.entry.type' + _description = 'HR Work Entry Type' + + leave_type_ids = fields.One2many('hr.leave.type', 'work_entry_type_id', string='Time Off Type') diff --git a/addons/hr_work_entry_holidays/tests/__init__.py b/addons/hr_work_entry_holidays/tests/__init__.py new file mode 100644 index 00000000000..14db6584d1c --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_leave +from . import test_multi_contract +from . import test_payslip_holidays_computation +from . import test_performance +from . import test_work_entry diff --git a/addons/hr_work_entry_holidays/tests/common.py b/addons/hr_work_entry_holidays/tests/common.py new file mode 100644 index 00000000000..082670d04f4 --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/common.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo.fields import Datetime +from odoo.addons.hr_work_entry_contract.tests.common import TestWorkEntryBase + + +class TestWorkEntryHolidaysBase(TestWorkEntryBase): + + @classmethod + def setUpClass(cls): + super(TestWorkEntryHolidaysBase, cls).setUpClass() + + cls.leave_type = cls.env['hr.leave.type'].create({ + 'name': 'Legal Leaves', + 'time_type': 'leave', + 'allocation_type': 'no', + 'validity_start': False, + 'work_entry_type_id': cls.work_entry_type_leave.id + }) + + # I create a new employee "Jules" + cls.jules_emp = cls.env['hr.employee'].create({ + 'name': 'Jules', + 'gender': 'male', + 'birthday': '1984-05-01', + 'country_id': cls.env.ref('base.be').id, + 'department_id': cls.dep_rd.id, + }) + + cls.calendar_35h = cls.env['resource.calendar'].create({ + 'name': '35h calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Evening', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Evening', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Evening', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Evening', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Evening', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}) + ] + }) + cls.calendar_35h._onchange_hours_per_day() # update hours/day + cls.calendar_40h = cls.env['resource.calendar'].create({'name': 'Default calendar'}) + + # This contract ends at the 15th of the month + cls.contract_cdd = cls.env['hr.contract'].create({ # Fixed term contract + 'date_end': datetime.strptime('2015-11-15', '%Y-%m-%d'), + 'date_start': datetime.strptime('2015-01-01', '%Y-%m-%d'), + 'name': 'First CDD Contract for Jules', + 'resource_calendar_id': cls.calendar_40h.id, + 'wage': 5000.0, + 'employee_id': cls.jules_emp.id, + 'state': 'open', + 'kanban_state': 'blocked', + 'date_generated_from': datetime.strptime('2015-11-16', '%Y-%m-%d'), + 'date_generated_to': datetime.strptime('2015-11-16', '%Y-%m-%d'), + }) + + # This contract starts the next day + cls.contract_cdi = cls.env['hr.contract'].create({ + 'date_start': datetime.strptime('2015-11-16', '%Y-%m-%d'), + 'name': 'Contract for Jules', + 'resource_calendar_id': cls.calendar_35h.id, + 'wage': 5000.0, + 'employee_id': cls.jules_emp.id, + 'state': 'open', + 'kanban_state': 'normal', + 'date_generated_from': datetime.strptime('2015-11-15', '%Y-%m-%d'), + 'date_generated_to': datetime.strptime('2015-11-15', '%Y-%m-%d'), + }) + + @classmethod + def create_leave(cls, date_from=None, date_to=None): + date_from = date_from or Datetime.today() + date_to = date_to or Datetime.today() + relativedelta(days=1) + return cls.env['hr.leave'].create({ + 'name': 'Holiday !!!', + 'employee_id': cls.richard_emp.id, + 'holiday_status_id': cls.leave_type.id, + 'date_to': date_to, + 'date_from': date_from, + 'number_of_days': 1, + }) diff --git a/addons/hr_work_entry_holidays/tests/test_leave.py b/addons/hr_work_entry_holidays/tests/test_leave.py new file mode 100644 index 00000000000..6ff4e227f24 --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/test_leave.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo.addons.hr_work_entry_holidays.tests.common import TestWorkEntryHolidaysBase + + +class TestWorkEntryLeave(TestWorkEntryHolidaysBase): + + def test_resource_leave_has_work_entry_type(self): + leave = self.create_leave() + + resource_leave = leave._create_resource_leave() + self.assertEqual(resource_leave.work_entry_type_id, self.leave_type.work_entry_type_id, "it should have the corresponding work_entry type") + + def test_resource_leave_in_contract_calendar(self): + other_calendar = self.env['resource.calendar'].create({'name': 'New calendar'}) + contract = self.richard_emp.contract_ids[0] + contract.resource_calendar_id = other_calendar + contract.state = 'open' # this set richard's calendar to New calendar + leave = self.create_leave() + + resource_leave = leave._create_resource_leave() + self.assertEqual(len(resource_leave), 1, "it should have created only one resource leave") + self.assertEqual(resource_leave.work_entry_type_id, self.leave_type.work_entry_type_id, "it should have the corresponding work_entry type") + + def test_resource_leave_different_calendars(self): + other_calendar = self.env['resource.calendar'].create({'name': 'New calendar'}) + contract = self.richard_emp.contract_ids[0] + contract.resource_calendar_id = other_calendar + contract.state = 'open' # this set richard's calendar to New calendar + + # set another calendar + self.richard_emp.resource_calendar_id = self.env['resource.calendar'].create({'name': 'Other calendar'}) + + leave = self.create_leave() + resource_leave = leave._create_resource_leave() + self.assertEqual(len(resource_leave), 2, "it should have created one resource leave per calendar") + self.assertEqual(resource_leave.mapped('work_entry_type_id'), self.leave_type.work_entry_type_id, "they should have the corresponding work_entry type") + + def test_create_mark_conflicting_work_entries(self): + work_entry = self.create_work_entry(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 10, 12, 0)) + self.assertNotEqual(work_entry.state, 'conflict', "It should not be conflicting") + leave = self.create_leave(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 10, 18, 0)) + self.assertEqual(work_entry.state, 'conflict', "It should be conflicting") + self.assertEqual(work_entry.leave_id, leave, "It should be linked to conflicting leave") + + def test_write_mark_conflicting_work_entries(self): + leave = self.create_leave(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 10, 12, 0)) + work_entry = self.create_work_entry(datetime(2019, 10, 9, 9, 0), datetime(2019, 10, 10, 9, 0)) # the day before + self.assertNotEqual(work_entry.state, 'conflict', "It should not be conflicting") + leave.date_from = datetime(2019, 10, 9, 9, 0) # now it conflicts + self.assertEqual(work_entry.state, 'conflict', "It should be conflicting") + self.assertEqual(work_entry.leave_id, leave, "It should be linked to conflicting leave") + + def test_validate_leave_with_overlap(self): + contract = self.richard_emp.contract_ids[:1] + contract.state = 'open' + contract.date_generated_from = datetime(2019, 10, 10, 9, 0) + contract.date_generated_to = datetime(2019, 10, 10, 9, 0) + leave = self.create_leave(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 12, 18, 0)) + work_entry_1 = self.create_work_entry(datetime(2019, 10, 8, 9, 0), datetime(2019, 10, 11, 9, 0)) # overlaps + work_entry_2 = self.create_work_entry(datetime(2019, 10, 11, 9, 0), datetime(2019, 10, 11, 10, 0)) # included + adjacent_work_entry = self.create_work_entry(datetime(2019, 10, 12, 18, 0), datetime(2019, 10, 13, 18, 0)) # after and don't overlap + leave.action_validate() + self.assertNotEqual(adjacent_work_entry.state, 'conflict', "It should not conflict") + self.assertFalse(work_entry_2.active, "It should have been archived") + self.assertEqual(work_entry_1.state, 'conflict', "It should conflict") + self.assertFalse(work_entry_1.leave_id, "It should not be linked to the leave") + + leave_work_entry = self.env['hr.work.entry'].search([('leave_id', '=', leave.id)]) - work_entry_1 + self.assertTrue(leave_work_entry.work_entry_type_id.is_leave, "It should have created a leave work entry") + self.assertEqual(leave_work_entry[:1].state, 'conflict', "The leave work entry should conflict") + + def test_conflict_move_work_entry(self): + leave = self.create_leave(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 12, 18, 0)) + work_entry = self.create_work_entry(datetime(2019, 10, 8, 9, 0), datetime(2019, 10, 11, 9, 0)) # overlaps + self.assertEqual(work_entry.state, 'conflict', "It should be conflicting") + self.assertEqual(work_entry.leave_id, leave, "It should be linked to conflicting leave") + work_entry.date_stop = datetime(2019, 10, 9, 9, 0) # no longer overlaps + self.assertNotEqual(work_entry.state, 'conflict', "It should not be conflicting") + self.assertFalse(work_entry.leave_id, "It should not be linked to any leave") + + def test_validate_leave_without_overlap(self): + contract = self.richard_emp.contract_ids[:1] + contract.state = 'open' + contract.date_generated_from = datetime(2019, 10, 10, 9, 0) + contract.date_generated_to = datetime(2019, 10, 10, 9, 0) + leave = self.create_leave(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 12, 18, 0)) + work_entry = self.create_work_entry(datetime(2019, 10, 11, 9, 0), datetime(2019, 10, 11, 10, 0)) # included + leave.action_validate() + self.assertFalse(work_entry[:1].active, "It should have been archived") + + leave_work_entry = self.env['hr.work.entry'].search([('leave_id', '=', leave.id)]) + self.assertTrue(leave_work_entry.work_entry_type_id.is_leave, "It should have created a leave work entry") + self.assertNotEqual(leave_work_entry[:1].state, 'conflict', "The leave work entry should not conflict") + + def test_refuse_leave(self): + leave = self.create_leave(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 10, 18, 0)) + work_entries = self.richard_emp.contract_id._generate_work_entries(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 10, 18, 0)) + adjacent_work_entry = self.create_work_entry(datetime(2019, 10, 7, 9, 0), datetime(2019, 10, 10, 9, 0)) + self.assertTrue(all(work_entries.mapped(lambda w: w.state == 'conflict')), "Attendance work entries should all conflict with the leave") + self.assertNotEqual(adjacent_work_entry.state, 'conflict', "Non overlapping work entry should not conflict") + leave.action_refuse() + self.assertTrue(all(work_entries.mapped(lambda w: w.state != 'conflict')), "Attendance work entries should no longer conflict") + self.assertNotEqual(adjacent_work_entry.state, 'conflict', "Non overlapping work entry should not conflict") + + def test_refuse_approved_leave(self): + start = datetime(2019, 10, 10, 6, 0) + end = datetime(2019, 10, 10, 18, 0) + + # Setup contract generation state + contract = self.richard_emp.contract_ids[:1] + contract.state = 'open' + contract.date_generated_from = start - relativedelta(hours=1) + contract.date_generated_to = start - relativedelta(hours=1) + + leave = self.create_leave(start, end) + leave.action_validate() + work_entries = self.env['hr.work.entry'].search([('employee_id', '=', self.richard_emp.id), ('date_start', '<=', end), ('date_stop', '>=', start)]) + leave_work_entry = self.richard_emp.contract_ids._generate_work_entries(start, end) + self.assertEqual(leave_work_entry[:1].leave_id, leave) + leave.action_refuse() + work_entries = self.env['hr.work.entry'].search([('employee_id', '=', self.richard_emp.id), ('date_start', '>=', start), ('date_stop', '<=', end)]) + self.assertFalse(leave_work_entry[:1].filtered('leave_id').active) + self.assertEqual(len(work_entries), 2, "Attendance work entries should have been re-created (morning and afternoon)") + self.assertTrue(all(work_entries.mapped(lambda w: w.state != 'conflict')), "Attendance work entries should not conflict") + + def test_archived_work_entry_conflict(self): + self.create_leave(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 10, 18, 0)) + work_entry = self.create_work_entry(datetime(2019, 10, 10, 9, 0), datetime(2019, 10, 10, 18, 0)) + self.assertTrue(work_entry.active) + self.assertEqual(work_entry.state, 'conflict', "Attendance work entries should conflict with the leave") + work_entry.toggle_active() + self.assertEqual(work_entry.state, 'cancelled', "Attendance work entries should be cancelled and not conflict") + self.assertFalse(work_entry.active) diff --git a/addons/hr_work_entry_holidays/tests/test_multi_contract.py b/addons/hr_work_entry_holidays/tests/test_multi_contract.py new file mode 100644 index 00000000000..06eadd45517 --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/test_multi_contract.py @@ -0,0 +1,120 @@ +# # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from odoo.exceptions import ValidationError +from odoo.addons.hr_work_entry_holidays.tests.common import TestWorkEntryHolidaysBase + + +class TestWorkEntryHolidaysMultiContract(TestWorkEntryHolidaysBase): + + def setUp(self): + super().setUp() + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Legal Leaves', + 'time_type': 'leave', + 'allocation_type': 'no', + 'validity_start': False, + 'work_entry_type_id': self.work_entry_type_leave.id + }) + + def create_leave(self, start, end): + work_days_data = self.jules_emp._get_work_days_data_batch(start, end) + return self.env['hr.leave'].create({ + 'name': 'Doctor Appointment', + 'employee_id': self.jules_emp.id, + 'holiday_status_id': self.leave_type.id, + 'date_from': start, + 'date_to': end, + 'number_of_days': work_days_data[self.jules_emp.id]['days'], + }) + + def test_multi_contract_holiday(self): + # Leave during second contract + leave = self.create_leave(datetime(2015, 11, 17, 7, 0), datetime(2015, 11, 20, 18, 0)) + leave.action_approve() + start = datetime.strptime('2015-11-01', '%Y-%m-%d') + end_generate = datetime(2015, 11, 30, 23, 59, 59) + work_entries = self.jules_emp.contract_ids._generate_work_entries(start, end_generate) + work_entries.action_validate() + work_entries = work_entries.filtered(lambda we: we.contract_id == self.contract_cdi) + + work = work_entries.filtered(lambda line: line.work_entry_type_id == self.env.ref('hr_work_entry.work_entry_type_attendance')) + leave = work_entries.filtered(lambda line: line.work_entry_type_id == self.work_entry_type_leave) + self.assertEqual(sum(work.mapped('duration')), 49, "It should be 49 hours of work this month for this contract") + self.assertEqual(sum(leave.mapped('duration')), 28, "It should be 28 hours of leave this month for this contract") + + def test_move_contract_in_leave(self): + # test move contract dates such that a leave is accross two contracts + start = datetime.strptime('2015-11-05 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-12-15 18:00:00', '%Y-%m-%d %H:%M:%S') + self.contract_cdi.write({'date_start': datetime.strptime('2015-12-30', '%Y-%m-%d').date()}) + # begins during contract, ends after contract + leave = self.create_leave(start, end) + leave.action_approve() + # move contract in the middle of the leave + with self.assertRaises(ValidationError): + self.contract_cdi.date_start = datetime.strptime('2015-11-17', '%Y-%m-%d').date() + + def test_create_contract_in_leave(self): + # test create contract such that a leave is accross two contracts + start = datetime.strptime('2015-11-05 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-12-15 18:00:00', '%Y-%m-%d %H:%M:%S') + self.contract_cdi.date_start = datetime.strptime('2015-12-30', '%Y-%m-%d').date() # remove this contract to be able to create the leave + # begins during contract, ends after contract + leave = self.create_leave(start, end) + leave.action_approve() + # move contract in the middle of the leave + with self.assertRaises(ValidationError): + self.env['hr.contract'].create({ + 'date_start': datetime.strptime('2015-11-30', '%Y-%m-%d').date(), + 'name': 'Contract for Richard', + 'resource_calendar_id': self.calendar_40h.id, + 'wage': 5000.0, + 'employee_id': self.jules_emp.id, + 'state': 'open', + 'date_generated_from': datetime.strptime('2015-11-30', '%Y-%m-%d'), + 'date_generated_to': datetime.strptime('2015-11-30', '%Y-%m-%d'), + }) + + def test_leave_outside_contract(self): + # Leave outside contract => should not raise + start = datetime.strptime('2014-10-18 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2014-10-20 09:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + # begins before contract, ends during contract => should not raise + start = datetime.strptime('2014-10-25 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-01-15 18:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + # begins during contract, ends after contract => should not raise + self.contract_cdi.date_end = datetime.strptime('2015-11-30', '%Y-%m-%d').date() + start = datetime.strptime('2015-11-25 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-12-5 18:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + def test_no_leave_overlapping_contracts(self): + with self.assertRaises(ValidationError): + # Overlap two contracts + start = datetime.strptime('2015-11-12 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-17 18:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + # Leave inside fixed term contract => should not raise + start = datetime.strptime('2015-11-04 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-07 09:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + # Leave inside contract (no end) => should not raise + start = datetime.strptime('2015-11-18 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-20 09:00:00', '%Y-%m-%d %H:%M:%S') + self.create_leave(start, end) + + def test_leave_request_next_contracts(self): + start = datetime.strptime('2015-11-23 07:00:00', '%Y-%m-%d %H:%M:%S') + end = datetime.strptime('2015-11-24 18:00:00', '%Y-%m-%d %H:%M:%S') + leave = self.create_leave(start, end) + + leave._compute_number_of_hours_display() + self.assertEqual(leave.number_of_hours_display, 14, "It should count hours according to the future contract.") diff --git a/addons/hr_work_entry_holidays/tests/test_payslip_holidays_computation.py b/addons/hr_work_entry_holidays/tests/test_payslip_holidays_computation.py new file mode 100644 index 00000000000..96362495c0e --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/test_payslip_holidays_computation.py @@ -0,0 +1,40 @@ +# # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, date + +from odoo.addons.hr_work_entry_holidays.tests.common import TestWorkEntryHolidaysBase + + +class TestPayslipHolidaysComputation(TestWorkEntryHolidaysBase): + + def setUp(self): + super().setUp() + + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Legal Leaves', + 'time_type': 'leave', + 'allocation_type': 'no', + 'validity_start': False, + 'work_entry_type_id': self.work_entry_type_leave.id + }) + + def test_work_data(self): + start = datetime(2015, 11, 8, 8, 0) + end = datetime(2015, 11, 10, 22, 0) + work_days_data = self.jules_emp._get_work_days_data_batch(start, end) + leave = self.env['hr.leave'].create({ + 'name': 'Doctor Appointment', + 'employee_id': self.jules_emp.id, + 'holiday_status_id': self.leave_type.id, + 'date_from': start, + 'date_to': end, + 'number_of_days': work_days_data[self.jules_emp.id]['days'], + }) + leave.action_approve() + + work_entries = self.jules_emp.contract_ids._generate_work_entries(date(2015, 11, 10), date(2015, 11, 21)) + work_entries.action_validate() + work_entries = work_entries.filtered(lambda we: we.work_entry_type_id in self.env.ref('hr_work_entry.work_entry_type_attendance')) + sum_hours = sum(work_entries.mapped('duration')) + self.assertEqual(sum_hours, 59, 'It should count 59 attendance hours') # 24h first contract + 35h second contract diff --git a/addons/hr_work_entry_holidays/tests/test_performance.py b/addons/hr_work_entry_holidays/tests/test_performance.py new file mode 100644 index 00000000000..0c8e0086050 --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/test_performance.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date, datetime + +from odoo.addons.hr_work_entry_holidays.tests.common import TestWorkEntryHolidaysBase +from odoo.tests.common import users, warmup, tagged + + +class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase): + + @classmethod + def setUpClass(cls): + super(TestWorkEntryHolidaysPerformance, cls).setUpClass() + cls.jack = cls.env['hr.employee'].create({'name': 'Jack'}) + cls.employees = cls.richard_emp | cls.jack + + cls.env['hr.contract'].create([{ + 'date_start': date(2018, 1, 1), + 'date_end': date(2018, 2, 1), + 'name': 'Contract for %s' % employee.name, + 'wage': 5000.0, + 'state': 'open', + 'employee_id': employee.id, + 'date_generated_from': datetime(2018, 1, 1, 0, 0), + 'date_generated_to': datetime(2018, 1, 1, 0, 0), + } for employee in cls.employees]) + + @users('__system__', 'admin') + @warmup + def test_performance_leave_validate(self): + self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2)) + leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) + + with self.assertQueryCount(__system__=90, admin=99): + leave.action_validate() + leave.action_refuse() + + @users('__system__', 'admin') + @warmup + def test_performance_leave_write(self): + leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) + + with self.assertQueryCount(__system__=24, admin=24): + leave.date_to = datetime(2018, 1, 1, 19, 0) + leave.action_refuse() + + @users('__system__', 'admin') + @warmup + def test_performance_leave_create(self): + with self.assertQueryCount(__system__=28, admin=59): + leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) + leave.action_refuse() + + @users('__system__', 'admin') + @warmup + def test_performance_leave_confirm(self): + leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) + leave.action_draft() + with self.assertQueryCount(__system__=22, admin=55): + leave.action_confirm() + leave.state = 'cancel' + + +@tagged('work_entry_perf') +class TestWorkEntryHolidaysPerformancesBigData(TestWorkEntryHolidaysBase): + + @classmethod + def setUpClass(cls): + super(TestWorkEntryHolidaysPerformancesBigData, cls).setUpClass() + cls.company = cls.env['res.company'].create({'name': 'A company'}) + + cls.paid_time_off = cls.env['hr.leave.type'].create({ + 'name': 'Paid Time Off', + 'request_unit': 'day', + 'leave_validation_type': 'both', + 'company_id': cls.company.id, + }) + + cls.employees = cls.env['hr.employee'].create([{ + 'name': 'Employee %s' % i, + 'company_id': cls.company.id + } for i in range(100)]) + + cls.contracts = cls.env['hr.contract'].create([{ + 'date_start': date(2018, 1, 1), + 'date_end': False, + 'name': 'Contract for %s' % employee.name, + 'wage': 5000.0, + 'state': 'open', + 'employee_id': employee.id, + 'date_generated_from': datetime(2018, 1, 1, 0, 0), + 'date_generated_to': datetime(2018, 1, 1, 0, 0), + } for employee in cls.employees]) + + cls.leaves = cls.env['hr.leave'].create([{ + 'name': 'Holiday - %s' % employee.name, + 'employee_id': employee.id, + 'holiday_status_id': cls.paid_time_off.id, + 'request_date_from': date(2020, 8, 3), + 'request_date_to': date(2020, 8, 7), + 'number_of_days': 5, + } for employee in cls.employees]) + cls.leaves._compute_date_from_to() + cls.leaves.action_approve() + cls.leaves.action_validate() + + + def test_work_entries_generation_perf(self): + # Test Case 7: Try to generate work entries for + # a hundred employees over a month + with self.assertQueryCount(__system__=11522, admin=11522): + work_entries = self.contracts._generate_work_entries(date(2020, 7, 1), date(2020, 8, 31)) + + # Original work entries to generate when we don't adapt date_generated_from and + # date_generated_to when they are equal for old contracts: 138300 + self.assertEqual(len(work_entries), 8800) diff --git a/addons/hr_work_entry_holidays/tests/test_work_entry.py b/addons/hr_work_entry_holidays/tests/test_work_entry.py new file mode 100644 index 00000000000..56403c69587 --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/test_work_entry.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, date +from dateutil.relativedelta import relativedelta +import pytz + +from odoo.tests.common import tagged +from odoo.fields import Date, Datetime +from odoo.addons.hr_work_entry_holidays.tests.common import TestWorkEntryHolidaysBase + + +@tagged('work_entry') +class TestWorkeEntryHolidaysWorkEntry(TestWorkEntryHolidaysBase): + def setUp(self): + super(TestWorkeEntryHolidaysWorkEntry, self).setUp() + self.tz = pytz.timezone(self.richard_emp.tz) + self.start = datetime(2015, 11, 1, 1, 0, 0) + self.end = datetime(2015, 11, 30, 23, 59, 59) + self.resource_calendar_id = self.env['resource.calendar'].create({'name': 'Zboub'}) + contract = self.env['hr.contract'].create({ + 'date_start': self.start.date() - relativedelta(days=5), + 'name': 'dodo', + 'resource_calendar_id': self.resource_calendar_id.id, + 'wage': 1000, + 'employee_id': self.richard_emp.id, + 'state': 'open', + 'date_generated_from': self.end.date() + relativedelta(days=5), + }) + self.richard_emp.resource_calendar_id = self.resource_calendar_id + self.richard_emp.contract_id = contract + + def test_validate_non_approved_leave_work_entry(self): + work_entry1 = self.env['hr.work.entry'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'work_entry_type_id': self.work_entry_type_leave.id, + 'contract_id': self.richard_emp.contract_id.id, + 'date_start': self.start, + 'date_stop': self.end, + }) + self.env['hr.leave'].create({ + 'name': 'Doctor Appointment', + 'employee_id': self.richard_emp.id, + 'holiday_status_id': self.leave_type.id, + 'date_from': self.start - relativedelta(days=1), + 'date_to': self.start + relativedelta(days=1), + 'number_of_days': 2, + }) + self.assertFalse(work_entry1.action_validate(), "It should not validate work_entries conflicting with non approved leaves") + self.assertEqual(work_entry1.state, 'conflict') + + def test_refuse_leave_work_entry(self): + start = datetime(2015, 11, 1, 9, 0, 0) + end = datetime(2015, 11, 3, 13, 0, 0) + leave = self.env['hr.leave'].create({ + 'name': 'Doctor Appointment', + 'employee_id': self.richard_emp.id, + 'holiday_status_id': self.leave_type.id, + 'date_from': start, + 'date_to': start + relativedelta(days=1), + 'number_of_days': 2, + }) + work_entry = self.env['hr.work.entry'].create({ + 'name': '1', + 'employee_id': self.richard_emp.id, + 'contract_id': self.richard_emp.contract_id.id, + 'work_entry_type_id': self.work_entry_type.id, + 'date_start': start, + 'date_stop': end, + 'leave_id': leave.id + }) + work_entry.action_validate() + self.assertEqual(work_entry.state, 'conflict', "It should have an error (conflicting leave to approve") + leave.action_refuse() + self.assertNotEqual(work_entry.state, 'conflict', "It should not have an error") + + def test_time_week_leave_work_entry(self): + # /!\ this is a week day => it exists an calendar attendance at this time + start = datetime(2015, 11, 2, 10, 0, 0) + end = datetime(2015, 11, 2, 17, 0, 0) + work_days_data = self.jules_emp._get_work_days_data_batch(start, end) + leave = self.env['hr.leave'].create({ + 'name': '1leave', + 'employee_id': self.richard_emp.id, + 'holiday_status_id': self.leave_type.id, + 'date_from': start, + 'date_to': end, + 'number_of_days': work_days_data[self.jules_emp.id]['days'], + }) + leave.action_validate() + + work_entries = self.richard_emp.contract_id._generate_work_entries(self.start, self.end) + work_entries.action_validate() + leave_work_entry = work_entries.filtered(lambda we: we.work_entry_type_id in self.work_entry_type_leave) + sum_hours = sum(leave_work_entry.mapped('duration')) + + self.assertEqual(sum_hours, 5.0, "It should equal the number of hours richard should have worked") + + def test_contract_on_another_company(self): + """ Test that the work entry generation still work if + the contract is not on the same company than + the employee (Internal Use Case) + So when generating the work entries in Belgium, + there is an issue when accessing to the time off + in Hong Kong. + """ + company = self.env['res.company'].create({'name': 'Another Company'}) + + employee = self.env['hr.employee'].create({ + 'name': 'New Employee', + 'company_id': company.id, + }) + + self.env['hr.contract'].create({ + 'name': 'Employee Contract', + 'employee_id': employee.id, + 'date_start': Date.from_string('2015-01-01'), + 'state': 'open', + 'company_id': self.env.ref('base.main_company').id, + 'wage': 4000, + }) + + leave_type = self.env['hr.leave.type'].create({ + 'name': 'Sick', + 'request_unit': 'hour', + 'leave_validation_type': 'both', + 'allocation_type': 'no', + 'company_id': company.id, + }) + leave1 = self.env['hr.leave'].create({ + 'name': 'Sick 1 week during christmas snif', + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'date_from': Datetime.from_string('2019-12-23 06:00:00'), + 'date_to': Datetime.from_string('2019-12-27 20:00:00'), + 'number_of_days': 5, + }) + leave1.action_approve() + leave1.action_validate() + + # The work entries generation shouldn't raise an error + + user = self.env['res.users'].create({ + 'name': 'Classic User', + 'login': 'Classic User', + 'company_id': self.env.ref('base.main_company').id, + 'company_ids': self.env.ref('base.main_company').ids, + 'groups_id': [(6, 0, [self.env.ref('hr_contract.group_hr_contract_manager').id, self.env.ref('base.group_user').id])], + }) + self.env['hr.employee'].with_user(user).generate_work_entries('2019-12-01', '2019-12-31') + + def test_work_entries_generation_if_parent_leave_zero_hours(self): + # Test case: The employee has a parental leave at 0 hours per week + # The employee has a leave during that period + + employee = self.env['hr.employee'].create({'name': 'My employee'}) + calendar = self.env['resource.calendar'].create({ + 'name': 'Parental 0h', + 'attendance_ids': False, + }) + employee.resource_calendar_id = calendar + contract = self.env['hr.contract'].create({ + 'date_start': self.start.date() - relativedelta(years=1), + 'name': 'Contract - Parental 0h', + 'resource_calendar_id': calendar.id, + 'wage': 1000, + 'employee_id': employee.id, + 'state': 'open', + }) + + leave_type = self.env['hr.leave.type'].create({ + 'name': 'Sick', + 'request_unit': 'hour', + 'leave_validation_type': 'both', + 'allocation_type': 'no', + }) + + leave = self.env['hr.leave'].create({ + 'name': "Sick 1 that doesn't make sense, but it's the prod so YOLO", + 'employee_id': employee.id, + 'holiday_status_id': leave_type.id, + 'request_date_from': date(2020, 9, 4), + 'request_date_to': date(2020, 9, 4), + 'number_of_days': 1, + }) + leave.action_approve() + leave.action_validate() + + work_entries = contract._generate_work_entries(date(2020, 7, 1), date(2020, 9, 30)) + + self.assertEqual(len(work_entries), 0) diff --git a/addons/hr_work_entry_holidays/views/hr_leave_views.xml b/addons/hr_work_entry_holidays/views/hr_leave_views.xml new file mode 100644 index 00000000000..ecd131cd006 --- /dev/null +++ b/addons/hr_work_entry_holidays/views/hr_leave_views.xml @@ -0,0 +1,42 @@ + + + + + work_entry.type.leave.form.inherit + hr.leave.type + + + + + + + + + + + + hr.holidays.view.tree.inherit.work.entry + hr.leave + + primary + + + date_from + + + + + + hr.holidays.view.search.manager.work.entry + hr.leave + + primary + + + 0 + + + + + +