[MOV] hr_work_entry_contract,hr_work_entry_holidays: move from enterprise to community

Before this commit, hr_work_entry_contract and hr_work_entry_holidays were defined in enterprise but those modules could be integrated with some community applications, like hr_attendance.

taskid: 2222790

--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr

closes odoo/odoo#66247

Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Odoo's Mergebot
2021-02-26 15:04:31 +01:00
committed by GitHub
46 changed files with 3181 additions and 8 deletions
@@ -303,12 +303,4 @@
parent="hr.menu_human_resources_configuration"
sequence="3"/>
<menuitem
id="hr_menu_contract"
name="All Contracts"
action="action_hr_contract"
parent="hr.menu_hr_employee_payroll"
sequence="6"
groups="hr_contract.group_hr_contract_manager"/>
</odoo>
@@ -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
@@ -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',
],
}
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Work Entry Type -->
<record id="work_entry_type_leave" model="hr.work.entry.type">
<field name="name">Generic Time Off</field>
<field name="code">LEAVE100</field>
<field name="color">3</field>
<field name="is_leave">True</field>
</record>
<record id="work_entry_type_compensatory" model="hr.work.entry.type">
<field name="name">Compensatory Time Off</field>
<field name="code">LEAVE105</field>
<field name="color">3</field>
<field name="is_leave">True</field>
</record>
<record id="work_entry_type_home_working" model="hr.work.entry.type">
<field name="name">Home Working</field>
<field name="code">WORK110</field>
<field name="color">2</field>
<field name="is_leave">True</field>
</record>
<record id="work_entry_type_unpaid_leave" model="hr.work.entry.type">
<field name="name">Unpaid</field>
<field name="color">5</field>
<field name="code">LEAVE90</field>
<field name="is_leave">True</field>
</record>
<record id="work_entry_type_sick_leave" model="hr.work.entry.type">
<field name="name">Sick Time Off</field>
<field name="code">LEAVE110</field>
<field name="is_leave">True</field>
<field name="color">5</field>
</record>
<record id="work_entry_type_legal_leave" model="hr.work.entry.type">
<field name="name" eval="'Legal Leaves '+time.strftime('%Y')"/>
<field name="code">LEAVE120</field>
<field name="is_leave">True</field>
<field name="color">5</field>
</record>
<record id="hr_work_entry.work_entry_type_attendance" model="hr.work.entry.type">
<field name="is_leave">False</field>
</record>
</data>
</odoo>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Work entries -->
<record id="work_entry_type_extra_hours" model="hr.work.entry.type">
<field name="name">Extra Hours</field>
<field name="code">WORK300</field>
<field name="color">8</field>
</record>
<record id="work_entry_type_long_leave" model="hr.work.entry.type">
<field name="name">Long Term Time Off</field>
<field name="code">LEAVE200</field>
<field name="is_leave">True</field>
<field name="color">4</field>
</record>
</odoo>
@@ -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 ""
"<i class=\"fa fa-exclamation-triangle mr-1\" title=\"Warning\"/>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 "<i class=\"fa fa-info-circle mr-1\" title=\"Hint\"/>"
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 "<span> to </span>"
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 ""
@@ -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
@@ -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
@@ -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))
@@ -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")
@@ -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
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="ir_rule_hr_work_entry_multi_company" model="ir.rule">
<field name="name">HR Work Entry Contract: Multi Company</field>
<field name="model_id" ref="model_hr_work_entry"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</odoo>
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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
3 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
<defs>
<path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#CD7690"/>
<stop offset="100%" stop-color="#CA5377"/>
</linearGradient>
<path id="icon-d" d="M29.5,16.9166667 C34.5338704,16.9166667 38.6145833,20.9973796 38.6145833,26.03125 C38.6145833,31.0651204 34.5338704,35.1458333 29.5,35.1458333 C24.4661296,35.1458333 20.3854167,31.0651204 20.3854167,26.03125 C20.3854167,20.9973796 24.4661296,16.9166667 29.5,16.9166667 Z M38.3969283,46.0833333 L17.6510417,46.0833333 C16.1408691,46.0833333 14.9166667,44.8591309 14.9166667,43.3489583 L14.9166667,41.2386475 C14.9166667,38.7291748 16.6245687,36.5417887 19.0590739,35.9331624 L23.1215007,34.91757 C26.4370443,37.3024007 31.6104248,37.9874756 35.8784993,34.91757 L38.085401,35.4692877 C38.5061499,38.7235298 38.4706769,43.0476895 38.3969283,46.0833333 Z M58.75,30.5 C59.9921875,30.5 61,31.6757812 61,33.125 L61,52.375 C61,53.8242187 59.9921875,55 58.75,55 L42.25,55 C41.0078125,55 40,53.8242187 40,52.375 L40,33.125 C40,31.6757812 41.0078125,30.5 42.25,30.5 L44.5,30.5 L44.5,27.65625 C44.5,27.2953125 44.753125,27 45.0625,27 L46.9375,27 C47.246875,27 47.5,27.2953125 47.5,27.65625 L47.5,30.5 L53.5,30.5 L53.5,27.65625 C53.5,27.2953125 53.753125,27 54.0625,27 L55.9375,27 C56.246875,27 56.5,27.2953125 56.5,27.65625 L56.5,30.5 L58.75,30.5 Z M58.46875,52.375 C58.6234375,52.375 58.75,52.2105322 58.75,52.009516 L58.75,33.8571429 L42.25,33.8571429 L42.25,52.009516 C42.25,52.2105322 42.3765625,52.375 42.53125,52.375 L58.46875,52.375 Z M49.1056813,41.3851318 C49.1056813,42.9223389 54.4224866,42.5321777 54.4224578,45.454248 C54.4224578,46.8539063 53.2582943,48.0494873 51.3671328,48.298877 L51.3671328,49.4570312 C51.3671328,49.6188232 51.2125191,49.75 51.0218203,49.75 L49.8707786,49.75 C49.6800798,49.75 49.5254661,49.6188232 49.5254661,49.4570312 L49.5254661,48.2798584 C48.3887549,48.1158447 47.3758383,47.6706543 46.6838033,47.1086426 C46.5578505,47.0063477 46.5419661,46.8402588 46.6463369,46.7217529 L47.520812,45.7289063 C47.6381319,45.5957275 47.8617793,45.5725586 48.0145801,45.6766602 C48.7315639,46.1651123 49.6576057,46.5528076 50.5424978,46.5528076 C51.5730542,46.5528076 52.0424202,46.0319336 52.0424202,45.5479736 C52.0424202,44.1172119 46.7256148,44.427832 46.7256148,41.4118896 C46.7256148,40.1247314 47.8384419,39.0840332 49.5254949,38.7595703 L49.5254949,37.5429688 C49.5254949,37.3811768 49.6801086,37.25 49.8708074,37.25 L51.0218491,37.25 C51.2125479,37.25 51.3671616,37.3811768 51.3671616,37.5429688 L51.3671616,38.6962646 C52.2924264,38.7869873 53.2953866,39.0949219 53.9940977,39.6192627 C54.1141225,39.7093262 54.1444237,39.8580811 54.068196,39.9776611 L53.3904626,41.0408691 C53.2913292,41.1964111 53.0539268,41.2421875 52.8847237,41.1389404 C52.241723,40.7466064 51.4535473,40.4472168 50.6844788,40.4472168 C49.7254021,40.4472168 49.1056813,40.8153809 49.1056813,41.3851318 Z"/>
<path id="icon-e" d="M29.5,14.9166667 C34.5338704,14.9166667 38.6145833,18.9973796 38.6145833,24.03125 C38.6145833,29.0651204 34.5338704,33.1458333 29.5,33.1458333 C24.4661296,33.1458333 20.3854167,29.0651204 20.3854167,24.03125 C20.3854167,18.9973796 24.4661296,14.9166667 29.5,14.9166667 Z M38.3969283,44.0833333 L17.6510417,44.0833333 C16.1408691,44.0833333 14.9166667,42.8591309 14.9166667,41.3489583 L14.9166667,39.2386475 C14.9166667,36.7291748 16.6245687,34.5417887 19.0590739,33.9331624 L23.1215007,32.91757 C26.4370443,35.3024007 31.6104248,35.9874756 35.8784993,32.91757 L38.085401,33.4692877 C38.5061499,36.7235298 38.4706769,41.0476895 38.3969283,44.0833333 Z M58.75,28.5 C59.9921875,28.5 61,29.6757812 61,31.125 L61,50.375 C61,51.8242187 59.9921875,53 58.75,53 L42.25,53 C41.0078125,53 40,51.8242187 40,50.375 L40,31.125 C40,29.6757812 41.0078125,28.5 42.25,28.5 L44.5,28.5 L44.5,25.65625 C44.5,25.2953125 44.753125,25 45.0625,25 L46.9375,25 C47.246875,25 47.5,25.2953125 47.5,25.65625 L47.5,28.5 L53.5,28.5 L53.5,25.65625 C53.5,25.2953125 53.753125,25 54.0625,25 L55.9375,25 C56.246875,25 56.5,25.2953125 56.5,25.65625 L56.5,28.5 L58.75,28.5 Z M58.46875,50.375 C58.6234375,50.375 58.75,50.2105322 58.75,50.009516 L58.75,31.8571429 L42.25,31.8571429 L42.25,50.009516 C42.25,50.2105322 42.3765625,50.375 42.53125,50.375 L58.46875,50.375 Z M49.1056813,39.3851318 C49.1056813,40.9223389 54.4224866,40.5321777 54.4224578,43.454248 C54.4224578,44.8539063 53.2582943,46.0494873 51.3671328,46.298877 L51.3671328,47.4570312 C51.3671328,47.6188232 51.2125191,47.75 51.0218203,47.75 L49.8707786,47.75 C49.6800798,47.75 49.5254661,47.6188232 49.5254661,47.4570312 L49.5254661,46.2798584 C48.3887549,46.1158447 47.3758383,45.6706543 46.6838033,45.1086426 C46.5578505,45.0063477 46.5419661,44.8402588 46.6463369,44.7217529 L47.520812,43.7289063 C47.6381319,43.5957275 47.8617793,43.5725586 48.0145801,43.6766602 C48.7315639,44.1651123 49.6576057,44.5528076 50.5424978,44.5528076 C51.5730542,44.5528076 52.0424202,44.0319336 52.0424202,43.5479736 C52.0424202,42.1172119 46.7256148,42.427832 46.7256148,39.4118896 C46.7256148,38.1247314 47.8384419,37.0840332 49.5254949,36.7595703 L49.5254949,35.5429688 C49.5254949,35.3811768 49.6801086,35.25 49.8708074,35.25 L51.0218491,35.25 C51.2125479,35.25 51.3671616,35.3811768 51.3671616,35.5429688 L51.3671616,36.6962646 C52.2924264,36.7869873 53.2953866,37.0949219 53.9940977,37.6192627 C54.1141225,37.7093262 54.1444237,37.8580811 54.068196,37.9776611 L53.3904626,39.0408691 C53.2913292,39.1964111 53.0539268,39.2421875 52.8847237,39.1389404 C52.241723,38.7466064 51.4535473,38.4472168 50.6844788,38.4472168 C49.7254021,38.4472168 49.1056813,38.8153809 49.1056813,39.3851318 Z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="icon-b" fill="#fff">
<use xlink:href="#icon-a"/>
</mask>
<g mask="url(#icon-b)">
<rect width="70" height="70" fill="url(#icon-c)"/>
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
<path fill="#393939" d="M45.4871795,51 L4,51 C2,51 -7.10542736e-15,50.851312 0,46.8367347 L2.08199737e-16,23.7312137 L22,0 L37,1.04081633 L36,15.6122449 L40.5053241,10.5061222 L41.4366949,9.83683131 L44.9722671,6.08098476 L49,12.4897959 L53.8072717,6.21274166 L56,11.4489796 L59,11.4489796 L60.3332838,33.1194301 L45.4871795,51 Z" opacity=".324" transform="translate(0 19)"/>
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
<use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#icon-d"/>
<use fill="#FFF" fill-rule="nonzero" xlink:href="#icon-e"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -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;
});
@@ -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 $('<span>').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;
});
@@ -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
@@ -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,
})
@@ -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'})
@@ -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)],
)
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend">
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/hr_work_entry_contract/static/src/js/work_entries_controller_mixin.js"></script>
<script type="text/javascript" src="/hr_work_entry_contract/static/src/js/work_entries_calendar.js"></script>
</xpath>
</template>
</odoo>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_work_entry_contract_view_calendar_inherit" model="ir.ui.view">
<field name="name">hr.work.entry.contract.view.calendar.inherit</field>
<field name="model">hr.work.entry</field>
<field name="inherit_id" ref="hr_work_entry.hr_work_entry_view_calendar"/>
<field name="arch" type="xml">
<xpath expr="//calendar" position="attributes">
<attribute name="js_class">work_entries_calendar</attribute>
</xpath>
</field>
</record>
<record id="hr_work_entry_contract_view_form_inherit" model="ir.ui.view">
<field name="name">hr.work.entry.contract.view.form.inherit</field>
<field name="model">hr.work.entry</field>
<field name="inherit_id" ref="hr_work_entry.hr_work_entry_view_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="before">
<div attrs="{'invisible': [('state', '!=', 'conflict')]}">
<div class="alert alert-warning mb-0" role="alert" attrs="{'invisible': ['!', ('work_entry_type_id', '=', False)]}" name="work_entry_undefined">
This work entry cannot be validated. The work entry type is undefined.
</div>
</div>
</xpath>
</field>
</record>
<record id="hr_work_entry_contract_type_view_form_inherit" model="ir.ui.view">
<field name="name">hr.work.entry.type.contract.view.form.inherit</field>
<field name="model">hr.work.entry.type</field>
<field name="inherit_id" ref="hr_work_entry.hr_work_entry_type_view_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='identification']" position="after">
<group>
<field name="is_leave"/>
</group>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Calendar View -->
<record id="resource_calendar_view_form" model="ir.ui.view">
<field name="name">resource.calendar.view.form.inherit</field>
<field name="model">resource.calendar</field>
<field name="inherit_id" ref="resource.resource_calendar_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="before">
<header>
<button name="%(action_create_company_global_time_off)d" string="Create Company Public Time Off" type="action" class="oe_highlight"/>
</header>
</xpath>
</field>
</record>
</odoo>
@@ -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
@@ -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,
})]
})
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="create_company_global_time_off_view_form" model="ir.ui.view">
<field name="name">create.company.global.time.off.view.form</field>
<field name="model">create.company.global.time.off</field>
<field name="arch" type="xml">
<form string="Create Company Public Time Off">
<group>
<field name="name"/>
<label for="date_from" string="Period"/>
<div>
<field name="date_from" class="oe_inline"/>
<span> to </span>
<field name="date_to" class="oe_inline"/>
</div>
<field name="company_id"/>
<field name="work_entry_type_id"/>
</group>
<footer>
<button string="Confirm" name="action_confirm" type="object" class="oe_highlight"/>
<button string="Cancel" class="btn btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_create_company_global_time_off" model="ir.actions.act_window">
<field name="name">Create Company Public Time Off</field>
<field name="res_model">create.company.global.time.off</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="create_company_global_time_off_view_form"/>
<field name="target">new</field>
</record>
</odoo>
@@ -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
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_work_entry_regeneration_wizard" model="ir.ui.view">
<field name="name">hr_work_entry_regeneration_wizard</field>
<field name="model">hr.work.entry.regeneration.wizard</field>
<field name="arch" type="xml">
<form string="Regenerate Employee Work Entries">
<group>
<group>
<field name="employee_id"/>
<label for="date_from"></label>
<div name="date_from">
<div class="text-info" attrs="{'invisible': [('earliest_available_date_message', '=', '')]}">
<i class="fa fa-info-circle mr-1" title="Hint"/>
<field name="earliest_available_date_message" nolabel="1"/>
</div >
<field name="date_from"/>
</div>
<label for="date_to"></label>
<div name="date_to">
<div class="text-info" attrs="{'invisible': [('latest_available_date_message', '=', '')]}">
<i class="fa fa-info-circle mr-1" title="Hint"/>
<field name="latest_available_date_message" nolabel="1"/>
</div>
<field name="date_to"/>
</div>
</group>
</group>
<field name="search_criteria_completed" invisible="1"/>
<field name="valid" invisible="1"/>
<div attrs="{'invisible': ['|', ('search_criteria_completed', '=', False), ('valid', '=', True)]}">
<div class="text-danger"><i class="fa fa-exclamation-triangle mr-1" title="Warning"/>You are not allowed to regenerate validated work entries</div>
<field name="validated_work_entry_ids" widget="many2many" nolabel="1">
<tree string="Work Entries"
default_order = "date_start"
editable="bottom"
no_open="1" decoration-danger="state == 'validated'">
<field name="state" invisible="1"/>
<field name="date_start"/>
<field name="date_stop"/>
<field name="work_entry_type_id"/>
<field name="state"/>
</tree>
</field>
</div>
<footer>
<button name="regenerate_work_entries"
string="Regenerate Work Entries"
type="object" class="oe_highlight"
attrs="{'invisible': ['|', ('search_criteria_completed', '=', False), ('valid', '=', False)]}"/>
<button name="regenerate_work_entries_disabled"
string="Regenerate Work Entries"
disabled="1"
attrs="{'invisible': [('search_criteria_completed', '=', True), ('valid', '=', True)]}"/>
</footer>
</form>
</field>
</record>
<record id="hr_work_entry_regeneration_wizard_action" model="ir.actions.act_window">
<field name="name">Work Entry Regeneration</field>
<field name="res_model">hr.work.entry.regeneration.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
+11
View File
@@ -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()
@@ -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',
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="hr_holidays.holiday_status_comp" model="hr.leave.type">
<field name="work_entry_type_id" ref="hr_work_entry_contract.work_entry_type_compensatory"></field>
</record>
<record id="hr_holidays.holiday_status_unpaid" model="hr.leave.type">
<field name="work_entry_type_id" ref="hr_work_entry_contract.work_entry_type_unpaid_leave"></field>
</record>
<record id="hr_holidays.holiday_status_sl" model="hr.leave.type">
<field name="work_entry_type_id" ref="hr_work_entry_contract.work_entry_type_sick_leave"></field>
</record>
<record id="hr_holidays.holiday_status_cl" model="hr.leave.type">
<field name="work_entry_type_id" ref="hr_work_entry_contract.work_entry_type_legal_leave"></field>
</record>
</data>
</odoo>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- add work_entry type to leave type -->
<record id="hr_holidays.holiday_status_unpaid" model="hr.leave.type">
<field name="work_entry_type_id" ref="hr_work_entry_contract.work_entry_type_unpaid_leave"></field>
</record>
<record id="hr_holidays.holiday_status_comp" model="hr.leave.type">
<field name="work_entry_type_id" ref="hr_work_entry_contract.work_entry_type_leave"></field>
</record>
</odoo>
@@ -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. <br/>\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 ""
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import hr_contract
from . import hr_leave
from . import hr_work_entry
@@ -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')
@@ -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()
@@ -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')
@@ -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
@@ -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,
})
@@ -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)
@@ -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.")
@@ -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
@@ -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)
@@ -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)
@@ -0,0 +1,42 @@
<?xml version='1.0' encoding='UTF-8' ?>
<odoo>
<!-- Work entry type on hr leave -->
<record id="work_entry_type_leave_form_inherit" model="ir.ui.view">
<field name="name">work_entry.type.leave.form.inherit</field>
<field name="model">hr.leave.type</field>
<field name="inherit_id" ref="hr_holidays.edit_holiday_status_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='calendar']" position="after">
<group name="work_entry" string="Work Entries">
<field name="work_entry_type_id"/>
</group>
</xpath>
</field>
</record>
<record id="hr_leave_view_tree_inherit_work_entry" model="ir.ui.view">
<field name="name">hr.holidays.view.tree.inherit.work.entry</field>
<field name="model">hr.leave</field>
<field name="inherit_id" ref="hr_holidays.hr_leave_view_tree"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//tree" position="attributes">
<attribute name="default_order">date_from</attribute>
</xpath>
</field>
</record>
<record id="hr_leave_view_search_manager_inherit_work_entry" model="ir.ui.view">
<field name="name">hr.holidays.view.search.manager.work.entry</field>
<field name="model">hr.leave</field>
<field name="inherit_id" ref="hr_holidays.hr_leave_view_search_manager"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//filter[@name='gray']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
</field>
</record>
</odoo>