[ADD] l10n_fr_hr_holidays: add french time off for part timers

Adds 2 new modules:
l10n_fr_hr_holidays
l10n_fr_hr_work_entry_holidays

This commit adds modules allowing to compute accurately
the number of days off taken by a french part-timer.

Both modules will automatically install when hr_holidays is installed and
at least one of the companies is located in France

In France, when taking a time off using a different calendar from the
company's calendar you have to count the days as if you were taking time
off in the company calendar.

For example let's say you have an employee working only monday through
wednesday, if that employee were to take a time off ending with
wednesday afternoon, the time off would count up until friday (if the
company calendar if monday->friday), so that a whole week is always 5
days regardless of the employee calendar.

This is also the case for 'holes' in the employee calendar, for example
a calendar where one would work monday, wednesday and friday only.

l10n_fr_work_entry_holidays:
By default time off only generate work entries for intersections between
time off and attendance period, so that when taking a time off for a
whole month you wouldn't get work entries on the weekend if you normally
don't work that day.

But since l10n_fr_hr_holidays computes the number of days it would
normally cost with french rules. We also have to make sure that the work
entries are generated appropriately. Essentially this modules adds the
logic required to fill the holes needed using the company calendar
instead of the employee one.

OTHER:
Fixes a redundant call to super function in hr.leave._get_number_of_days,
improving its performance a bit

task-2602897

closes odoo/odoo#82637

