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..17a48e1628b --- /dev/null +++ b/addons/hr_work_entry_holidays/__manifest__.py @@ -0,0 +1,28 @@ +# -*- 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', + 'views/hr_work_entry_views.xml', + 'data/hr_payroll_holidays_data.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..07348d8adfa --- /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 requests a leave 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..558a8e24669 --- /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 api, 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..f7b4bf181f8 --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/common.py @@ -0,0 +1,88 @@ +# -*- 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'), + }) + + 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..a098e1b087b --- /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(cls): + super(TestWorkEntryHolidaysMultiContract, cls).setUp() + 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 + }) + + 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..577f2fe6313 --- /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(cls): + super(TestPayslipHolidaysComputation, cls).setUp() + + 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 + }) + + 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..7421d9d29b7 --- /dev/null +++ b/addons/hr_work_entry_holidays/tests/test_performance.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date, datetime +from dateutil.relativedelta import relativedelta +from odoo import fields + +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..66648486261 --- /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, + }) + + contract = 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..18a16a50e88 --- /dev/null +++ b/addons/hr_work_entry_holidays/views/hr_leave_views.xml @@ -0,0 +1,68 @@ + + + + + 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 + + + + + + All Time Off + hr.leave + tree,kanban,form,calendar,activity,gantt + + + { + 'search_default_gray':1, + 'hide_employee_name': 1 + } + +

+ Meet the time off dashboard. +

+ A great way to keep track on employee’s PTOs, sick days, and approval status. +

+
+
+ + + +
diff --git a/addons/hr_work_entry_holidays/views/hr_work_entry_views.xml b/addons/hr_work_entry_holidays/views/hr_work_entry_views.xml new file mode 100644 index 00000000000..203560628e5 --- /dev/null +++ b/addons/hr_work_entry_holidays/views/hr_work_entry_views.xml @@ -0,0 +1,50 @@ + + + + + payroll.hr.work.entry.view.form.inherit + hr.work.entry + + + + +