diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 068ea21e437..b2b98a91988 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -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") diff --git a/addons/hr_holidays/__init__.py b/addons/hr_holidays/__init__.py index d26fccf560c..fea4e90c2dd 100644 --- a/addons/hr_holidays/__init__.py +++ b/addons/hr_holidays/__init__.py @@ -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() diff --git a/addons/hr_holidays/__manifest__.py b/addons/hr_holidays/__manifest__.py index 34631ea36a7..6a335eed202 100644 --- a/addons/hr_holidays/__manifest__.py +++ b/addons/hr_holidays/__manifest__.py @@ -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', } diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index d6858f3a7ad..a873d92e701 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -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] diff --git a/addons/l10n_fr_hr_holidays/__init__.py b/addons/l10n_fr_hr_holidays/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_fr_hr_holidays/__manifest__.py b/addons/l10n_fr_hr_holidays/__manifest__.py new file mode 100644 index 00000000000..5edc2a3891f --- /dev/null +++ b/addons/l10n_fr_hr_holidays/__manifest__.py @@ -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', + ], +} diff --git a/addons/l10n_fr_hr_holidays/data/l10n_fr_hr_holidays_demo.xml b/addons/l10n_fr_hr_holidays/data/l10n_fr_hr_holidays_demo.xml new file mode 100644 index 00000000000..4d0b1067a23 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/data/l10n_fr_hr_holidays_demo.xml @@ -0,0 +1,54 @@ + + + + Part time + + 9 + + + + + + + Mitchell Admin + + + + + + + Paid Time Off + + yes + no + both + officer + + + 2 + True + + + + + + + Paid Time Off allocation + validate + + 20 + + + + + + diff --git a/addons/l10n_fr_hr_holidays/models/__init__.py b/addons/l10n_fr_hr_holidays/models/__init__.py new file mode 100644 index 00000000000..a77dcd6c2c3 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_leave +from . import res_company +from . import resource_calendar +from . import res_config_settings diff --git a/addons/l10n_fr_hr_holidays/models/hr_leave.py b/addons/l10n_fr_hr_holidays/models/hr_leave.py new file mode 100644 index 00000000000..9156a49279f --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/hr_leave.py @@ -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) diff --git a/addons/l10n_fr_hr_holidays/models/res_company.py b/addons/l10n_fr_hr_holidays/models/res_company.py new file mode 100644 index 00000000000..dfe6017d5a5 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/res_company.py @@ -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 diff --git a/addons/l10n_fr_hr_holidays/models/res_config_settings.py b/addons/l10n_fr_hr_holidays/models/res_config_settings.py new file mode 100644 index 00000000000..e7b36abbf6e --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/res_config_settings.py @@ -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) diff --git a/addons/l10n_fr_hr_holidays/models/resource_calendar.py b/addons/l10n_fr_hr_holidays/models/resource_calendar.py new file mode 100644 index 00000000000..4dc1f57e409 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/models/resource_calendar.py @@ -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 diff --git a/addons/l10n_fr_hr_holidays/tests/__init__.py b/addons/l10n_fr_hr_holidays/tests/__init__.py new file mode 100644 index 00000000000..60abb90f795 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_french_leaves diff --git a/addons/l10n_fr_hr_holidays/tests/test_french_leaves.py b/addons/l10n_fr_hr_holidays/tests/test_french_leaves.py new file mode 100644 index 00000000000..e4fb02e35c1 --- /dev/null +++ b/addons/l10n_fr_hr_holidays/tests/test_french_leaves.py @@ -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() diff --git a/addons/l10n_fr_hr_holidays/views/res_config_settings_views.xml b/addons/l10n_fr_hr_holidays/views/res_config_settings_views.xml new file mode 100644 index 00000000000..b46922dad1d --- /dev/null +++ b/addons/l10n_fr_hr_holidays/views/res_config_settings_views.xml @@ -0,0 +1,29 @@ + + + + res.config.settings.view.form.inherit.hr + res.config.settings + + + + + + + + + + + + + + + + diff --git a/addons/l10n_fr_hr_work_entry_holidays/__init__.py b/addons/l10n_fr_hr_work_entry_holidays/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_fr_hr_work_entry_holidays/__manifest__.py b/addons/l10n_fr_hr_work_entry_holidays/__manifest__.py new file mode 100644 index 00000000000..5a86035971c --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/__manifest__.py @@ -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', +} diff --git a/addons/l10n_fr_hr_work_entry_holidays/models/__init__.py b/addons/l10n_fr_hr_work_entry_holidays/models/__init__.py new file mode 100644 index 00000000000..df52f76ea4e --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/models/__init__.py @@ -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 diff --git a/addons/l10n_fr_hr_work_entry_holidays/models/hr_contract.py b/addons/l10n_fr_hr_work_entry_holidays/models/hr_contract.py new file mode 100644 index 00000000000..b13bb5e793b --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/models/hr_contract.py @@ -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 diff --git a/addons/l10n_fr_hr_work_entry_holidays/models/hr_work_entry.py b/addons/l10n_fr_hr_work_entry_holidays/models/hr_work_entry.py new file mode 100644 index 00000000000..4e430596422 --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/models/hr_work_entry.py @@ -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 diff --git a/addons/l10n_fr_hr_work_entry_holidays/tests/__init__.py b/addons/l10n_fr_hr_work_entry_holidays/tests/__init__.py new file mode 100644 index 00000000000..3ff31f67443 --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/tests/__init__.py @@ -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 diff --git a/addons/l10n_fr_hr_work_entry_holidays/tests/test_french_work_entries.py b/addons/l10n_fr_hr_work_entry_holidays/tests/test_french_work_entries.py new file mode 100644 index 00000000000..3940b05c9fd --- /dev/null +++ b/addons/l10n_fr_hr_work_entry_holidays/tests/test_french_work_entries.py @@ -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.')