[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:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user