[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
This commit is contained in:
Rob Coekaerts
2023-10-24 12:38:09 +00:00
parent a33df072d8
commit a9b804b1ad
7 changed files with 79 additions and 37 deletions
+11 -12
View File
@@ -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'])
@@ -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))
+29 -5
View File
@@ -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):
"""
@@ -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()
+3 -7
View File
@@ -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"
@@ -4,7 +4,7 @@
<field name="name">resource.calendar.attendance.tree</field>
<field name="model">resource.calendar.attendance</field>
<field name="arch" type="xml">
<tree string="Working Time" editable="top">
<tree string="Working Time" editable="top" default_order="sequence, week_type, dayofweek, hour_from">
<field name="sequence" widget="handle"/>
<field name="display_type" column_invisible="True"/>
<field name="display_name" width="1" string=" " invisible="display_type != 'line_section'"/>
@@ -13,6 +13,7 @@
<field name="day_period"/>
<field name="hour_from" widget="float_time"/>
<field name="hour_to" widget="float_time"/>
<field name="duration_days" optional="show"/>
<field name="date_from" optional="hide"/>
<field name="date_to" optional="hide"/>
<field name="week_type" readonly="1" force_save="1" groups="base.group_no_one"/>
@@ -37,6 +38,7 @@
<field name="hour_to" widget="float_time"/>
</div>
<field name="day_period"/>
<field name="duration_days"/>
</group>
</sheet>
</form>
+5 -4
View File
@@ -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({