Related: odoo/enterprise#25805
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
Co-authored-by: Bertrand Dossogne <bedo@odoo.com>
This commit is contained in:
William Braeckman
2023-04-27 16:10:19 +02:00
committed by Yannick Tivisse
co-authored by Bertrand Dossogne
parent b0421b3aab
commit b23fea1b96
22 changed files with 830 additions and 0 deletions
@@ -45,6 +45,7 @@ class ResConfigSettings(models.TransientModel):
language_count = fields.Integer('Number of Languages', compute="_compute_language_count")
company_name = fields.Char(related="company_id.display_name", string="Company Name")
company_informations = fields.Text(compute="_compute_company_informations")
company_country_code = fields.Char(related="company_id.country_id.code", string="Company Country Code", readonly=True)
profiling_enabled_until = fields.Datetime("Profiling enabled until", config_parameter='base.profiling_enabled_until')
module_product_images = fields.Boolean("Get product pictures using barcode")
+10
View File
@@ -6,3 +6,13 @@ from . import models
from . import populate
from . import report
from . import wizard
from odoo import api, SUPERUSER_ID
def _hr_holiday_post_init(env):
french_companies = env['res.company'].search_count([('partner_id.country_id.code', '=', 'FR')])
if french_companies:
env['ir.module.module'].search([
('name', '=', 'l10n_fr_hr_work_entry_holidays'),
('state', '=', 'uninstalled')
]).sudo().button_install()
+1
View File
@@ -82,5 +82,6 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p
'/hr_holidays/static/tests/tours/**/*'
],
},
'post_init_hook': '_hr_holiday_post_init',
'license': 'LGPL-3',
}
+1
View File
@@ -748,6 +748,7 @@ class HolidaysRequest(models.Model):
def _get_number_of_days(self, date_from, date_to, employee):
""" Returns a float equals to the timedelta between two dates given as string."""
self.ensure_one()
if employee.resource_calendar_id:
return self._get_number_of_days_batch(date_from, date_to, employee)[employee.id]
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'France - Time Off',
'version': '1.0',
'category': 'Human Resources/Time Off',
'icon': '/l10n_fr/static/description/icon.png',
'summary': 'Management of leaves for part-time workers in France',
'depends': ['hr_holidays', 'l10n_fr'],
'auto_install': True,
'license': 'LGPL-3',
'data': [
'views/res_config_settings_views.xml',
],
'demo': [
'data/l10n_fr_hr_holidays_demo.xml',
],
}
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="l10n_fr_part_time_calendar" model="resource.calendar">
<field name="name">Part time</field>
<field name="company_id" eval="False"/>
<field name="hours_per_day">9</field>
<field name="attendance_ids"
eval="[(5, 0, 0),
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 18.0, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 18.0, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 18.0, 'day_period': 'afternoon'}),
]"
/>
</record>
<record id="l10n_fr_part_time_employee" model="hr.employee">
<field name="company_id" ref="l10n_fr.demo_company_fr"/>
<field name="active" eval="1"/>
<field name="name">Mitchell Admin</field>
<field name="user_id" ref="base.user_admin"/>
<field name="resource_calendar_id" ref="l10n_fr_part_time_calendar"/>
<field name="image_1920" eval="obj(ref('base.partner_admin')).image_1920" model="res.partner"/>
</record>
<record id="l10n_fr_holiday_status_cl" model="hr.leave.type">
<field name="name">Paid Time Off</field>
<field name="company_id" ref="l10n_fr.demo_company_fr"/>
<field name="requires_allocation">yes</field>
<field name="employee_requests">no</field>
<field name="leave_validation_type">both</field>
<field name="allocation_validation_type">officer</field>
<field name="responsible_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="icon_id" ref="hr_holidays.icon_14"/>
<field name="color">2</field>
<field name="has_valid_allocation">True</field>
</record>
<record id="l10n_fr.demo_company_fr" model="res.company">
<field name="l10n_fr_reference_leave_type" ref="l10n_fr_holiday_status_cl"/>
</record>
<record id="l10n_fr_hr_holidays_allocation" model="hr.leave.allocation">
<field name="name">Paid Time Off allocation</field>
<field name="state">validate</field>
<field name="holiday_status_id" ref="l10n_fr_holiday_status_cl"/>
<field name="number_of_days">20</field>
<field name="date_from" eval="time.strftime('%Y-01-01')"/>
<field name="date_to" eval="time.strftime('%Y-12-31')"/>
<field name="employee_id" ref="l10n_fr_part_time_employee"/>
<field name="employee_ids" eval="[(4, ref('l10n_fr_part_time_employee'))]"/>
</record>
</odoo>
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_leave
from . import res_company
from . import resource_calendar
from . import res_config_settings
@@ -0,0 +1,128 @@
# -*- 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 import fields, models, api
class HrLeave(models.Model):
_inherit = 'hr.leave'
l10n_fr_date_to_changed = fields.Boolean()
def _get_fr_date_to(self, vals):
assert 'employee_id' in vals or len(self) == 1
employee = self.env['hr.employee'].browse(vals['employee_id']).sudo() if 'employee_id' in vals else self.employee_id
employee_calendar = employee.resource_calendar_id
company_calendar = employee.company_id.resource_calendar_id
leave_type_id = vals['holiday_status_id'] if 'holiday_status_id' in vals else self.holiday_status_id.id
if employee.company_id.country_id.code == 'FR' and employee_calendar != company_calendar and leave_type_id:
reference_leave_type = employee.company_id._get_fr_reference_leave_type()
if reference_leave_type.id == leave_type_id:
return self._get_fr_new_date_to(vals['date_to'], employee_calendar, company_calendar)
return vals['date_to']
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
vals['date_to'] = self._get_fr_date_to(vals) # returns the initial date_to if no change needed
return super().create(vals_list)
def write(self, vals):
if 'date_to' not in vals:
return super().write(vals)
if 'employee_id' in vals:
vals['date_to'] = self._get_fr_date_to(vals) # returns the initial date_to if no change needed
return super().write(vals)
else:
# Different employees could share different calendars
for leave in self:
vals_copy = vals.copy()
vals_copy['date_to'] = leave._get_fr_date_to(vals_copy)
super(HrLeave, leave).write(vals_copy)
def _get_fr_new_date_to(self, date_to, employee_calendar, company_calendar):
date_target = date_to
if isinstance(date_to, str):
date_target = datetime.fromisoformat(date_to)
new_date_to = date_target
date_target += relativedelta(days=1)
while not employee_calendar._works_on_date(date_target):
if company_calendar._works_on_date(date_target):
new_date_to = date_target
date_target += relativedelta(days=1)
return new_date_to
def _get_fr_number_of_days(self, employee, date_from, date_to, employee_calendar, company_calendar):
self.ensure_one()
self.l10n_fr_date_to_changed = False
# We can fill the holes using the company calendar as default
# What we need to compute is how much we will need to push date_to in order to account for the lost days
# This gets even more complicated in two_weeks_calendars
if self.request_unit_half and self.request_date_from_period == 'am':
# In normal workflows request_unit_half implies that date_from and date_to are the same
# request_unit_half allows us to choose between `am` and `pm`
# In a case where we work from mon-wed and request a half day in the morning
# we do not want to push date_to since the next work attendance is actually in the afternoon
date_from_weektype = str(self.env['resource.calendar.attendance'].get_week_type(date_from))
date_from_dayofweek = str(date_from.weekday())
# Fetch the attendances we care about
attendance_ids = employee_calendar.attendance_ids.filtered(lambda a:
a.dayofweek == date_from_dayofweek
and a.day_period != "lunch"
and (not employee_calendar.two_weeks_calendar or a.week_type == date_from_weektype))
if len(attendance_ids) == 2:
# The employee took the morning off on a day where he works the afternoon aswell
attendance = attendance_ids[0] if attendance_ids[0].day_period == 'morning' else attendance_ids[1]
return {'days': 0.5, 'hours': attendance.hour_to - attendance.hour_from}
# Check calendars for working days until we find the right target, start at date_to + 1 day
# Postpone date_target until the next working day
date_start = date_from
date_target = date_to + relativedelta(days=1)
counter = 0
while not employee_calendar._works_on_date(date_start):
date_start += relativedelta(days=1)
while not employee_calendar._works_on_date(date_target):
date_target += relativedelta(days=1)
counter += 1
# Allow up to 14 days for two weeks calendars to avoid infinite loop.
if counter > 14:
# Default behaviour
result = employee._get_work_days_data_batch(date_start, date_to, calendar=employee_calendar)[employee.id]
if self.request_unit_half and result['hours'] > 0:
result['days'] = 0.5
return result
date_target = datetime.combine(date_target.date(), datetime.min.time())
self.l10n_fr_date_to_changed = True
return employee._get_work_days_data_batch(date_start, date_target, calendar=company_calendar)[employee.id]
def _get_number_of_days(self, date_from, date_to, employee):
"""
In french time off laws, if an employee has a part time contract, when taking time off
before one of his off day (compared to the company's calendar) it should also count the time
between the time off and the next calendar work day/company off day (weekends).
For example take an employee working mon-wed in a company where the regular calendar is mon-fri.
If the employee were to take a time off ending on wednesday, the legal duration would count until friday.
Returns a dict containing two keys: 'days' and 'hours' with the value being the duration for the requested time period.
"""
basic_amount = super()._get_number_of_days(date_from, date_to, employee)
if not employee or not (basic_amount['days'] or basic_amount['hours']):
return basic_amount
employee = employee.sudo()
company = employee.company_id
if company.country_id.code != 'FR' or not company.resource_calendar_id:
return basic_amount
calendar = self._get_calendar()
if not calendar or not calendar != company.resource_calendar_id:
return basic_amount
if self.holiday_status_id != employee.company_id._get_fr_reference_leave_type():
return basic_amount
return self._get_fr_number_of_days(employee, date_from, date_to, calendar, company.resource_calendar_id)
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import ValidationError
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_fr_reference_leave_type = fields.Many2one(
'hr.leave.type',
string='Company Paid Time Off Type')
def _get_fr_reference_leave_type(self):
self.ensure_one()
if not self.l10n_fr_reference_leave_type:
raise ValidationError(_("You must first define a reference time off type for the company."))
return self.l10n_fr_reference_leave_type
@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
l10n_fr_reference_leave_type = fields.Many2one(
'hr.leave.type',
related='company_id.l10n_fr_reference_leave_type',
readonly=False)
@@ -0,0 +1,30 @@
# -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.tools import ormcache
from collections import defaultdict
class ResourceCalendar(models.Model):
_inherit = 'resource.calendar'
def _works_on_date(self, date):
self.ensure_one()
working_days = self._get_working_hours()
dayofweek = str(date.weekday())
if self.two_weeks_calendar:
weektype = str(self.env['resource.calendar.attendance'].get_week_type(date))
return working_days[weektype][dayofweek]
return working_days[False][dayofweek]
@ormcache('self.id')
def _get_working_hours(self):
self.ensure_one()
working_days = defaultdict(lambda: defaultdict(lambda: False))
for attendance in self.attendance_ids:
working_days[attendance.week_type][attendance.dayofweek] = True
return working_days
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_french_leaves
@@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import time
from odoo.tests.common import TransactionCase, tagged
_logger = logging.getLogger(__name__)
@tagged('post_install_l10n', 'post_install', '-at_install', 'french_leaves')
class TestFrenchLeaves(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
country_fr = cls.env.ref('base.fr')
cls.company = cls.env['res.company'].create({
'name': 'French Company',
'country_id': country_fr.id,
})
cls.employee = cls.env['hr.employee'].create({
'name': 'Louis',
'gender': 'other',
'birthday': '1973-03-29',
'country_id': country_fr.id,
'company_id': cls.company.id,
})
cls.time_off_type = cls.env['hr.leave.type'].create({
'name': 'Time Off',
'requires_allocation': 'no',
})
cls.company.write({
'l10n_fr_reference_leave_type': cls.time_off_type.id,
})
cls.base_calendar = cls.env['resource.calendar'].create({
'name': 'default calendar',
})
def test_no_differences(self):
# Base case that should not have a different behaviour
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = self.base_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-06',
'date_to': '2021-09-10 23:59:59',
})
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
def test_end_of_week(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-06',
'date_to': '2021-09-08 23:59:59',
})
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
def test_start_of_week(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-08',
'date_to': '2021-09-10 23:59:59',
})
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
def test_last_day_half(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-10',
'date_to': '2021-09-10 12:00:00',
'request_date_from': '2021-09-10',
'request_date_to': '2021-09-10 12:00:00',
'request_unit_half': True,
'request_date_from_period': 'am',
})
# Since the employee works on the afternoon, the date_to is not post-poned
self.assertEqual(leave.number_of_days, 0.5, 'The number of days should be equal to 0.5.')
leave.request_date_from_period = 'pm'
# This however should push the date_to
self.assertEqual(leave.number_of_days, 2.5, 'The number of days should be equal to 2.5.')
def test_calendar_with_holes(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-06',
'date_to': '2021-09-10 23:59:59',
})
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
def test_calendar_end_week_hole(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-06',
'date_to': '2021-09-08 23:59:59',
})
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
def test_2_weeks_calendar(self):
company_calendar = self.env['resource.calendar'].create({
'name': 'Company Calendar',
'two_weeks_calendar': True,
'attendance_ids': [
(0, 0, {'week_type': '0', 'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '0', 'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '0', 'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '0', 'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '0', 'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '0', 'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '1', 'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '1', 'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '1', 'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '1', 'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '1', 'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '1', 'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '1', 'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '1', 'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'week_type': '1', 'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = company_calendar
self.employee.resource_calendar_id = employee_calendar
# Week type 0
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-06',
'date_to': '2021-09-08 23:59:59',
})
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
leave.unlink()
# Week type 1
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-13',
'date_to': '2021-09-15 23:59:59',
})
self.assertEqual(leave.number_of_days, 3, 'The number of days should be equal to 3.')
leave.unlink()
# Both ending with week type 1
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-06',
'date_to': '2021-09-15 23:59:59',
})
self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.')
leave.unlink()
# Both ending with week type 0
with self.assertQueryCount(118):
start_time = time.time()
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-13',
'date_to': '2021-09-22 23:59:59',
})
# --- 0.11486363410949707 seconds ---
_logger.info("French Leave Creation: --- %s seconds ---", time.time() - start_time)
self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.')
leave.unlink()
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.hr</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="70"/>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<block name="work_organization_setting_container" position="after">
<field name="company_country_code" invisible="1"/>
<block title="French Time Off Localization" attrs="{'invisible': [('company_country_code', '!=', 'FR')]}">
<setting company_dependent="1" help="Set the time off type used as the company Paid Time Off to compute part-timers leave duration">
<field name="l10n_fr_reference_leave_type"
class="o_light_label"
domain="[('company_id', 'in', [company_id, False])]"
context="{'default_company_id': company_id}"/>
</setting>
</block>
</block>
</field>
</record>
<menuitem id="hr_holidays_menu_configuration"
name="Settings"
parent="hr_holidays.menu_hr_holidays_configuration"
sequence="10"
action="hr.hr_config_settings_action"
groups="base.group_system"/>
</odoo>
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'France - Work Entries Time Off',
'version': '1.0',
'icon': '/l10n_fr/static/description/icon.png',
'summary': 'Management of leaves for part-time workers in France',
'depends': [
'l10n_fr_hr_holidays',
'hr_work_entry_holidays',
],
'auto_install': True,
'license': 'LGPL-3',
}
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_contract
from . import hr_work_entry
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
import pytz
from odoo import models
from odoo.addons.resource.models.utils import datetime_to_string
class HrContract(models.Model):
_inherit = 'hr.contract'
def _get_contract_work_entries_values(self, date_start, date_stop):
# Add the work entries difference for french payroll
# Work entries by default are not generated on days the employee does not work
# So we have to fill the gaps with work entries for those periods
result = super()._get_contract_work_entries_values(date_start, date_stop)
fr_contracts = self.filtered(lambda c: c.company_id.country_id.code == 'FR' and c.resource_calendar_id != c.company_id.resource_calendar_id)
if not fr_contracts:
return result
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
# l10n_fr_date_to_changed is False when no adjustment had to be done
all_leaves = self.env['hr.leave'].search([
('employee_id', 'in', fr_contracts.employee_id.ids),
('state', '=', 'validate'),
('date_from', '<=', datetime_to_string(end_dt)),
('date_to', '>=', datetime_to_string(start_dt)),
('l10n_fr_date_to_changed', '=', True),
])
leaves_per_employee = defaultdict(lambda: self.env['hr.leave'])
for leave in all_leaves:
leaves_per_employee[leave.employee_id] |= leave
for contract in fr_contracts:
employee = contract.employee_id
employee_calendar = contract.resource_calendar_id
company = contract.company_id
company_calendar = company.resource_calendar_id
resource = employee.resource_id
tz = pytz.timezone(employee_calendar.tz)
for leave in leaves_per_employee[employee]:
leave_start_dt = max(start_dt, leave.date_from.astimezone(tz))
leave_end_dt_fr = min(end_dt, leave.date_to.astimezone(tz))
# Compute the attendances for the company calendar and the employee calendar
# and then compute and keep the difference between those two
company_attendances = company_calendar._attendance_intervals_batch(
leave_start_dt, leave_end_dt_fr, resources=resource, tz=tz,
)[resource.id]
# Dates on which work entries should already be generated
employee_dates = set()
for vals in result:
employee_dates.add(vals['date_start'].date())
employee_dates.add(vals['date_stop'].date())
leave_work_entry_type = leave.holiday_status_id.work_entry_type_id
result += [{
'name': '%s%s' % (leave_work_entry_type.name + ': ' if leave_work_entry_type else "", 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': leave_work_entry_type.id,
'employee_id': employee.id,
'company_id': contract.company_id.id,
'state': 'draft',
'contract_id': contract.id,
'leave_id': leave.id,
} for interval in company_attendances if interval[0].date() not in employee_dates]
return result
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class HrWorkEntry(models.Model):
_inherit = 'hr.work.entry'
def _filter_french_part_time_entries(self):
french_part_time_work_entries = self.filtered(lambda w:
w.company_id.country_id.code == 'FR'
and w.employee_id.resource_calendar_id != w.company_id.resource_calendar_id)
return french_part_time_work_entries
def _mark_leaves_outside_schedule(self):
french_part_time_work_entries = self._filter_french_part_time_entries()
if not french_part_time_work_entries:
return super()._mark_leaves_outside_schedule()
other_work_entries = self - french_part_time_work_entries
if other_work_entries:
return other_work_entries._mark_leaves_outside_schedule()
return False
def _get_duration_batch(self):
res = super()._get_duration_batch()
french_part_time_work_entries = self._filter_french_part_time_entries()
if not french_part_time_work_entries:
return res
for entry in french_part_time_work_entries:
if entry.id in res and res[entry.id] == 0:
res[entry.id] = (entry.date_stop - entry.date_start).seconds/3600
return res
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_french_work_entries
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import time
from datetime import datetime
from odoo.tests.common import TransactionCase, tagged
_logger = logging.getLogger(__name__)
@tagged('post_install_l10n', 'post_install', '-at_install', 'french_work_entries')
class TestFrenchWorkEntries(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
country_fr = cls.env.ref('base.fr')
cls.company = cls.env['res.company'].create({
'name': 'French Company',
'country_id': country_fr.id,
})
cls.employee = cls.env['hr.employee'].create({
'name': 'Louis',
'gender': 'other',
'birthday': '1973-03-29',
'country_id': country_fr.id,
'company_id': cls.company.id,
})
cls.employee_contract = cls.env['hr.contract'].create({
'date_start': '2020-01-01',
'date_end': '2023-01-01',
'name': 'Louis\'s contract',
'wage': 2,
'employee_id': cls.employee.id,
'company_id': cls.company.id,
})
cls.time_off_type = cls.env['hr.leave.type'].create({
'name': 'Time Off',
'requires_allocation': 'no',
})
cls.company.write({
'l10n_fr_reference_leave_type': cls.time_off_type.id,
})
def test_fill_gaps(self):
company_calendar = self.env['resource.calendar'].create({
'name': 'Company Calendar',
})
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = company_calendar
self.employee.resource_calendar_id = employee_calendar
self.employee_contract.resource_calendar_id = employee_calendar
# Get the create values for a week of work entries, it should only give us 4 entries ((am+pm) * 2)
work_entry_create_vals = self.employee_contract._get_contract_work_entries_values(datetime(2021, 9, 6), datetime(2021, 9, 10, 23, 59, 59))
self.assertEqual(len(work_entry_create_vals), 4, 'Should have generated 4 work entries.')
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'date_from': '2021-09-06',
'date_to': '2021-09-08 23:59:59', # This should fill the gap up to the 10th
})
leave.action_validate()
# Since the gaps have been filled, we should now get 10 work entries
with self.assertQueryCount(45):
start_time = time.time()
work_entry_create_vals = self.employee_contract._get_contract_work_entries_values(datetime(2021, 9, 6), datetime(2021, 9, 10, 23, 59, 59))
# --- 0.11486363410949707 seconds ---
_logger.info("Get Contract Work Entries: --- %s seconds ---", time.time() - start_time)
self.assertEqual(len(work_entry_create_vals), 10, 'Should have generated 10 work entries.')
# Make sure that the gap filling does not go past the requested date
work_entry_create_vals = self.employee_contract._get_contract_work_entries_values(datetime(2021, 9, 6), datetime(2021, 9, 9, 23, 59, 59))
self.assertEqual(len(work_entry_create_vals), 8, 'Should have generated 8 work entries.')