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.')