538 lines
22 KiB
Python
538 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import math
|
|
from datetime import datetime, time, timedelta
|
|
from dateutil.rrule import rrule, DAILY
|
|
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.tools.float_utils import float_round
|
|
|
|
# Default hour per day value. The one should
|
|
# only be used when the one from the calendar
|
|
# is not available.
|
|
HOURS_PER_DAY = 8
|
|
|
|
|
|
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
|
|
return res
|
|
|
|
def _get_default_attendance_ids(self):
|
|
return [
|
|
(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'})
|
|
]
|
|
|
|
name = fields.Char(required=True)
|
|
company_id = fields.Many2one(
|
|
'res.company', 'Company',
|
|
default=lambda self: self.env['res.company']._company_default_get())
|
|
attendance_ids = fields.One2many(
|
|
'resource.calendar.attendance', 'calendar_id', 'Working Time',
|
|
copy=True, default=_get_default_attendance_ids)
|
|
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)]
|
|
)
|
|
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.")
|
|
|
|
|
|
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)
|
|
|
|
@api.onchange('attendance_ids')
|
|
def _onchange_hours_per_day(self):
|
|
attendances = self._get_global_attendances()
|
|
hour_count = 0.0
|
|
for attendance in attendances:
|
|
hour_count += attendance.hour_to - attendance.hour_from
|
|
if attendances:
|
|
self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2)
|
|
|
|
# --------------------------------------------------
|
|
# Computation API
|
|
# --------------------------------------------------
|
|
def _attendance_intervals(self, start_dt, end_dt, resource=None, domain=None):
|
|
""" Return the attendance intervals in the given datetime range.
|
|
The returned intervals are expressed 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 = domain + [
|
|
('calendar_id', '=', self.id),
|
|
('resource_id', 'in', resource_ids),
|
|
]
|
|
|
|
# express all dates and times in the resource's timezone
|
|
tz = 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)
|
|
weekday = int(attendance.dayofweek)
|
|
|
|
for day in rrule(DAILY, start, until=until, byweekday=weekday):
|
|
# 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):
|
|
""" Return the leave intervals in the given datetime range.
|
|
The returned intervals are expressed 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 = 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):
|
|
""" Return the effective work intervals between the given datetimes. """
|
|
return (self._attendance_intervals(start_dt, end_dt, resource) -
|
|
self._leave_intervals(start_dt, end_dt, resource, domain))
|
|
|
|
# --------------------------------------------------
|
|
# External API
|
|
# --------------------------------------------------
|
|
|
|
@api.multi
|
|
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
|
|
)
|
|
|
|
@api.multi
|
|
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
|
|
|
|
@api.multi
|
|
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 = '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')
|
|
|
|
@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, tracking=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['res.company']._company_default_get())
|
|
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['res.company']._company_default_get().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.multi
|
|
@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.multi
|
|
@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
|
|
|
|
|
|
class ResourceCalendarLeaves(models.Model):
|
|
_name = "resource.calendar.leaves"
|
|
_description = "Resource Time Off Detail"
|
|
|
|
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
|