In a calendar with 2 weeks mode (odd and even), before this commit, all new created periods (placed in one of the two sections) don't have the week_type field set. This generates a confusion, because a period that should be applied only in odd weeks, for instance, it was applied also in the even weeks. Now, the new periods will have the week_type field set with the week_type of the section where is it. Also, an error message is raised if a period is not in one of the two sections. opw-2239966 closes odoo/odoo#50478 X-original-commit: 33a62118818952872321591e85403b657380128a Signed-off-by: Nicolas Lempereur (nle) <nle@odoo.com> Signed-off-by: Jorge Pinna Puissant (jpp) <jpp@odoo.com>
795 lines
34 KiB
Python
795 lines
34 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
import math
|
|
from datetime import datetime, time, timedelta
|
|
from dateutil.relativedelta import relativedelta
|
|
from dateutil.rrule import rrule, DAILY, WEEKLY
|
|
from functools import partial
|
|
from itertools import chain
|
|
from pytz import timezone, utc
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.addons.base.models.res_partner import _tz_get
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.osv import expression
|
|
from odoo.tools.float_utils import float_round
|
|
|
|
from odoo.tools import date_utils, float_utils
|
|
from .resource_mixin import timezone_datetime
|
|
|
|
# Default hour per day value. The one should
|
|
# only be used when the one from the calendar
|
|
# is not available.
|
|
HOURS_PER_DAY = 8
|
|
# This will generate 16th of days
|
|
ROUNDING_FACTOR = 16
|
|
|
|
|
|
def make_aware(dt):
|
|
""" Return ``dt`` with an explicit timezone, together with a function to
|
|
convert a datetime to the same (naive or aware) timezone as ``dt``.
|
|
"""
|
|
if dt.tzinfo:
|
|
return dt, lambda val: val.astimezone(dt.tzinfo)
|
|
else:
|
|
return dt.replace(tzinfo=utc), lambda val: val.astimezone(utc).replace(tzinfo=None)
|
|
|
|
|
|
def string_to_datetime(value):
|
|
""" Convert the given string value to a datetime in UTC. """
|
|
return utc.localize(fields.Datetime.from_string(value))
|
|
|
|
|
|
def datetime_to_string(dt):
|
|
""" Convert the given datetime (converted in UTC) to a string value. """
|
|
return fields.Datetime.to_string(dt.astimezone(utc))
|
|
|
|
|
|
def float_to_time(hours):
|
|
""" Convert a number of hours into a time object. """
|
|
if hours == 24.0:
|
|
return time.max
|
|
fractional, integral = math.modf(hours)
|
|
return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
|
|
|
|
|
|
def _boundaries(intervals, opening, closing):
|
|
""" Iterate on the boundaries of intervals. """
|
|
for start, stop, recs in intervals:
|
|
if start < stop:
|
|
yield (start, opening, recs)
|
|
yield (stop, closing, recs)
|
|
|
|
|
|
class Intervals(object):
|
|
""" Collection of ordered disjoint intervals with some associated records.
|
|
Each interval is a triple ``(start, stop, records)``, where ``records``
|
|
is a recordset.
|
|
"""
|
|
def __init__(self, intervals=()):
|
|
self._items = []
|
|
if intervals:
|
|
# normalize the representation of intervals
|
|
append = self._items.append
|
|
starts = []
|
|
recses = []
|
|
for value, flag, recs in sorted(_boundaries(intervals, 'start', 'stop')):
|
|
if flag == 'start':
|
|
starts.append(value)
|
|
recses.append(recs)
|
|
else:
|
|
start = starts.pop()
|
|
if not starts:
|
|
append((start, value, recses[0].union(*recses)))
|
|
recses.clear()
|
|
|
|
def __bool__(self):
|
|
return bool(self._items)
|
|
|
|
def __len__(self):
|
|
return len(self._items)
|
|
|
|
def __iter__(self):
|
|
return iter(self._items)
|
|
|
|
def __reversed__(self):
|
|
return reversed(self._items)
|
|
|
|
def __or__(self, other):
|
|
""" Return the union of two sets of intervals. """
|
|
return Intervals(chain(self._items, other._items))
|
|
|
|
def __and__(self, other):
|
|
""" Return the intersection of two sets of intervals. """
|
|
return self._merge(other, False)
|
|
|
|
def __sub__(self, other):
|
|
""" Return the difference of two sets of intervals. """
|
|
return self._merge(other, True)
|
|
|
|
def _merge(self, other, difference):
|
|
""" Return the difference or intersection of two sets of intervals. """
|
|
result = Intervals()
|
|
append = result._items.append
|
|
|
|
# using 'self' and 'other' below forces normalization
|
|
bounds1 = _boundaries(self, 'start', 'stop')
|
|
bounds2 = _boundaries(other, 'switch', 'switch')
|
|
|
|
start = None # set by start/stop
|
|
recs1 = None # set by start
|
|
enabled = difference # changed by switch
|
|
for value, flag, recs in sorted(chain(bounds1, bounds2)):
|
|
if flag == 'start':
|
|
start = value
|
|
recs1 = recs
|
|
elif flag == 'stop':
|
|
if enabled and start < value:
|
|
append((start, value, recs1))
|
|
start = None
|
|
else:
|
|
if not enabled and start is not None:
|
|
start = value
|
|
if enabled and start is not None and start < value:
|
|
append((start, value, recs1))
|
|
enabled = not enabled
|
|
|
|
return result
|
|
|
|
|
|
class ResourceCalendar(models.Model):
|
|
""" Calendar model for a resource. It has
|
|
|
|
- attendance_ids: list of resource.calendar.attendance that are a working
|
|
interval in a given weekday.
|
|
- leave_ids: list of leaves linked to this calendar. A leave can be general
|
|
or linked to a specific resource, depending on its resource_id.
|
|
|
|
All methods in this class use intervals. An interval is a tuple holding
|
|
(begin_datetime, end_datetime). A list of intervals is therefore a list of
|
|
tuples, holding several intervals of work or leaves. """
|
|
_name = "resource.calendar"
|
|
_description = "Resource Working Time"
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
res = super(ResourceCalendar, self).default_get(fields)
|
|
if not res.get('name') and res.get('company_id'):
|
|
res['name'] = _('Working Hours of %s') % self.env['res.company'].browse(res['company_id']).name
|
|
if 'attendance_ids' in fields and not res.get('attendance_ids'):
|
|
res['attendance_ids'] = [
|
|
(0, 0, {'name': _('Monday Morning'), 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
|
|
(0, 0, {'name': _('Monday Afternoon'), 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
|
|
(0, 0, {'name': _('Tuesday Morning'), 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
|
|
(0, 0, {'name': _('Tuesday Afternoon'), 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
|
|
(0, 0, {'name': _('Wednesday Morning'), 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
|
|
(0, 0, {'name': _('Wednesday Afternoon'), 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
|
|
(0, 0, {'name': _('Thursday Morning'), 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
|
|
(0, 0, {'name': _('Thursday Afternoon'), 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
|
|
(0, 0, {'name': _('Friday Morning'), 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
|
|
(0, 0, {'name': _('Friday Afternoon'), 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'})
|
|
]
|
|
return res
|
|
|
|
name = fields.Char(required=True)
|
|
company_id = fields.Many2one(
|
|
'res.company', 'Company',
|
|
default=lambda self: self.env.company)
|
|
attendance_ids = fields.One2many(
|
|
'resource.calendar.attendance', 'calendar_id', 'Working Time',
|
|
copy=True)
|
|
leave_ids = fields.One2many(
|
|
'resource.calendar.leaves', 'calendar_id', 'Time Off')
|
|
global_leave_ids = fields.One2many(
|
|
'resource.calendar.leaves', 'calendar_id', 'Global Time Off',
|
|
domain=[('resource_id', '=', False)], copy=True,
|
|
)
|
|
hours_per_day = fields.Float("Average Hour per Day", default=HOURS_PER_DAY,
|
|
help="Average hours per day a resource is supposed to work with this calendar.")
|
|
tz = fields.Selection(
|
|
_tz_get, string='Timezone', required=True,
|
|
default=lambda self: self._context.get('tz') or self.env.user.tz or 'UTC',
|
|
help="This field is used in order to define in which timezone the resources will work.")
|
|
two_weeks_calendar = fields.Boolean(string="Calendar in 2 weeks mode")
|
|
two_weeks_explanation = fields.Char('Explanation', compute="_compute_two_weeks_explanation")
|
|
|
|
@api.returns('self', lambda value: value.id)
|
|
def copy(self, default=None):
|
|
self.ensure_one()
|
|
if default is None:
|
|
default = {}
|
|
if not default.get('name'):
|
|
default.update(name=_('%s (copy)') % (self.name))
|
|
return super(ResourceCalendar, self).copy(default)
|
|
|
|
@api.constrains('attendance_ids')
|
|
def _check_attendance_ids(self):
|
|
for resource in self:
|
|
if (resource.two_weeks_calendar and
|
|
resource.attendance_ids.filtered(lambda a: a.display_type == 'line_section') and
|
|
not resource.attendance_ids.sorted('sequence')[0].display_type):
|
|
raise ValidationError(_("In a calendar with 2 weeks mode, all periods need to be in the sections."))
|
|
|
|
@api.depends('two_weeks_calendar')
|
|
def _compute_two_weeks_explanation(self):
|
|
today = fields.Date.today()
|
|
week_type = _("odd") if int(math.floor((today.toordinal() - 1) / 7) % 2) else _("even")
|
|
first_day = date_utils.start_of(today, 'week')
|
|
last_day = date_utils.end_of(today, 'week')
|
|
self.two_weeks_explanation = "This week (from %s to %s) is an %s week." % (first_day, last_day, week_type)
|
|
|
|
def _get_global_attendances(self):
|
|
return self.attendance_ids.filtered(lambda attendance:
|
|
not attendance.date_from and not attendance.date_to
|
|
and not attendance.resource_id and not attendance.display_type)
|
|
|
|
def _compute_hours_per_day(self, attendances):
|
|
if not attendances:
|
|
return 0
|
|
|
|
hour_count = 0.0
|
|
for attendance in attendances:
|
|
hour_count += attendance.hour_to - attendance.hour_from
|
|
|
|
if self.two_weeks_calendar:
|
|
number_of_days = len(set(attendances.filtered(lambda cal: cal.week_type == '1').mapped('dayofweek')))
|
|
number_of_days += len(set(attendances.filtered(lambda cal: cal.week_type == '0').mapped('dayofweek')))
|
|
else:
|
|
number_of_days = len(set(attendances.mapped('dayofweek')))
|
|
|
|
return float_round(hour_count / float(number_of_days), precision_digits=2)
|
|
|
|
@api.onchange('attendance_ids', 'two_weeks_calendar')
|
|
def _onchange_hours_per_day(self):
|
|
attendances = self._get_global_attendances()
|
|
self.hours_per_day = self._compute_hours_per_day(attendances)
|
|
|
|
def switch_calendar_type(self):
|
|
if not self.two_weeks_calendar:
|
|
self.attendance_ids.unlink()
|
|
self.attendance_ids = [
|
|
(0, 0, {
|
|
'name': 'Even week',
|
|
'dayofweek': '0',
|
|
'sequence': '0',
|
|
'hour_from': 0,
|
|
'day_period': 'morning',
|
|
'week_type': '0',
|
|
'hour_to': 0,
|
|
'display_type':
|
|
'line_section'}),
|
|
(0, 0, {
|
|
'name': 'Odd week',
|
|
'dayofweek': '0',
|
|
'sequence': '25',
|
|
'hour_from': 0,
|
|
'day_period':
|
|
'morning',
|
|
'week_type': '1',
|
|
'hour_to': 0,
|
|
'display_type': 'line_section'}),
|
|
]
|
|
|
|
self.two_weeks_calendar = True
|
|
default_attendance = self.default_get('attendance_ids')['attendance_ids']
|
|
for idx, att in enumerate(default_attendance):
|
|
att[2]["week_type"] = '0'
|
|
att[2]["sequence"] = idx + 1
|
|
self.attendance_ids = default_attendance
|
|
for idx, att in enumerate(default_attendance):
|
|
att[2]["week_type"] = '1'
|
|
att[2]["sequence"] = idx + 26
|
|
self.attendance_ids = default_attendance
|
|
else:
|
|
self.two_weeks_calendar = False
|
|
self.attendance_ids.unlink()
|
|
self.attendance_ids = self.default_get('attendance_ids')['attendance_ids']
|
|
self._onchange_hours_per_day()
|
|
|
|
@api.onchange('attendance_ids')
|
|
def _onchange_attendance_ids(self):
|
|
if not self.two_weeks_calendar:
|
|
return
|
|
|
|
even_week_seq = self.attendance_ids.filtered(lambda att: att.display_type == 'line_section' and att.week_type == '0')
|
|
odd_week_seq = self.attendance_ids.filtered(lambda att: att.display_type == 'line_section' and att.week_type == '1')
|
|
if len(even_week_seq) != 1 or len(odd_week_seq) != 1:
|
|
raise ValidationError(_("You can't delete section between weeks."))
|
|
|
|
even_week_seq = even_week_seq.sequence
|
|
odd_week_seq = odd_week_seq.sequence
|
|
|
|
for line in self.attendance_ids.filtered(lambda att: att.display_type is False):
|
|
if even_week_seq > odd_week_seq:
|
|
line.week_type = '1' if even_week_seq > line.sequence else '0'
|
|
else:
|
|
line.week_type = '0' if odd_week_seq > line.sequence else '1'
|
|
|
|
def _check_overlap(self, attendance_ids):
|
|
""" attendance_ids correspond to attendance of a week,
|
|
will check for each day of week that there are no superimpose. """
|
|
result = []
|
|
for attendance in attendance_ids.filtered(lambda att: not att.date_from and not att.date_to):
|
|
# 0.000001 is added to each start hour to avoid to detect two contiguous intervals as superimposing.
|
|
# Indeed Intervals function will join 2 intervals with the start and stop hour corresponding.
|
|
result.append((int(attendance.dayofweek) * 24 + attendance.hour_from + 0.000001, int(attendance.dayofweek) * 24 + attendance.hour_to, attendance))
|
|
|
|
if len(Intervals(result)) != len(result):
|
|
raise ValidationError(_("Attendances can't overlap."))
|
|
|
|
@api.constrains('attendance_ids')
|
|
def _check_attendance(self):
|
|
# Avoid superimpose in attendance
|
|
for calendar in self:
|
|
attendance_ids = calendar.attendance_ids.filtered(lambda attendance: not attendance.resource_id and attendance.display_type is False)
|
|
if calendar.two_weeks_calendar:
|
|
calendar._check_overlap(attendance_ids.filtered(lambda attendance: attendance.week_type == '0'))
|
|
calendar._check_overlap(attendance_ids.filtered(lambda attendance: attendance.week_type == '1'))
|
|
else:
|
|
calendar._check_overlap(attendance_ids)
|
|
|
|
# --------------------------------------------------
|
|
# Computation API
|
|
# --------------------------------------------------
|
|
def _attendance_intervals(self, start_dt, end_dt, resource=None, domain=None, tz=None):
|
|
""" Return the attendance intervals in the given datetime range.
|
|
The returned intervals are expressed in specified tz or in the resource's timezone.
|
|
"""
|
|
assert start_dt.tzinfo and end_dt.tzinfo
|
|
combine = datetime.combine
|
|
|
|
resource_ids = [resource.id, False] if resource else [False]
|
|
domain = domain if domain is not None else []
|
|
domain = expression.AND([domain, [
|
|
('calendar_id', '=', self.id),
|
|
('resource_id', 'in', resource_ids),
|
|
('display_type', '=', False),
|
|
]])
|
|
|
|
# express all dates and times in specified tz or in the resource's timezone
|
|
tz = tz if tz else timezone((resource or self).tz)
|
|
start_dt = start_dt.astimezone(tz)
|
|
end_dt = end_dt.astimezone(tz)
|
|
|
|
# for each attendance spec, generate the intervals in the date range
|
|
result = []
|
|
for attendance in self.env['resource.calendar.attendance'].search(domain):
|
|
start = start_dt.date()
|
|
if attendance.date_from:
|
|
start = max(start, attendance.date_from)
|
|
until = end_dt.date()
|
|
if attendance.date_to:
|
|
until = min(until, attendance.date_to)
|
|
if attendance.week_type:
|
|
start_week_type = int(math.floor((start.toordinal()-1)/7) % 2)
|
|
if start_week_type != int(attendance.week_type):
|
|
# start must be the week of the attendance
|
|
# if it's not the case, we must remove one week
|
|
start = start + relativedelta(weeks=-1)
|
|
weekday = int(attendance.dayofweek)
|
|
|
|
if self.two_weeks_calendar and attendance.week_type:
|
|
days = rrule(WEEKLY, start, interval=2, until=until, byweekday=weekday)
|
|
else:
|
|
days = rrule(DAILY, start, until=until, byweekday=weekday)
|
|
|
|
for day in days:
|
|
# attendance hours are interpreted in the resource's timezone
|
|
dt0 = tz.localize(combine(day, float_to_time(attendance.hour_from)))
|
|
dt1 = tz.localize(combine(day, float_to_time(attendance.hour_to)))
|
|
result.append((max(start_dt, dt0), min(end_dt, dt1), attendance))
|
|
|
|
return Intervals(result)
|
|
|
|
def _leave_intervals(self, start_dt, end_dt, resource=None, domain=None, tz=None):
|
|
""" Return the leave intervals in the given datetime range.
|
|
The returned intervals are expressed in specified tz or in the calendar's timezone.
|
|
"""
|
|
assert start_dt.tzinfo and end_dt.tzinfo
|
|
self.ensure_one()
|
|
|
|
# for the computation, express all datetimes in UTC
|
|
resource_ids = [resource.id, False] if resource else [False]
|
|
if domain is None:
|
|
domain = [('time_type', '=', 'leave')]
|
|
domain = domain + [
|
|
('calendar_id', '=', self.id),
|
|
('resource_id', 'in', resource_ids),
|
|
('date_from', '<=', datetime_to_string(end_dt)),
|
|
('date_to', '>=', datetime_to_string(start_dt)),
|
|
]
|
|
|
|
# retrieve leave intervals in (start_dt, end_dt)
|
|
tz = tz if tz else timezone((resource or self).tz)
|
|
start_dt = start_dt.astimezone(tz)
|
|
end_dt = end_dt.astimezone(tz)
|
|
result = []
|
|
for leave in self.env['resource.calendar.leaves'].search(domain):
|
|
dt0 = string_to_datetime(leave.date_from).astimezone(tz)
|
|
dt1 = string_to_datetime(leave.date_to).astimezone(tz)
|
|
result.append((max(start_dt, dt0), min(end_dt, dt1), leave))
|
|
|
|
return Intervals(result)
|
|
|
|
def _work_intervals(self, start_dt, end_dt, resource=None, domain=None, tz=None):
|
|
""" Return the effective work intervals between the given datetimes. """
|
|
return (self._attendance_intervals(start_dt, end_dt, resource, tz=tz) -
|
|
self._leave_intervals(start_dt, end_dt, resource, domain, tz=tz))
|
|
|
|
# --------------------------------------------------
|
|
# Private Methods / Helpers
|
|
# --------------------------------------------------
|
|
|
|
def _get_days_data(self, intervals, day_total):
|
|
"""
|
|
helper function to compute duration of `intervals`
|
|
expressed in days and hours.
|
|
`day_total` is a dict {date: n_hours} with the number of hours for each day.
|
|
"""
|
|
day_hours = defaultdict(float)
|
|
for start, stop, meta in intervals:
|
|
day_hours[start.date()] += (stop - start).total_seconds() / 3600
|
|
|
|
# compute number of days as quarters
|
|
days = sum(
|
|
float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR
|
|
for day in day_hours
|
|
)
|
|
return {
|
|
'days': days,
|
|
'hours': sum(day_hours.values()),
|
|
}
|
|
|
|
def _get_day_total(self, from_datetime, to_datetime, resource=None):
|
|
"""
|
|
@return dict with hours of attendance in each day between `from_datetime` and `to_datetime`
|
|
"""
|
|
self.ensure_one()
|
|
# 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)
|
|
to_full = to_datetime + timedelta(days=1)
|
|
intervals = self._attendance_intervals(from_full, to_full, resource=resource)
|
|
day_total = defaultdict(float)
|
|
for start, stop, meta in intervals:
|
|
day_total[start.date()] += (stop - start).total_seconds() / 3600
|
|
return day_total
|
|
|
|
# --------------------------------------------------
|
|
# External API
|
|
# --------------------------------------------------
|
|
|
|
def get_work_hours_count(self, start_dt, end_dt, compute_leaves=True, domain=None):
|
|
"""
|
|
`compute_leaves` controls whether or not this method is taking into
|
|
account the global leaves.
|
|
|
|
`domain` controls the way leaves are recognized.
|
|
None means default value ('time_type', '=', 'leave')
|
|
|
|
Counts the number of work hours between two datetimes.
|
|
"""
|
|
# Set timezone in UTC if no timezone is explicitly given
|
|
if not start_dt.tzinfo:
|
|
start_dt = start_dt.replace(tzinfo=utc)
|
|
if not end_dt.tzinfo:
|
|
end_dt = end_dt.replace(tzinfo=utc)
|
|
|
|
if compute_leaves:
|
|
intervals = self._work_intervals(start_dt, end_dt, domain=domain)
|
|
else:
|
|
intervals = self._attendance_intervals(start_dt, end_dt)
|
|
|
|
return sum(
|
|
(stop - start).total_seconds() / 3600
|
|
for start, stop, meta in intervals
|
|
)
|
|
|
|
def get_work_duration_data(self, from_datetime, to_datetime, compute_leaves=True, domain=None):
|
|
"""
|
|
Get the working duration (in days and hours) for a given period, only
|
|
based on the current calendar. This method does not use resource to
|
|
compute it.
|
|
|
|
`domain` is used in order to recognise the leaves to take,
|
|
None means default value ('time_type', '=', 'leave')
|
|
|
|
Returns a dict {'days': n, 'hours': h} containing the
|
|
quantity of working time expressed as days and as hours.
|
|
"""
|
|
# naive datetimes are made explicit in UTC
|
|
from_datetime, dummy = make_aware(from_datetime)
|
|
to_datetime, dummy = make_aware(to_datetime)
|
|
|
|
day_total = self._get_day_total(from_datetime, to_datetime)
|
|
|
|
# actual hours per day
|
|
if compute_leaves:
|
|
intervals = self._work_intervals(from_datetime, to_datetime, domain=domain)
|
|
else:
|
|
intervals = self._attendance_intervals(from_datetime, to_datetime)
|
|
|
|
return self._get_days_data(intervals, day_total)
|
|
|
|
def plan_hours(self, hours, day_dt, compute_leaves=False, domain=None, resource=None):
|
|
"""
|
|
`compute_leaves` controls whether or not this method is taking into
|
|
account the global leaves.
|
|
|
|
`domain` controls the way leaves are recognized.
|
|
None means default value ('time_type', '=', 'leave')
|
|
|
|
Return datetime after having planned hours
|
|
"""
|
|
day_dt, revert = make_aware(day_dt)
|
|
|
|
# which method to use for retrieving intervals
|
|
if compute_leaves:
|
|
get_intervals = partial(self._work_intervals, domain=domain, resource=resource)
|
|
else:
|
|
get_intervals = self._attendance_intervals
|
|
|
|
if hours >= 0:
|
|
delta = timedelta(days=14)
|
|
for n in range(100):
|
|
dt = day_dt + delta * n
|
|
for start, stop, meta in get_intervals(dt, dt + delta):
|
|
interval_hours = (stop - start).total_seconds() / 3600
|
|
if hours <= interval_hours:
|
|
return revert(start + timedelta(hours=hours))
|
|
hours -= interval_hours
|
|
return False
|
|
else:
|
|
hours = abs(hours)
|
|
delta = timedelta(days=14)
|
|
for n in range(100):
|
|
dt = day_dt - delta * n
|
|
for start, stop, meta in reversed(get_intervals(dt - delta, dt)):
|
|
interval_hours = (stop - start).total_seconds() / 3600
|
|
if hours <= interval_hours:
|
|
return revert(stop - timedelta(hours=hours))
|
|
hours -= interval_hours
|
|
return False
|
|
|
|
def plan_days(self, days, day_dt, compute_leaves=False, domain=None):
|
|
"""
|
|
`compute_leaves` controls whether or not this method is taking into
|
|
account the global leaves.
|
|
|
|
`domain` controls the way leaves are recognized.
|
|
None means default value ('time_type', '=', 'leave')
|
|
|
|
Returns the datetime of a days scheduling.
|
|
"""
|
|
day_dt, revert = make_aware(day_dt)
|
|
|
|
# which method to use for retrieving intervals
|
|
if compute_leaves:
|
|
get_intervals = partial(self._work_intervals, domain=domain)
|
|
else:
|
|
get_intervals = self._attendance_intervals
|
|
|
|
if days > 0:
|
|
found = set()
|
|
delta = timedelta(days=14)
|
|
for n in range(100):
|
|
dt = day_dt + delta * n
|
|
for start, stop, meta in get_intervals(dt, dt + delta):
|
|
found.add(start.date())
|
|
if len(found) == days:
|
|
return revert(stop)
|
|
return False
|
|
|
|
elif days < 0:
|
|
days = abs(days)
|
|
found = set()
|
|
delta = timedelta(days=14)
|
|
for n in range(100):
|
|
dt = day_dt - delta * n
|
|
for start, stop, meta in reversed(get_intervals(dt - delta, dt)):
|
|
found.add(start.date())
|
|
if len(found) == days:
|
|
return revert(start)
|
|
return False
|
|
|
|
else:
|
|
return revert(day_dt)
|
|
|
|
|
|
class ResourceCalendarAttendance(models.Model):
|
|
_name = "resource.calendar.attendance"
|
|
_description = "Work Detail"
|
|
_order = 'week_type, dayofweek, hour_from'
|
|
|
|
name = fields.Char(required=True)
|
|
dayofweek = fields.Selection([
|
|
('0', 'Monday'),
|
|
('1', 'Tuesday'),
|
|
('2', 'Wednesday'),
|
|
('3', 'Thursday'),
|
|
('4', 'Friday'),
|
|
('5', 'Saturday'),
|
|
('6', 'Sunday')
|
|
], 'Day of Week', required=True, index=True, default='0')
|
|
date_from = fields.Date(string='Starting Date')
|
|
date_to = fields.Date(string='End Date')
|
|
hour_from = fields.Float(string='Work from', required=True, index=True,
|
|
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)
|
|
calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
|
|
day_period = fields.Selection([('morning', 'Morning'), ('afternoon', 'Afternoon')], required=True, default='morning')
|
|
resource_id = fields.Many2one('resource.resource', 'Resource')
|
|
week_type = fields.Selection([
|
|
('1', 'Odd week'),
|
|
('0', 'Even week')
|
|
], 'Week Even/Odd', default=False)
|
|
two_weeks_calendar = fields.Boolean("Calendar in 2 weeks mode", related='calendar_id.two_weeks_calendar')
|
|
display_type = fields.Selection([
|
|
('line_section', "Section")], default=False, help="Technical field for UX purpose.")
|
|
sequence = fields.Integer(default=10,
|
|
help="Gives the sequence of this line when displaying the resource calendar.")
|
|
|
|
@api.onchange('hour_from', 'hour_to')
|
|
def _onchange_hours(self):
|
|
# avoid negative or after midnight
|
|
self.hour_from = min(self.hour_from, 23.99)
|
|
self.hour_from = max(self.hour_from, 0.0)
|
|
self.hour_to = min(self.hour_to, 23.99)
|
|
self.hour_to = max(self.hour_to, 0.0)
|
|
|
|
# avoid wrong order
|
|
self.hour_to = max(self.hour_to, self.hour_from)
|
|
|
|
|
|
class ResourceResource(models.Model):
|
|
_name = "resource.resource"
|
|
_description = "Resources"
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
res = super(ResourceResource, self).default_get(fields)
|
|
if not res.get('calendar_id') and res.get('company_id'):
|
|
company = self.env['res.company'].browse(res['company_id'])
|
|
res['calendar_id'] = company.resource_calendar_id.id
|
|
return res
|
|
|
|
name = fields.Char(required=True)
|
|
active = fields.Boolean(
|
|
'Active', default=True,
|
|
help="If the active field is set to False, it will allow you to hide the resource record without removing it.")
|
|
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
|
|
resource_type = fields.Selection([
|
|
('user', 'Human'),
|
|
('material', 'Material')], string='Resource Type',
|
|
default='user', required=True)
|
|
user_id = fields.Many2one('res.users', string='User', help='Related user name for the resource to manage its access.')
|
|
time_efficiency = fields.Float(
|
|
'Efficiency Factor', default=100, required=True,
|
|
help="This field is used to calculate the the expected duration of a work order at this work center. For example, if a work order takes one hour and the efficiency factor is 100%, then the expected duration will be one hour. If the efficiency factor is 200%, however the expected duration will be 30 minutes.")
|
|
calendar_id = fields.Many2one(
|
|
"resource.calendar", string='Working Time',
|
|
default=lambda self: self.env.company.resource_calendar_id,
|
|
required=True,
|
|
help="Define the schedule of resource")
|
|
tz = fields.Selection(
|
|
_tz_get, string='Timezone', required=True,
|
|
default=lambda self: self._context.get('tz') or self.env.user.tz or 'UTC',
|
|
help="This field is used in order to define in which timezone the resources will work.")
|
|
|
|
_sql_constraints = [
|
|
('check_time_efficiency', 'CHECK(time_efficiency>0)', 'Time efficiency must be strictly positive'),
|
|
]
|
|
|
|
@api.constrains('time_efficiency')
|
|
def _check_time_efficiency(self):
|
|
for record in self:
|
|
if record.time_efficiency == 0:
|
|
raise ValidationError(_('The efficiency factor cannot be equal to 0.'))
|
|
|
|
@api.model
|
|
def create(self, values):
|
|
if values.get('company_id') and not values.get('calendar_id'):
|
|
values['calendar_id'] = self.env['res.company'].browse(values['company_id']).resource_calendar_id.id
|
|
if not values.get('tz'):
|
|
# retrieve timezone on user or calendar
|
|
tz = (self.env['res.users'].browse(values.get('user_id')).tz or
|
|
self.env['resource.calendar'].browse(values.get('calendar_id')).tz)
|
|
if tz:
|
|
values['tz'] = tz
|
|
return super(ResourceResource, self).create(values)
|
|
|
|
@api.returns('self', lambda value: value.id)
|
|
def copy(self, default=None):
|
|
self.ensure_one()
|
|
if default is None:
|
|
default = {}
|
|
if not default.get('name'):
|
|
default.update(name=_('%s (copy)') % (self.name))
|
|
return super(ResourceResource, self).copy(default)
|
|
|
|
@api.onchange('company_id')
|
|
def _onchange_company_id(self):
|
|
if self.company_id:
|
|
self.calendar_id = self.company_id.resource_calendar_id.id
|
|
|
|
@api.onchange('user_id')
|
|
def _onchange_user_id(self):
|
|
if self.user_id:
|
|
self.tz = self.user_id.tz
|
|
|
|
def _get_work_interval(self, start, end):
|
|
""" Return interval's start datetime for interval closest to start. And interval's end datetime for interval closest to end.
|
|
If none is found return None
|
|
Note: this method is used in enterprise (forecast and planning)
|
|
|
|
:start: datetime
|
|
:end: datetime
|
|
:return: (datetime|None, datetime|None)
|
|
"""
|
|
start_datetime = timezone_datetime(start)
|
|
end_datetime = timezone_datetime(end)
|
|
resource_mapping = {}
|
|
for resource in self:
|
|
work_intervals = sorted(
|
|
resource.calendar_id._work_intervals(start_datetime, end_datetime, resource),
|
|
key=lambda x: x[0]
|
|
)
|
|
if work_intervals:
|
|
resource_mapping[resource.id] = (work_intervals[0][0].astimezone(utc), work_intervals[-1][1].astimezone(utc))
|
|
else:
|
|
resource_mapping[resource.id] = (None, None)
|
|
return resource_mapping
|
|
|
|
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)
|
|
|
|
"""
|
|
start_datetime = timezone_datetime(start)
|
|
end_datetime = timezone_datetime(end)
|
|
resource_mapping = {}
|
|
for resource in self:
|
|
calendar = resource.calendar_id
|
|
resource_work_intervals = calendar._work_intervals(start_datetime, end_datetime, resource)
|
|
resource_work_intervals = [(start, stop) for start, stop, meta in resource_work_intervals]
|
|
# start + flatten(intervals) + end
|
|
resource_work_intervals = [start_datetime] + list(chain.from_iterable(resource_work_intervals)) + [end_datetime]
|
|
# put it back to UTC
|
|
resource_work_intervals = list(map(lambda dt: dt.astimezone(utc), resource_work_intervals))
|
|
# pick groups of two
|
|
resource_work_intervals = list(zip(resource_work_intervals[0::2], resource_work_intervals[1::2]))
|
|
resource_mapping[resource.id] = resource_work_intervals
|
|
return resource_mapping
|
|
|
|
|
|
class ResourceCalendarLeaves(models.Model):
|
|
_name = "resource.calendar.leaves"
|
|
_description = "Resource Time Off Detail"
|
|
_order = "date_from"
|
|
|
|
name = fields.Char('Reason')
|
|
company_id = fields.Many2one(
|
|
'res.company', related='calendar_id.company_id', string="Company",
|
|
readonly=True, store=True)
|
|
calendar_id = fields.Many2one('resource.calendar', 'Working Hours')
|
|
date_from = fields.Datetime('Start Date', required=True)
|
|
date_to = fields.Datetime('End Date', required=True)
|
|
resource_id = fields.Many2one(
|
|
"resource.resource", 'Resource',
|
|
help="If empty, this is a generic time off for the company. If a resource is set, the time off is only for this resource")
|
|
time_type = fields.Selection([('leave', 'Time Off'), ('other', 'Other')], default='leave',
|
|
help="Whether this should be computed as a time off or as work time (eg: formation)")
|
|
|
|
@api.constrains('date_from', 'date_to')
|
|
def check_dates(self):
|
|
if self.filtered(lambda leave: leave.date_from > leave.date_to):
|
|
raise ValidationError(_('The start date of the time off must be earlier end date.'))
|
|
|
|
@api.onchange('resource_id')
|
|
def onchange_resource(self):
|
|
if self.resource_id:
|
|
self.calendar_id = self.resource_id.calendar_id
|