diff --git a/addons/hr/models/resource.py b/addons/hr/models/resource.py index b59767f6254..d0db7c76b82 100644 --- a/addons/hr/models/resource.py +++ b/addons/hr/models/resource.py @@ -1,10 +1,37 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from pytz import utc, timezone +from datetime import datetime from odoo import fields, models +from odoo.addons.resource.models.resource import Intervals class ResourceResource(models.Model): _inherit = "resource.resource" user_id = fields.Many2one(copy=False) + employee_id = fields.One2many('hr.employee', 'resource_id', domain="[('company_id', '=', company_id)]") + + def _get_calendars_validity_within_period(self, start, end, default_company=None): + """ + Returns a dict of dict with resource's id as first key and resource's calendar as secondary key + The value is the validity interval of the calendar for the given resource. + + The validity interval of the employee resource calendar is the lifetime of the employee, from creation to departure. + """ + assert start.tzinfo and end.tzinfo + calendars_within_period_per_resource = super()._get_calendars_validity_within_period(start, end, default_company=default_company) + for resource in self: + if not resource.employee_id: + continue + create_date = max(start, utc.localize(resource.employee_id.create_date)) + if resource.employee_id.departure_date and resource.employee_id.departure_date <= end.date(): + departure_datetime = timezone(resource.tz).localize(datetime.combine(resource.employee_id.departure_date, datetime.max.time())) + departure_datetime = min(departure_datetime, end) + else: + departure_datetime = end + interval = Intervals([(create_date, departure_datetime, self.env['resource.calendar.attendance'])]) + for calendar in calendars_within_period_per_resource[resource.id]: + calendars_within_period_per_resource[resource.id][calendar] &= interval + return calendars_within_period_per_resource diff --git a/addons/hr/tests/__init__.py b/addons/hr/tests/__init__.py index c46b7cc336f..17a4487628d 100644 --- a/addons/hr/tests/__init__.py +++ b/addons/hr/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_channel from . import test_hr_flow from . import test_self_user_access from . import test_multi_company +from . import test_resource diff --git a/addons/hr/tests/test_resource.py b/addons/hr/tests/test_resource.py new file mode 100644 index 00000000000..490fe343694 --- /dev/null +++ b/addons/hr/tests/test_resource.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime +from pytz import timezone, utc + +from odoo.addons.resource.models.resource import Intervals, sum_intervals + +from .common import TestHrCommon + + +class TestResource(TestHrCommon): + + @classmethod + def setUpClass(cls): + super(TestResource, cls).setUpClass() + cls.calendar_40h = cls.env['resource.calendar'].create({'name': 'Default calendar'}) + cls.employee_niv = cls.env['hr.employee'].create({ + 'name': 'Sharlene Rhodes', + 'departure_date': '2022-06-01', + 'resource_calendar_id': cls.calendar_40h.id, + }) + cls.employee_niv_create_date = '2021-01-01 10:00:00' + cls.env.cr.execute("UPDATE hr_employee SET create_date=%s WHERE id=%s", + (cls.employee_niv_create_date, cls.employee_niv.id)) + + def test_calendars_validity_within_period_default(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[self.employee_niv.resource_id.id]), "Niv should only have one calendar") + niv_entry = calendars[self.employee_niv.resource_id.id] + niv_calendar = next(iter(niv_entry)) + self.assertEqual(niv_calendar, self.calendar_40h, "It should be Niv's Calendar") + self.assertFalse(niv_entry[niv_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - niv_entry[niv_calendar], "Calendar validity should cover all interval") + + def test_calendars_validity_within_period_creation(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2020, 12, 1, 8, 0, 0)), + utc.localize(datetime(2021, 1, 31, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2021, 1, 1, 10, 0, 0)), + utc.localize(datetime(2021, 1, 31, 17, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + niv_entry = calendars[self.employee_niv.resource_id.id] + self.assertFalse(niv_entry[self.calendar_40h] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - niv_entry[self.calendar_40h], "Calendar validity should cover all interval") + + def test_calendars_validity_within_period_before_creation(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2020, 12, 1, 8, 0, 0)), + utc.localize(datetime(2020, 12, 31, 17, 0, 0)), + ) + niv_entry = calendars[self.employee_niv.resource_id.id] + self.assertFalse(niv_entry[self.calendar_40h], "Interval should be empty") + + def test_calendars_validity_within_period_before_departure(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2022, 5, 1, 8, 0, 0)), + utc.localize(datetime(2022, 6, 30, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2022, 5, 1, 8, 0, 0)), + timezone(self.employee_niv.tz).localize(datetime(2022, 6, 1, 23, 59, 59, 999999)), + self.env['resource.calendar.attendance'] + )]) + niv_entry = calendars[self.employee_niv.resource_id.id] + self.assertFalse(niv_entry[self.calendar_40h] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - niv_entry[self.calendar_40h], "Calendar validity should cover all interval") + + def test_get_valid_work_intervals(self): + start = utc.localize(datetime(2022, 5, 29, 0, 0, 0)) + end = utc.localize(datetime(2022, 6, 4, 23, 59, 59)) + work_intervals, _ = self.employee_niv.resource_id._get_valid_work_intervals(start, end) + sum_work_intervals = sum_intervals(work_intervals[self.employee_niv.resource_id.id]) + self.assertEqual(24, sum_work_intervals, "Sum of the work intervals for the resource niv should be 24h as he left his work after wednesday.") diff --git a/addons/hr_contract/models/__init__.py b/addons/hr_contract/models/__init__.py index ce5e380e01d..0c4bf551ae3 100644 --- a/addons/hr_contract/models/__init__.py +++ b/addons/hr_contract/models/__init__.py @@ -6,4 +6,5 @@ from . import hr_employee_public from . import hr_contract from . import res_users from . import resource +from . import resource_resource from . import hr_contract_type diff --git a/addons/hr_contract/models/resource.py b/addons/hr_contract/models/resource.py index ad4079e2f65..ba000c4de18 100644 --- a/addons/hr_contract/models/resource.py +++ b/addons/hr_contract/models/resource.py @@ -1,6 +1,5 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - from datetime import datetime from odoo import fields, models diff --git a/addons/hr_contract/models/resource_resource.py b/addons/hr_contract/models/resource_resource.py new file mode 100644 index 00000000000..5ac7e078eb2 --- /dev/null +++ b/addons/hr_contract/models/resource_resource.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict +from datetime import datetime +from pytz import timezone + +from odoo import models +from odoo.addons.resource.models.resource import Intervals + +class ResourceResource(models.Model): + _inherit = 'resource.resource' + + def _get_calendars_validity_within_period(self, start, end, default_company=None): + assert start.tzinfo and end.tzinfo + if not self: + return super()._get_calendars_validity_within_period(start, end, default_company=default_company) + calendars_within_period_per_resource = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']] + resource_without_contract = self.filtered(lambda r: not r.employee_id or r.employee_id.employee_type not in ['employee', 'student']) + if resource_without_contract: + calendars_within_period_per_resource.update( + super(ResourceResource, resource_without_contract)._get_calendars_validity_within_period(start, end, default_company=default_company) + ) + resource_with_contract = self - resource_without_contract + if not resource_with_contract: + return calendars_within_period_per_resource + timezones = {resource.tz for resource in resource_with_contract} + date_start = min(start.astimezone(timezone(tz)).date() for tz in timezones) + date_end = max(end.astimezone(timezone(tz)).date() for tz in timezones) + contracts = resource_with_contract.employee_id._get_contracts( + date_start, date_end, states=['open', 'draft', 'close'] + ).filtered(lambda c: c.state in ['open', 'close'] or c.kanban_state == 'done') + for contract in contracts: + tz = timezone(contract.employee_id.tz) + calendars_within_period_per_resource[contract.employee_id.resource_id.id][contract.resource_calendar_id] |= Intervals([( + tz.localize(datetime.combine(contract.date_start, datetime.min.time())) if contract.date_start > start.astimezone(tz).date() else start, + tz.localize(datetime.combine(contract.date_end, datetime.max.time())) if contract.date_end and contract.date_end < end.astimezone(tz).date() else end, + self.env['resource.calendar.attendance'] + )]) + return calendars_within_period_per_resource diff --git a/addons/hr_contract/tests/__init__.py b/addons/hr_contract/tests/__init__.py index d798e53356b..6f1137322aa 100644 --- a/addons/hr_contract/tests/__init__.py +++ b/addons/hr_contract/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_auto_status from . import test_contract from . import test_calendar_sync +from . import test_resource diff --git a/addons/hr_contract/tests/test_calendar_sync.py b/addons/hr_contract/tests/test_calendar_sync.py index cecbe399225..40493ddaf9e 100644 --- a/addons/hr_contract/tests/test_calendar_sync.py +++ b/addons/hr_contract/tests/test_calendar_sync.py @@ -66,4 +66,4 @@ class TestContractCalendars(TestContractCommon): # Transfer global leaves self.calendar_richard.transfer_leaves_to(self.calendar_35h, resources=None, from_date=Date.to_date('2015-11-21')) - self.assertEqual(leave3.calendar_id, self.calendar_35h, "Global leave should be transfered") \ No newline at end of file + self.assertEqual(leave3.calendar_id, self.calendar_35h, "Global leave should be transfered") diff --git a/addons/hr_contract/tests/test_resource.py b/addons/hr_contract/tests/test_resource.py new file mode 100644 index 00000000000..0a6021c6051 --- /dev/null +++ b/addons/hr_contract/tests/test_resource.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date, datetime +from pytz import utc, timezone + +from odoo.addons.resource.models.resource import Intervals, sum_intervals +from odoo.fields import Date + +from .common import TestContractCommon + +class TestResource(TestContractCommon): + + @classmethod + def setUpClass(cls): + super(TestResource, cls).setUpClass() + cls.calendar_richard = cls.env['resource.calendar'].create({'name': 'Calendar of Richard'}) + cls.employee.resource_calendar_id = cls.calendar_richard + + cls.calendar_35h = cls.env['resource.calendar'].create({ + 'name': '35h calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Evening', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Evening', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Evening', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Evening', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Evening', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}) + ], + }) + cls.calendar_35h._onchange_hours_per_day() # update hours/day + + cls.contract_cdd = cls.env['hr.contract'].create({ + 'date_start': Date.to_date('2021-09-01'), + 'date_end': Date.to_date('2021-10-31'), + 'name': 'First CDD Contract for Richard', + 'resource_calendar_id': cls.calendar_35h.id, + 'wage': 5000.0, + 'employee_id': cls.employee.id, + 'state': 'open', + }) + cls.contract_cdi = cls.env['hr.contract'].create({ + 'date_start': Date.to_date('2021-11-01'), + 'name': 'CDI Contract for Richard', + 'resource_calendar_id': cls.calendar_richard.id, + 'wage': 5000.0, + 'employee_id': cls.employee.id, + 'state': 'draft', + 'kanban_state': 'done', + }) + + def test_calendars_validity_within_period(self): + tz = timezone(self.employee.tz) + calendars = self.employee.resource_id._get_calendars_validity_within_period( + tz.localize(datetime(2021, 10, 1, 0, 0, 0)), + tz.localize(datetime(2021, 12, 1, 0, 0, 0)), + ) + interval_35h = Intervals([( + tz.localize(datetime(2021, 10, 1, 0, 0, 0)), + tz.localize(datetime.combine(date(2021, 10, 31), datetime.max.time())), + self.env['resource.calendar.attendance'] + )]) + interval_40h = Intervals([( + tz.localize(datetime(2021, 11, 1, 0, 0, 0)), + tz.localize(datetime(2021, 12, 1, 0, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(2, len(calendars[self.employee.resource_id.id]), "Jean should only have one calendar") + richard_entries = calendars[self.employee.resource_id.id] + for calendar in richard_entries: + self.assertTrue(calendar in (self.calendar_35h | self.calendar_richard), "Each calendar should be listed") + if calendar == self.calendar_35h: + self.assertFalse(richard_entries[calendar] - interval_35h, "Interval 35h should cover all calendar 35h validity") + self.assertFalse(interval_35h - richard_entries[calendar], "Calendar 35h validity should cover all interval 35h") + elif calendar == self.calendar_richard: + self.assertFalse(richard_entries[calendar] - interval_40h, "Interval 40h should cover all calendar 40h validity") + self.assertFalse(interval_40h - richard_entries[calendar], "Calendar 40h validity should cover all interval 40h") + + def test_queries(self): + employees_test = self.env['hr.employee'].create([{ + 'name': 'Employee ' + str(i), + } for i in range(0, 50)]) + for emp in employees_test: + new_contract = self.contract_cdd.copy() + new_contract.employee_id = emp + new_contract.state = 'open' + new_contract = self.contract_cdi.copy() + new_contract.employee_id = emp + new_contract.state = 'draft' + new_contract.kanban_state = 'done' + + start = utc.localize(datetime(2021, 9, 1, 0, 0, 0)) + end = utc.localize(datetime(2021, 11, 30, 23, 59, 59)) + with self.assertQueryCount(14): + work_intervals, _ = (employees_test | self.employee).resource_id._get_valid_work_intervals(start, end) + + self.assertEqual(len(work_intervals), 51) + + def test_get_valid_work_intervals(self): + start = timezone(self.employee.tz).localize(datetime(2021, 10, 24, 2, 0, 0)) + end = timezone(self.employee.tz).localize(datetime(2021, 11, 6, 23, 59, 59)) + work_intervals, _ = self.employee.resource_id._get_valid_work_intervals(start, end) + sum_work_intervals = sum_intervals(work_intervals[self.employee.resource_id.id]) + self.assertEqual(75, sum_work_intervals, "Sum of the work intervals for the employee should be 35h+40h = 75h") diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index e1050d4a5a2..9c815aacb1a 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -138,6 +138,12 @@ class Intervals(object): return result +def sum_intervals(intervals): + """ Sum the intervals duration (unit : hour)""" + return sum( + (stop - start).total_seconds() / 3600 + for start, stop, meta in intervals + ) class ResourceCalendar(models.Model): """ Calendar model for a resource. It has @@ -382,13 +388,15 @@ class ResourceCalendar(models.Model): """ Return the attendance intervals in the given datetime range. The returned intervals are expressed in specified tz or in the resource's timezone. """ - self.ensure_one() - resources = self.env['resource.resource'] if not resources else resources assert start_dt.tzinfo and end_dt.tzinfo self.ensure_one() combine = datetime.combine - resources_list = list(resources) + [self.env['resource.resource']] + if not resources: + resources = self.env['resource.resource'] + resources_list = [resources] + else: + resources_list = list(resources) + [self.env['resource.resource']] resource_ids = [r.id for r in resources_list] domain = domain if domain is not None else [] domain = expression.AND([domain, [ @@ -472,15 +480,18 @@ class ResourceCalendar(models.Model): """ Return the leave intervals in the given datetime range. The returned intervals are expressed in specified tz or in the calendar's timezone. """ - resources = self.env['resource.resource'] if not resources else resources assert start_dt.tzinfo and end_dt.tzinfo self.ensure_one() - # for the computation, express all datetimes in UTC - resources_list = list(resources) + [self.env['resource.resource']] + if not resources: + resources = self.env['resource.resource'] + resources_list = [resources] + else: + resources_list = list(resources) + [self.env['resource.resource']] resource_ids = [r.id for r in resources_list] if domain is None: domain = [('time_type', '=', 'leave')] + # for the computation, express all datetimes in UTC domain = domain + [ ('calendar_id', 'in', [False, self.id]), ('resource_id', 'in', resource_ids), @@ -518,7 +529,7 @@ class ResourceCalendar(models.Model): resources = self.env['resource.resource'] resources_list = [resources] else: - resources_list = list(resources) + resources_list = list(resources) + [self.env['resource.resource']] attendance_intervals = self._attendance_intervals_batch(start_dt, end_dt, resources, tz=tz) if compute_leaves: @@ -588,8 +599,11 @@ class ResourceCalendar(models.Model): @return dict with hours of attendance in each day between `from_datetime` and `to_datetime` """ self.ensure_one() - resources = self.env['resource.resource'] if not resources else resources - resources_list = list(resources) + [self.env['resource.resource']] + if not resources: + resources = self.env['resource.resource'] + resources_list = [resources] + else: + resources_list = list(resources) + [self.env['resource.resource']] # total hours per day: retrieve attendances with one extra day margin, # in order to compute the total hours on the first and last days from_full = from_datetime - timedelta(days=1) @@ -994,7 +1008,6 @@ class ResourceResource(models.Model): ) return result - def _get_unavailable_intervals(self, start, end): """ Compute the intervals during which employee is unavailable with hour granularity between start and end Note: this method is used in enterprise (forecast and planning) @@ -1012,6 +1025,52 @@ class ResourceResource(models.Model): resource_mapping.update(resources_unavailable_intervals) return resource_mapping + def _get_calendars_validity_within_period(self, start, end, default_company=None): + """ Gets a dict of dict with resource's id as first key and resource's calendar as secondary key + The value is the validity interval of the calendar for the given resource. + + Here the validity interval for each calendar is the whole interval but it's meant to be overriden in further modules + handling resource's employee contracts. + """ + assert start.tzinfo and end.tzinfo + resource_calendars_within_period = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']] + default_calendar = default_company and default_company.resource_calendar_id or self.env.company.resource_calendar_id + if not self: + # if no resource, add the company resource calendar. + resource_calendars_within_period[False][default_calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])]) + for resource in self: + calendar = resource.calendar_id or resource.company_id.resource_calendar_id or default_calendar + resource_calendars_within_period[resource.id][calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])]) + return resource_calendars_within_period + + def _get_valid_work_intervals(self, start, end, calendars=None): + """ Gets the valid work intervals of the resource following their calendars between ``start`` and ``end`` + + This methods handle the eventuality of a resource having multiple resource calendars, see _get_calendars_validity_within_period method + for further explanation. + """ + assert start.tzinfo and end.tzinfo + resource_calendar_validity_intervals = {} + calendar_resources = defaultdict(lambda: self.env['resource.resource']) + resource_work_intervals = defaultdict(Intervals) + calendar_work_intervals = dict() + + resource_calendar_validity_intervals = self._get_calendars_validity_within_period(start, end) + for resource in self: + # For each resource, retrieve its calendar and their validity intervals + for calendar in resource_calendar_validity_intervals[resource.id]: + calendar_resources[calendar] |= resource + for calendar in (calendars or []): + calendar_resources[calendar] |= self.env['resource.resource'] + for calendar, resources in calendar_resources.items(): + # For each calendar used by the resources, retrieve the work intervals for every resources using it + work_intervals_batch = calendar._work_intervals_batch(start, end, resources=resources) + for resource in resources: + # Make the conjunction between work intervals and calendar validity + resource_work_intervals[resource.id] |= work_intervals_batch[resource.id] & resource_calendar_validity_intervals[resource.id][calendar] + calendar_work_intervals[calendar.id] = work_intervals_batch[False] + + return resource_work_intervals, calendar_work_intervals class ResourceCalendarLeaves(models.Model): _name = "resource.calendar.leaves" diff --git a/addons/resource/tests/common.py b/addons/resource/tests/common.py index d4b19ccd701..ee2dee6dff7 100644 --- a/addons/resource/tests/common.py +++ b/addons/resource/tests/common.py @@ -6,8 +6,9 @@ from odoo.tests.common import TransactionCase class TestResourceCommon(TransactionCase): - def _define_calendar(self, name, attendances, tz): - return self.env['resource.calendar'].create({ + @classmethod + def _define_calendar(cls, name, attendances, tz): + return cls.env['resource.calendar'].create({ 'name': name, 'tz': tz, 'attendance_ids': [ @@ -20,8 +21,10 @@ class TestResourceCommon(TransactionCase): for index, att in enumerate(attendances) ], }) - def _define_calendar_2_weeks(self, name, attendances, tz): - return self.env['resource.calendar'].create({ + + @classmethod + def _define_calendar_2_weeks(cls, name, attendances, tz): + return cls.env['resource.calendar'].create({ 'name': name, 'tz': tz, 'two_weeks_calendar': True, @@ -39,42 +42,43 @@ class TestResourceCommon(TransactionCase): ], }) - def setUp(self): - super(TestResourceCommon, self).setUp() + @classmethod + def setUpClass(cls): + super(TestResourceCommon, cls).setUpClass() # UTC+1 winter, UTC+2 summer - self.calendar_jean = self._define_calendar('40 Hours', [(8, 16, i) for i in range(5)], 'Europe/Brussels') + cls.calendar_jean = cls._define_calendar('40 Hours', [(8, 16, i) for i in range(5)], 'Europe/Brussels') # UTC+6 - self.calendar_patel = self._define_calendar('38 Hours', sum([((9, 12, i), (13, 17, i)) for i in range(5)], ()), 'Etc/GMT-6') + cls.calendar_patel = cls._define_calendar('38 Hours', sum([((9, 12, i), (13, 17, i)) for i in range(5)], ()), 'Etc/GMT-6') # UTC-8 winter, UTC-7 summer - self.calendar_john = self._define_calendar('8+12 Hours', [(8, 16, 1), (8, 13, 4), (16, 23, 4)], 'America/Los_Angeles') + cls.calendar_john = cls._define_calendar('8+12 Hours', [(8, 16, 1), (8, 13, 4), (16, 23, 4)], 'America/Los_Angeles') # UTC+1 winter, UTC+2 summer - self.calendar_jules = self._define_calendar_2_weeks('Week 1: 30 Hours - Week 2: 16 Hours', [ + cls.calendar_jules = cls._define_calendar_2_weeks('Week 1: 30 Hours - Week 2: 16 Hours', [ (0, 0, 0, '0', 'line_section', 0), (8, 16, 0, '0', False, 1), (9, 17, 1, '0', False, 2), (0, 0, 0, '1', 'line_section', 10), (8, 16, 0, '1', False, 11), (7, 15, 2, '1', False, 12), (8, 16, 3, '1', False, 13), (10, 16, 4, '1', False, 14)], 'Europe/Brussels') - self.calendar_paul = self._define_calendar('Morning and evening shifts', sum([((2, 7, i), (10, 16, i)) for i in range(5)], ()), 'Brazil/DeNoronha') + cls.calendar_paul = cls._define_calendar('Morning and evening shifts', sum([((2, 7, i), (10, 16, i)) for i in range(5)], ()), 'Brazil/DeNoronha') # Employee is linked to a resource.resource via resource.mixin - self.jean = self.env['resource.test'].create({ + cls.jean = cls.env['resource.test'].create({ 'name': 'Jean', - 'resource_calendar_id': self.calendar_jean.id, + 'resource_calendar_id': cls.calendar_jean.id, }) - self.patel = self.env['resource.test'].create({ + cls.patel = cls.env['resource.test'].create({ 'name': 'Patel', - 'resource_calendar_id': self.calendar_patel.id, + 'resource_calendar_id': cls.calendar_patel.id, }) - self.john = self.env['resource.test'].create({ + cls.john = cls.env['resource.test'].create({ 'name': 'John', - 'resource_calendar_id': self.calendar_john.id, + 'resource_calendar_id': cls.calendar_john.id, }) - self.jules = self.env['resource.test'].create({ + cls.jules = cls.env['resource.test'].create({ 'name': 'Jules', - 'resource_calendar_id': self.calendar_jules.id, + 'resource_calendar_id': cls.calendar_jules.id, }) - self.paul = self.env['resource.test'].create({ + cls.paul = cls.env['resource.test'].create({ 'name': 'Paul', - 'resource_calendar_id': self.calendar_paul.id, + 'resource_calendar_id': cls.calendar_paul.id, }) diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index f15058c26ce..27127b630e3 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -6,7 +6,7 @@ from pytz import timezone, utc from odoo import fields from odoo.exceptions import ValidationError -from odoo.addons.resource.models.resource import Intervals +from odoo.addons.resource.models.resource import Intervals, sum_intervals from odoo.addons.resource.tests.common import TestResourceCommon from odoo.tests.common import TransactionCase @@ -1148,3 +1148,72 @@ class TestTimezones(TestResourceCommon): (date(2018, 4, 12), 8), (date(2018, 4, 13), 8), ]) + +class TestResource(TestResourceCommon): + + def test_calendars_validity_within_period(self): + calendars = self.jean.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[self.jean.resource_id.id]), "Jean should only have one calendar") + jean_entry = calendars[self.jean.resource_id.id] + jean_calendar = next(iter(jean_entry)) + self.assertEqual(self.jean.resource_calendar_id, jean_calendar, "It should be Jean's Calendar") + self.assertFalse(jean_entry[jean_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - jean_entry[jean_calendar], "Calendar validity should cover all interval") + + calendars = self.env['resource.resource']._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[False]), "False (default) should only have one calendar") + false_entry = calendars[False] + false_calendar = next(iter(false_entry)) + self.assertEqual(self.env.company.resource_calendar_id, false_calendar, "It should be company calendar Calendar") + self.assertFalse(false_entry[false_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - false_entry[false_calendar], "Calendar validity should cover all interval") + + def test_performance(self): + calendars = [self.calendar_jean, self.calendar_john, self.calendar_jules, self.calendar_patel] + calendars_len = len(calendars) + self.resources_test = self.env['resource.test'].create([{ + 'name': 'resource ' + str(i), + 'resource_calendar_id': calendars[i % calendars_len].id, + } for i in range(0, 50)]) + + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + with self.assertQueryCount(12): + work_intervals, _ = self.resources_test.resource_id._get_valid_work_intervals(start, end) + + self.assertEqual(len(work_intervals), 50) + + def test_get_valid_work_intervals(self): + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + work_intervals, _ = self.jean.resource_id._get_valid_work_intervals(start, end) + sum_work_intervals = sum_intervals(work_intervals[self.jean.resource_id.id]) + self.assertEqual(58, sum_work_intervals, "Sum of the work intervals for the resource jean should be 40h+18h = 58h") + + def test_get_valid_work_intervals_calendars_only(self): + calendars = [self.calendar_jean, self.calendar_john, self.calendar_jules, self.calendar_patel] + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + _, calendars_intervals = self.env['resource.resource']._get_valid_work_intervals(start, end, calendars) + sum_work_intervals_jean = sum_intervals(calendars_intervals[self.calendar_jean.id]) + self.assertEqual(58, sum_work_intervals_jean, "Sum of the work intervals for the calendar of jean should be 40h+18h = 58h") + sum_work_intervals_john = sum_intervals(calendars_intervals[self.calendar_john.id]) + self.assertEqual(26 - 1 / 3600, sum_work_intervals_john, "Sum of the work intervals for the calendar of john should be 20h+6h-1s = 25h59m59s") + sum_work_intervals_jules = sum_intervals(calendars_intervals[self.calendar_jules.id]) + self.assertEqual(31, sum_work_intervals_jules, "Sum of the work intervals for the calendar of jules should be Wodd:15h+Wpair:16h = 31h") + sum_work_intervals_patel = sum_intervals(calendars_intervals[self.calendar_patel.id]) + self.assertEqual(49, sum_work_intervals_patel, "Sum of the work intervals for the calendar of patel should be 14+35h = 49h")