From a9b804b1ad6c8fb34e6ac09d8134eacbce174760 Mon Sep 17 00:00:00 2001 From: Rob Coekaerts Date: Fri, 5 May 2023 17:18:43 +0200 Subject: [PATCH] [IMP] resource: add duration to resource_calendar_attendance Before this commit, conversion between worked hours and days in resource_calendar_attendance was calculated but in reality there is no unambigous way to do this. Eg in Belgium the morning working period is 4 hours, the one in the afternoon is 3 hours 36 minutes, while both of them are still counted as half days. To mediate this, the duration in days is explicitely added to resource.calendar.attendance, with sensible default being provided (half a day for morning and afternoon periods, 0 for lunch). task-3131517 Part-of: odoo/odoo#133145 --- addons/hr_holidays/models/hr_leave.py | 23 ++++++------- .../tests/test_automatic_leave_dates.py | 14 ++++---- addons/resource/models/resource_calendar.py | 34 ++++++++++++++++--- .../models/resource_calendar_attendance.py | 22 ++++++++++-- addons/resource/models/resource_mixin.py | 10 ++---- .../resource_calendar_attendance_views.xml | 4 ++- addons/test_resource/tests/common.py | 9 ++--- 7 files changed, 79 insertions(+), 37 deletions(-) diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index f48d1c26066..059b7541a33 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -377,19 +377,18 @@ class HolidaysRequest(models.Model): if (holiday.request_unit_half or holiday.request_unit_hours) and holiday.request_date_to != holiday.request_date_from: holiday.request_date_to = holiday.request_date_from - attendance_from, attendance_to = holiday._get_attendances(holiday.request_date_from, holiday.request_date_to) + + day_period = { + 'am': 'morning', + 'pm': 'afternoon' + }.get(holiday.request_date_from_period, None) if holiday.request_unit_half else None + + attendance_from, attendance_to = holiday._get_attendances(holiday.request_date_from, holiday.request_date_to, day_period=day_period) compensated_request_date_from = holiday.request_date_from compensated_request_date_to = holiday.request_date_to - if holiday.request_unit_half: - if holiday.request_date_from_period == 'am': - hour_from = attendance_from.hour_from - hour_to = attendance_from.hour_to - else: - hour_from = attendance_to.hour_from - hour_to = attendance_to.hour_to - elif holiday.request_unit_hours: + if holiday.request_unit_hours: hour_from = holiday.request_hour_from hour_to = holiday.request_hour_to else: @@ -546,8 +545,6 @@ class HolidaysRequest(models.Model): False) hours = resource_calendar.get_work_hours_count(self.date_from, self.date_to) days = hours / (today_hours or HOURS_PER_DAY) - if self.request_unit_half and hours > 0 and hours < HOURS_PER_DAY: - days = 0.5 return (days, hours) @@ -1593,13 +1590,15 @@ class HolidaysRequest(models.Model): holiday_tz = timezone(resource.tz or self.env.user.tz or 'UTC') return holiday_tz.localize(datetime.combine(date, hour)).astimezone(UTC).replace(tzinfo=None) - def _get_attendances(self, request_date_from, request_date_to): + def _get_attendances(self, request_date_from, request_date_to, day_period=None): self.ensure_one() domain = [ ('calendar_id', '=', self.resource_calendar_id.id), ('display_type', '=', False), ('day_period', '!=', 'lunch'), ] + if day_period: + domain.append(('day_period', '=', day_period)) attendances = self.env['resource.calendar.attendance']._read_group(domain, ['week_type', 'dayofweek', 'day_period'], ['hour_from:min', 'hour_to:max']) diff --git a/addons/hr_holidays/tests/test_automatic_leave_dates.py b/addons/hr_holidays/tests/test_automatic_leave_dates.py index 78991b18b3b..23cf3d1e4d4 100644 --- a/addons/hr_holidays/tests/test_automatic_leave_dates.py +++ b/addons/hr_holidays/tests/test_automatic_leave_dates.py @@ -93,6 +93,7 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): 'hour_to': 10, 'day_period': 'morning', 'dayofweek': '0', + 'duration_days': 0.25, }), (0, 0, { 'name': 'monday morning 2', @@ -100,6 +101,7 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): 'hour_to': 12.25, 'day_period': 'morning', 'dayofweek': '0', + 'duration_days': 0.25, }), (0, 0, { 'name': 'monday lunch', @@ -114,6 +116,7 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): 'hour_to': 17, 'day_period': 'afternoon', 'dayofweek': '0', + 'duration_days': 0.5, })] }) employee = self.employee_emp @@ -135,9 +138,6 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): self.assertEqual(leave_form.number_of_hours_text, '4 Hours') def test_attendance_on_morning(self): - # TODO: temporarily fixed this test. The behaviour of half-day leaves - # currently not very well defined. This will be handled in an - # upcoming task. calendar = self.env['resource.calendar'].create({ 'name': 'Morning only', 'attendance_ids': [(5, 0, 0), @@ -165,8 +165,8 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): # Ask for afternoon leave_form.request_date_from_period = 'pm' - self.assertEqual(leave_form.number_of_days_display, 1) - self.assertEqual(leave_form.number_of_hours_text, '8 Hours') + self.assertEqual(leave_form.number_of_days_display, 0) + self.assertEqual(leave_form.number_of_hours_text, '0 Hours') def test_attendance_next_day(self): self.env.user.tz = 'Europe/Brussels' @@ -241,6 +241,7 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): 'day_period': 'morning', 'dayofweek': '0', 'week_type': '0', + 'duration_days': 0.5, }), (0, 0, { 'name': 'monday morning even week', @@ -249,6 +250,7 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): 'day_period': 'morning', 'dayofweek': '0', 'week_type': '1', + 'duration_days': 0.25 })] }) employee = self.employee_emp @@ -262,7 +264,7 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon): leave_form.request_unit_half = True leave_form.request_date_from_period = 'am' - self.assertEqual(leave_form.number_of_days_display, 0.5) + self.assertEqual(leave_form.number_of_days_display, 0.25) self.assertEqual(leave_form.number_of_hours_text, '2 Hours') self.assertEqual(leave_form.date_from, datetime(2019, 9, 2, 8, 0, 0)) self.assertEqual(leave_form.date_to, datetime(2019, 9, 2, 10, 0, 0)) diff --git a/addons/resource/models/resource_calendar.py b/addons/resource/models/resource_calendar.py index 9855616d792..45fe7c437b3 100644 --- a/addons/resource/models/resource_calendar.py +++ b/addons/resource/models/resource_calendar.py @@ -166,11 +166,12 @@ class ResourceCalendar(models.Model): and not attendance.resource_id and not attendance.display_type) def _get_hours_per_day(self, attendances): + """ + Calculate the average hours worked per workday. + """ if not attendances: return 0 - attendances = attendances.filtered(lambda a: a.day_period != 'lunch') - hour_count = 0.0 for attendance in attendances: hour_count += attendance.hour_to - attendance.hour_from @@ -470,6 +471,31 @@ class ResourceCalendar(models.Model): # Private Methods / Helpers # -------------------------------------------------- + def _get_attendance_intervals_days_data(self, attendance_intervals): + """ + helper function to compute duration of `intervals` that have + 'resource.calendar.attendance' records as payload (3rd element in tuple). + expressed in days and hours. + + resource.calendar.attendance records have durations associated + with them so this method merely calculates the proportion that is + covered by the intervals. + """ + day_hours = defaultdict(float) + day_days = defaultdict(float) + for start, stop, meta in attendance_intervals: + # If the interval covers only a part of the original attendance, we + # take durations in days proportionally to what is left of the interval. + interval_hours = (stop - start).total_seconds() / 3600 + day_hours[start.date()] += interval_hours + day_days[start.date()] += meta.duration_days * interval_hours / meta.duration_hours + + return { + # Round the number of days to the closest 16th of a day. + 'days': sum(float_utils.round(ROUNDING_FACTOR * day_days[day]) / ROUNDING_FACTOR for day in day_days), + 'hours': sum(day_hours.values()), + } + def _get_days_data(self, intervals, day_total): """ helper function to compute duration of `intervals` @@ -607,15 +633,13 @@ class ResourceCalendar(models.Model): from_datetime, dummy = make_aware(from_datetime) to_datetime, dummy = make_aware(to_datetime) - day_total = self._get_resources_day_total(from_datetime, to_datetime)[False] - # actual hours per day if compute_leaves: intervals = self._work_intervals_batch(from_datetime, to_datetime, domain=domain)[False] else: intervals = self._attendance_intervals_batch(from_datetime, to_datetime, domain=domain)[False] - return self._get_days_data(intervals, day_total) + return self._get_attendance_intervals_days_data(intervals) def plan_hours(self, hours, day_dt, compute_leaves=False, domain=None, resource=None): """ diff --git a/addons/resource/models/resource_calendar_attendance.py b/addons/resource/models/resource_calendar_attendance.py index c9d6e5b4e13..8e6ea708718 100644 --- a/addons/resource/models/resource_calendar_attendance.py +++ b/addons/resource/models/resource_calendar_attendance.py @@ -9,7 +9,7 @@ from odoo import api, fields, models, _ class ResourceCalendarAttendance(models.Model): _name = "resource.calendar.attendance" _description = "Work Detail" - _order = 'week_type, dayofweek, hour_from' + _order = 'sequence, week_type, dayofweek, hour_from' name = fields.Char(required=True) dayofweek = fields.Selection([ @@ -27,10 +27,15 @@ class ResourceCalendarAttendance(models.Model): help="Start and End time of working.\n" "A specific value of 24:00 is interpreted as 23:59:59.999999.") hour_to = fields.Float(string='Work to', required=True) + # For the hour duration, the compute function is used to compute the value + # unambiguously, while the duration in days is computed for the default + # value based on the day_period but can be manually overridden. + duration_hours = fields.Float(compute='_compute_duration_hours', string='Duration (hours)') + duration_days = fields.Float(compute='_compute_duration_days', string='Duration (days)', store=True, readonly=False) calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade') day_period = fields.Selection([ ('morning', 'Morning'), - ('lunch', 'Lunch'), + ('lunch', 'Break'), ('afternoon', 'Afternoon')], required=True, default='morning') resource_id = fields.Many2one('resource.resource', 'Resource') week_type = fields.Selection([ @@ -64,6 +69,19 @@ class ResourceCalendarAttendance(models.Model): # some years have 53 weeks. Therefore, two consecutive odd week number follow each other (53 --> 1). return int(math.floor((date.toordinal() - 1) / 7) % 2) + @api.depends('hour_from', 'hour_to') + def _compute_duration_hours(self): + for attendance in self: + attendance.duration_hours = (attendance.hour_to - attendance.hour_from) if attendance.day_period != 'lunch' else 0 + + @api.depends('day_period', 'hour_from', 'hour_to') + def _compute_duration_days(self): + for attendance in self: + if attendance.day_period == 'lunch': + attendance.duration_days = 0 + else: + attendance.duration_days = 0.5 if attendance.duration_hours <= attendance.calendar_id.hours_per_day * 3 / 4 else 1 + @api.depends('week_type') def _compute_display_name(self): super()._compute_display_name() diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index c64e1a8b664..f8d2c411961 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -104,7 +104,6 @@ class ResourceMixin(models.AbstractModel): for calendar_resource in calendar_resources: result[calendar_resource.id] = {'days': 0, 'hours': 0} continue - day_total = calendar._get_resources_day_total(from_datetime, to_datetime, calendar_resources) # actual hours per day if compute_leaves: @@ -113,7 +112,7 @@ class ResourceMixin(models.AbstractModel): intervals = calendar._attendance_intervals_batch(from_datetime, to_datetime, calendar_resources) for calendar_resource in calendar_resources: - result[calendar_resource.id] = calendar._get_days_data(intervals[calendar_resource.id], day_total[calendar_resource.id]) + result[calendar_resource.id] = calendar._get_attendance_intervals_days_data(intervals[calendar_resource.id]) # convert "resource: result" into "employee: result" return {mapped_employees[r.id]: result[r.id] for r in resources} @@ -142,16 +141,13 @@ class ResourceMixin(models.AbstractModel): mapped_resources[calendar or record.resource_calendar_id] |= record.resource_id for calendar, calendar_resources in mapped_resources.items(): - day_total = calendar._get_resources_day_total(from_datetime, to_datetime, calendar_resources) - # compute actual hours per day attendances = calendar._attendance_intervals_batch(from_datetime, to_datetime, calendar_resources) leaves = calendar._leave_intervals_batch(from_datetime, to_datetime, calendar_resources, domain) for calendar_resource in calendar_resources: - result[calendar_resource.id] = calendar._get_days_data( - attendances[calendar_resource.id] & leaves[calendar_resource.id], - day_total[calendar_resource.id] + result[calendar_resource.id] = calendar._get_attendance_intervals_days_data( + attendances[calendar_resource.id] & leaves[calendar_resource.id] ) # convert "resource: result" into "employee: result" diff --git a/addons/resource/views/resource_calendar_attendance_views.xml b/addons/resource/views/resource_calendar_attendance_views.xml index 73a9b4678cb..fca2233ea0b 100644 --- a/addons/resource/views/resource_calendar_attendance_views.xml +++ b/addons/resource/views/resource_calendar_attendance_views.xml @@ -4,7 +4,7 @@ resource.calendar.attendance.tree resource.calendar.attendance - + @@ -13,6 +13,7 @@ + @@ -37,6 +38,7 @@ + diff --git a/addons/test_resource/tests/common.py b/addons/test_resource/tests/common.py index 94fd46fa9e6..a4f28f07a6d 100644 --- a/addons/test_resource/tests/common.py +++ b/addons/test_resource/tests/common.py @@ -17,6 +17,7 @@ class TestResourceCommon(TransactionCase): 'hour_from': att[0], 'hour_to': att[1], 'dayofweek': str(att[2]), + 'duration_days': att[3], }) for index, att in enumerate(attendances) ], @@ -47,18 +48,18 @@ class TestResourceCommon(TransactionCase): super(TestResourceCommon, cls).setUpClass() # UTC+1 winter, UTC+2 summer - cls.calendar_jean = cls._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, 1) for i in range(5)], 'Europe/Brussels') # UTC+6 - cls.calendar_patel = cls._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, 3/7), (13, 17, i, 4/7)) for i in range(5)], ()), 'Etc/GMT-6') # UTC-8 winter, UTC-7 summer - cls.calendar_john = cls._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, 1), (8, 13, 4, 5/12), (16, 23, 4, 7/12)], 'America/Los_Angeles') # UTC+1 winter, UTC+2 summer 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') - cls.calendar_paul = cls._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, 0.5), (10, 16, i, 0.5)) for i in range(5)], ()), 'Brazil/DeNoronha') # Employee is linked to a resource.resource via resource.mixin cls.jean = cls.env['resource.test'].create({