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({