mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 10:52:01 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -1,7 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import resource
|
||||
from . import resource_mixin
|
||||
from . import res_company
|
||||
from . import res_users
|
||||
from . import resource_calendar
|
||||
from . import resource_calendar_attendance
|
||||
from . import resource_calendar_leaves
|
||||
from . import resource_mixin
|
||||
from . import resource_resource
|
||||
from . import utils
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1011
odoo-bringout-oca-ocb-resource/resource/models/resource_calendar.py
Normal file
1011
odoo-bringout-oca-ocb-resource/resource/models/resource_calendar.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,132 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import math
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ResourceCalendarAttendance(models.Model):
|
||||
_name = 'resource.calendar.attendance'
|
||||
_description = "Work Detail"
|
||||
_order = 'sequence, 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')
|
||||
hour_from = fields.Float(string='Work from', default=0, 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', default=0, 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', inverse='_inverse_duration_hours', string='Duration (hours)', store=True, readonly=False)
|
||||
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, index=True, ondelete='cascade')
|
||||
duration_based = fields.Boolean(related="calendar_id.duration_based")
|
||||
day_period = fields.Selection([
|
||||
('morning', 'Morning'),
|
||||
('lunch', 'Break'),
|
||||
('afternoon', 'Afternoon'),
|
||||
('full_day', 'Full Day')], required=True, default='morning')
|
||||
week_type = fields.Selection([
|
||||
('1', 'Second'),
|
||||
('0', 'First')
|
||||
], 'Week Number', 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, 24)
|
||||
self.hour_to = max(self.hour_to, 0.0)
|
||||
|
||||
# avoid wrong order
|
||||
self.hour_to = max(self.hour_to, self.hour_from)
|
||||
|
||||
@api.constrains('day_period')
|
||||
def _check_day_period(self):
|
||||
for attendance in self:
|
||||
if attendance.day_period == 'lunch' and attendance.duration_based:
|
||||
raise UserError(self.env._("%(att)s is a break attendance, You should not have such record on duration based calendar", att=attendance.name))
|
||||
|
||||
@api.model
|
||||
def get_week_type(self, date):
|
||||
# week_type is defined by
|
||||
# * counting the number of days from January 1 of year 1
|
||||
# (extrapolated to dates prior to the first adoption of the Gregorian calendar)
|
||||
# * converted to week numbers and then the parity of this number is asserted.
|
||||
# It ensures that an even week number always follows an odd week number. With classical week number,
|
||||
# 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.filtered('hour_to'):
|
||||
attendance.duration_hours = (attendance.hour_to - attendance.hour_from) if attendance.day_period != 'lunch' else 0
|
||||
|
||||
def _inverse_duration_hours(self):
|
||||
for calendar, attendances in self.grouped('calendar_id').items():
|
||||
if not calendar.duration_based:
|
||||
continue
|
||||
for attendance in attendances:
|
||||
if attendance.day_period == 'full_day':
|
||||
period_duration = attendance.duration_hours / 2
|
||||
attendance.hour_to = 12 + period_duration
|
||||
attendance.hour_from = 12 - period_duration
|
||||
elif attendance.day_period == 'morning':
|
||||
attendance.hour_to = 12
|
||||
attendance.hour_from = 12 - attendance.duration_hours
|
||||
elif attendance.day_period == 'afternoon':
|
||||
attendance.hour_to = 12 + attendance.duration_hours
|
||||
attendance.hour_from = 12
|
||||
|
||||
@api.depends('day_period')
|
||||
def _compute_duration_days(self):
|
||||
for attendance in self:
|
||||
if attendance.day_period == 'lunch':
|
||||
attendance.duration_days = 0
|
||||
elif attendance.day_period == 'full_day':
|
||||
attendance.duration_days = 1
|
||||
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()
|
||||
this_week_type = str(self.get_week_type(fields.Date.context_today(self)))
|
||||
section_names = {'0': self.env._('First week'), '1': self.env._('Second week')}
|
||||
section_info = {True: self.env._('this week'), False: self.env._('other week')}
|
||||
for record in self.filtered(lambda l: l.display_type == 'line_section'):
|
||||
section_name = f"{section_names[record.week_type]} ({section_info[this_week_type == record.week_type]})"
|
||||
record.display_name = section_name
|
||||
|
||||
def _copy_attendance_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'dayofweek': self.dayofweek,
|
||||
'hour_from': self.hour_from,
|
||||
'hour_to': self.hour_to,
|
||||
'day_period': self.day_period,
|
||||
'week_type': self.week_type,
|
||||
'display_type': self.display_type,
|
||||
'sequence': self.sequence,
|
||||
}
|
||||
|
||||
def _is_work_period(self):
|
||||
return self.day_period != 'lunch' and not self.display_type
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, time
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from pytz import timezone, utc
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.fields import Datetime
|
||||
|
||||
|
||||
class ResourceCalendarLeaves(models.Model):
|
||||
_name = 'resource.calendar.leaves'
|
||||
_description = "Resource Time Off Detail"
|
||||
_order = "date_from"
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super().default_get(fields)
|
||||
if 'date_from' in fields and 'date_to' in fields and not res.get('date_from') and not res.get('date_to'):
|
||||
# Then we give the current day and we search the begin and end hours for this day in resource.calendar of the current company
|
||||
today = Datetime.now()
|
||||
calendar = self.env.company.resource_calendar_id
|
||||
if 'calendar_id' in res:
|
||||
calendar = self.env['resource.calendar'].browse(res['calendar_id'])
|
||||
tz = timezone(calendar.tz or 'UTC')
|
||||
date_from = tz.localize(datetime.combine(today, time.min))
|
||||
date_to = tz.localize(datetime.combine(today, time.max))
|
||||
res.update(
|
||||
date_from=date_from.astimezone(utc).replace(tzinfo=None),
|
||||
date_to=date_to.astimezone(utc).replace(tzinfo=None)
|
||||
)
|
||||
return res
|
||||
|
||||
name = fields.Char('Reason')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string="Company", readonly=True, store=True,
|
||||
default=lambda self: self.env.company, compute='_compute_company_id')
|
||||
calendar_id = fields.Many2one(
|
||||
'resource.calendar', "Working Hours",
|
||||
compute='_compute_calendar_id', store=True, readonly=False,
|
||||
domain="[('company_id', 'in', [company_id, False])]",
|
||||
check_company=True, index=True,
|
||||
)
|
||||
date_from = fields.Datetime('Start Date', required=True)
|
||||
date_to = fields.Datetime('End Date', compute="_compute_date_to", readonly=False, required=True, store=True)
|
||||
resource_id = fields.Many2one(
|
||||
"resource.resource", 'Resource', index=True,
|
||||
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.depends('resource_id.calendar_id')
|
||||
def _compute_calendar_id(self):
|
||||
for leave in self.filtered('resource_id'):
|
||||
leave.calendar_id = leave.resource_id.calendar_id
|
||||
|
||||
@api.depends('calendar_id')
|
||||
def _compute_company_id(self):
|
||||
for leave in self:
|
||||
leave.company_id = leave.calendar_id.company_id or self.env.company
|
||||
|
||||
@api.depends('date_from')
|
||||
def _compute_date_to(self):
|
||||
user_tz = self.env.tz
|
||||
if not (self.env.user.tz or self.env.context.get('tz')):
|
||||
user_tz = timezone(self.company_id.resource_calendar_id.tz or 'UTC')
|
||||
for leave in self:
|
||||
if not leave.date_from or (leave.date_to and leave.date_to > leave.date_from):
|
||||
continue
|
||||
local_date_from = utc.localize(leave.date_from).astimezone(user_tz)
|
||||
local_date_to = local_date_from + relativedelta(hour=23, minute=59, second=59)
|
||||
leave.date_to = local_date_to.astimezone(utc).replace(tzinfo=None)
|
||||
|
||||
@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 than the end date.'))
|
||||
|
||||
def _copy_leave_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'date_from': self.date_from,
|
||||
'date_to': self.date_to,
|
||||
'time_type': self.time_type,
|
||||
}
|
||||
|
|
@ -1,30 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from pytz import utc
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
def timezone_datetime(time):
|
||||
if not time.tzinfo:
|
||||
time = time.replace(tzinfo=utc)
|
||||
return time
|
||||
from odoo.tools.date_utils import localized
|
||||
|
||||
|
||||
class ResourceMixin(models.AbstractModel):
|
||||
_name = "resource.mixin"
|
||||
_name = 'resource.mixin'
|
||||
_description = 'Resource Mixin'
|
||||
|
||||
resource_id = fields.Many2one(
|
||||
'resource.resource', 'Resource',
|
||||
auto_join=True, index=True, ondelete='restrict', required=True)
|
||||
bypass_search_access=True, index=True, ondelete='restrict', required=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', 'Company',
|
||||
default=lambda self: self.env.company,
|
||||
index=True, related='resource_id.company_id', store=True, readonly=False)
|
||||
index=True, related='resource_id.company_id', precompute=True, store=True, readonly=False)
|
||||
resource_calendar_id = fields.Many2one(
|
||||
'resource.calendar', 'Working Hours',
|
||||
default=lambda self: self.env.company.resource_calendar_id,
|
||||
|
|
@ -67,20 +60,25 @@ class ResourceMixin(models.AbstractModel):
|
|||
return resource_vals
|
||||
|
||||
def copy_data(self, default=None):
|
||||
if default is None:
|
||||
default = {}
|
||||
default = dict(default or {})
|
||||
vals_list = super().copy_data(default=default)
|
||||
|
||||
resource_default = {}
|
||||
if 'company_id' in default:
|
||||
resource_default['company_id'] = default['company_id']
|
||||
if 'resource_calendar_id' in default:
|
||||
resource_default['calendar_id'] = default['resource_calendar_id']
|
||||
resource = self.resource_id.copy(resource_default)
|
||||
resources = [record.resource_id for record in self]
|
||||
resources_to_copy = self.env['resource.resource'].concat(*resources)
|
||||
new_resources = resources_to_copy.copy(resource_default)
|
||||
for resource, vals in zip(new_resources, vals_list):
|
||||
vals['resource_id'] = resource.id
|
||||
vals['company_id'] = resource.company_id.id
|
||||
vals['resource_calendar_id'] = resource.calendar_id.id
|
||||
return vals_list
|
||||
|
||||
default['resource_id'] = resource.id
|
||||
default['company_id'] = resource.company_id.id
|
||||
default['resource_calendar_id'] = resource.calendar_id.id
|
||||
return super(ResourceMixin, self).copy_data(default)
|
||||
def _get_calendars(self, date_from=None):
|
||||
return {resource.id: resource.resource_calendar_id for resource in self}
|
||||
|
||||
def _get_work_days_data_batch(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None):
|
||||
"""
|
||||
|
|
@ -98,19 +96,22 @@ class ResourceMixin(models.AbstractModel):
|
|||
result = {}
|
||||
|
||||
# naive datetimes are made explicit in UTC
|
||||
from_datetime = timezone_datetime(from_datetime)
|
||||
to_datetime = timezone_datetime(to_datetime)
|
||||
from_datetime = localized(from_datetime)
|
||||
to_datetime = localized(to_datetime)
|
||||
|
||||
mapped_resources = defaultdict(lambda: self.env['resource.resource'])
|
||||
for record in self:
|
||||
mapped_resources[calendar or record.resource_calendar_id] |= record.resource_id
|
||||
if calendar:
|
||||
mapped_resources = {calendar: self.resource_id}
|
||||
else:
|
||||
calendar_by_resource = self._get_calendars(from_datetime)
|
||||
mapped_resources = defaultdict(lambda: self.env['resource.resource'])
|
||||
for resource in self:
|
||||
mapped_resources[calendar_by_resource[resource.id]] |= resource.resource_id
|
||||
|
||||
for calendar, calendar_resources in mapped_resources.items():
|
||||
if not calendar:
|
||||
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:
|
||||
|
|
@ -119,10 +120,10 @@ 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}
|
||||
return {mapped_employees[r.id]: result[r.id] for r in resources}
|
||||
|
||||
def _get_leave_days_data_batch(self, from_datetime, to_datetime, calendar=None, domain=None):
|
||||
"""
|
||||
|
|
@ -140,24 +141,30 @@ class ResourceMixin(models.AbstractModel):
|
|||
result = {}
|
||||
|
||||
# naive datetimes are made explicit in UTC
|
||||
from_datetime = timezone_datetime(from_datetime)
|
||||
to_datetime = timezone_datetime(to_datetime)
|
||||
from_datetime = localized(from_datetime)
|
||||
to_datetime = localized(to_datetime)
|
||||
|
||||
mapped_resources = defaultdict(lambda: self.env['resource.resource'])
|
||||
for record in self:
|
||||
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)
|
||||
# handle fully flexible resources by returning the length of the whole interval
|
||||
# since we do not take into account leaves for fully flexible resources
|
||||
if not calendar:
|
||||
days = (to_datetime - from_datetime).days
|
||||
hours = (to_datetime - from_datetime).total_seconds() / 3600
|
||||
for calendar_resource in calendar_resources:
|
||||
result[calendar_resource.id] = {'days': days, 'hours': hours}
|
||||
continue
|
||||
|
||||
# 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"
|
||||
|
|
@ -171,7 +178,7 @@ class ResourceMixin(models.AbstractModel):
|
|||
for record in self
|
||||
}
|
||||
|
||||
def list_work_time_per_day(self, from_datetime, to_datetime, calendar=None, domain=None):
|
||||
def _list_work_time_per_day(self, from_datetime, to_datetime, calendar=None, domain=None):
|
||||
"""
|
||||
By default the resource calendar is used, but it can be
|
||||
changed using the `calendar` argument.
|
||||
|
|
@ -182,21 +189,28 @@ class ResourceMixin(models.AbstractModel):
|
|||
Returns a list of tuples (day, hours) for each day
|
||||
containing at least an attendance.
|
||||
"""
|
||||
resource = self.resource_id
|
||||
calendar = calendar or self.resource_calendar_id
|
||||
result = {}
|
||||
records_by_calendar = defaultdict(lambda: self.env[self._name])
|
||||
for record in self:
|
||||
records_by_calendar[calendar or record.resource_calendar_id or record.company_id.resource_calendar_id] += record
|
||||
|
||||
# naive datetimes are made explicit in UTC
|
||||
if not from_datetime.tzinfo:
|
||||
from_datetime = from_datetime.replace(tzinfo=utc)
|
||||
if not to_datetime.tzinfo:
|
||||
to_datetime = to_datetime.replace(tzinfo=utc)
|
||||
|
||||
compute_leaves = self.env.context.get('compute_leaves', True)
|
||||
intervals = calendar._work_intervals_batch(from_datetime, to_datetime, resource, domain, compute_leaves=compute_leaves)[resource.id]
|
||||
result = defaultdict(float)
|
||||
for start, stop, meta in intervals:
|
||||
result[start.date()] += (stop - start).total_seconds() / 3600
|
||||
return sorted(result.items())
|
||||
|
||||
for calendar, records in records_by_calendar.items():
|
||||
resources = self.resource_id
|
||||
all_intervals = calendar._work_intervals_batch(from_datetime, to_datetime, resources, domain, compute_leaves=compute_leaves)
|
||||
for record in records:
|
||||
intervals = all_intervals[record.resource_id.id]
|
||||
record_result = defaultdict(float)
|
||||
for start, stop, _meta in intervals:
|
||||
record_result[start.date()] += (stop - start).total_seconds() / 3600
|
||||
result[record.id] = sorted(record_result.items())
|
||||
return result
|
||||
|
||||
def list_leaves(self, from_datetime, to_datetime, calendar=None, domain=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from copy import deepcopy
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from datetime import datetime, timedelta, time
|
||||
from pytz import timezone
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.base.models.res_partner import _tz_get
|
||||
from odoo.tools import get_lang, babel_locale_parse
|
||||
from odoo.tools.intervals import Intervals
|
||||
from odoo.tools.date_utils import localized, sum_intervals, to_timezone, weeknumber, weekstart, weekend
|
||||
|
||||
|
||||
class ResourceResource(models.Model):
|
||||
_name = 'resource.resource'
|
||||
_description = "Resources"
|
||||
_order = "name"
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super().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='Type',
|
||||
default='user', required=True)
|
||||
user_id = fields.Many2one('res.users', string='User', index='btree_not_null', help='Related user name for the resource to manage its access.')
|
||||
avatar_128 = fields.Image(compute='_compute_avatar_128')
|
||||
share = fields.Boolean(related='user_id.share')
|
||||
email = fields.Char(related='user_id.email')
|
||||
phone = fields.Char(related='user_id.phone')
|
||||
|
||||
time_efficiency = fields.Float(
|
||||
'Efficiency Factor', default=100, required=True,
|
||||
help="This field is used to calculate 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,
|
||||
domain="[('company_id', '=', company_id)]",
|
||||
help="Define the working schedule of the resource. If not set, the resource will have fully flexible working hours.")
|
||||
tz = fields.Selection(
|
||||
_tz_get, string='Timezone', required=True,
|
||||
default=lambda self: self.env.context.get('tz') or self.env.user.tz or 'UTC')
|
||||
|
||||
_check_time_efficiency = models.Constraint(
|
||||
'CHECK(time_efficiency>0)',
|
||||
'Time efficiency must be strictly positive',
|
||||
)
|
||||
|
||||
@api.depends('user_id')
|
||||
def _compute_avatar_128(self):
|
||||
for resource in self:
|
||||
resource.avatar_128 = resource.user_id.avatar_128
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
if values.get('company_id') and not 'calendar_id' in values:
|
||||
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().create(vals_list)
|
||||
|
||||
def copy_data(self, default=None):
|
||||
vals_list = super().copy_data(default=default)
|
||||
return [dict(vals, name=self.env._("%s (copy)", resource.name)) for resource, vals in zip(self, vals_list)]
|
||||
|
||||
def write(self, vals):
|
||||
if self.env.context.get('check_idempotence') and len(self) == 1:
|
||||
vals = {
|
||||
fname: value
|
||||
for fname, value in vals.items()
|
||||
if self._fields[fname].convert_to_write(self[fname], self) != value
|
||||
}
|
||||
if not vals:
|
||||
return True
|
||||
return super().write(vals)
|
||||
|
||||
@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):
|
||||
# Deprecated method. Use `_adjust_to_calendar` instead
|
||||
return self._adjust_to_calendar(start, end)
|
||||
|
||||
def _adjust_to_calendar(self, start, end, compute_leaves=True):
|
||||
"""Adjust the given start and end datetimes to the closest effective hours encoded
|
||||
in the resource calendar. Only attendances in the same day as `start` and `end` are
|
||||
considered (respectively). If no attendance is found during that day, the closest hour
|
||||
is None.
|
||||
e.g. simplified example:
|
||||
given two attendances: 8am-1pm and 2pm-5pm, given start=9am and end=6pm
|
||||
resource._adjust_to_calendar(start, end)
|
||||
>>> {resource: (8am, 5pm)}
|
||||
:return: Closest matching start and end of working periods for each resource
|
||||
:rtype: dict(resource, tuple(datetime | None, datetime | None))
|
||||
"""
|
||||
revert_start_tz = to_timezone(start.tzinfo)
|
||||
revert_end_tz = to_timezone(end.tzinfo)
|
||||
start = localized(start)
|
||||
end = localized(end)
|
||||
result = {}
|
||||
for resource in self:
|
||||
resource_tz = timezone(resource.tz)
|
||||
start, end = start.astimezone(resource_tz), end.astimezone(resource_tz)
|
||||
search_range = [
|
||||
start + relativedelta(hour=0, minute=0, second=0),
|
||||
end + relativedelta(days=1, hour=0, minute=0, second=0),
|
||||
]
|
||||
calendar = resource.calendar_id or resource.company_id.resource_calendar_id or self.env.company.resource_calendar_id
|
||||
calendar_start = calendar._get_closest_work_time(start, resource=resource, search_range=search_range,
|
||||
compute_leaves=compute_leaves)
|
||||
search_range[0] = start
|
||||
calendar_end = calendar._get_closest_work_time(max(start, end), match_end=True,
|
||||
resource=resource, search_range=search_range,
|
||||
compute_leaves=compute_leaves)
|
||||
result[resource] = (
|
||||
calendar_start and revert_start_tz(calendar_start),
|
||||
calendar_end and revert_end_tz(calendar_end),
|
||||
)
|
||||
return result
|
||||
|
||||
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 = localized(start)
|
||||
end_datetime = localized(end)
|
||||
resource_mapping = {}
|
||||
calendar_mapping = defaultdict(lambda: self.env['resource.resource'])
|
||||
for resource in self:
|
||||
calendar_mapping[resource.calendar_id or resource.company_id.resource_calendar_id] |= resource
|
||||
|
||||
for calendar, resources in calendar_mapping.items():
|
||||
if not calendar:
|
||||
continue
|
||||
resources_unavailable_intervals = calendar._unavailable_intervals_batch(start_datetime, end_datetime, resources, tz=timezone(calendar.tz))
|
||||
resource_mapping.update(resources_unavailable_intervals)
|
||||
return resource_mapping
|
||||
|
||||
def _get_calendars_validity_within_period(self, start, end, default_company=None):
|
||||
""" Gets a dict of dict with resource's id as first key and resource's calendar as secondary key
|
||||
The value is the validity interval of the calendar for the given resource.
|
||||
|
||||
Here the validity interval for each calendar is the whole interval but it's meant to be overriden in further modules
|
||||
handling resource's employee contracts.
|
||||
"""
|
||||
assert start.tzinfo and end.tzinfo
|
||||
resource_calendars_within_period = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
|
||||
default_calendar = default_company and default_company.resource_calendar_id or self.env.company.resource_calendar_id
|
||||
if not self:
|
||||
# if no resource, add the company resource calendar.
|
||||
resource_calendars_within_period[False][default_calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
|
||||
for resource in self:
|
||||
calendar = resource.calendar_id or resource.company_id.resource_calendar_id or default_calendar
|
||||
resource_calendars_within_period[resource.id][calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
|
||||
return resource_calendars_within_period
|
||||
|
||||
def _get_valid_work_intervals(self, start, end, calendars=None, compute_leaves=True):
|
||||
""" Gets the valid work intervals of the resource following their calendars between ``start`` and ``end``
|
||||
|
||||
This methods handle the eventuality of a resource having multiple resource calendars, see _get_calendars_validity_within_period method
|
||||
for further explanation.
|
||||
|
||||
For flexible calendars and fully flexible resources: -> return the whole interval
|
||||
"""
|
||||
assert start.tzinfo and end.tzinfo
|
||||
resource_calendar_validity_intervals = {}
|
||||
calendar_resources = defaultdict(lambda: self.env['resource.resource'])
|
||||
resource_work_intervals = defaultdict(Intervals)
|
||||
calendar_work_intervals = dict()
|
||||
|
||||
resource_calendar_validity_intervals = self.sudo()._get_calendars_validity_within_period(start, end)
|
||||
for resource in self:
|
||||
# For each resource, retrieve its calendar and their validity intervals
|
||||
for calendar in resource_calendar_validity_intervals[resource.id]:
|
||||
calendar_resources[calendar] |= resource
|
||||
for calendar in (calendars or []):
|
||||
calendar_resources[calendar] |= self.env['resource.resource']
|
||||
for calendar, resources in calendar_resources.items():
|
||||
# for fully flexible resource, return the whole interval
|
||||
if not calendar:
|
||||
for resource in resources:
|
||||
resource_work_intervals[resource.id] |= Intervals([(start, end, self.env['resource.calendar.attendance'])])
|
||||
continue
|
||||
# For each calendar used by the resources, retrieve the work intervals for every resources using it
|
||||
work_intervals_batch = calendar._work_intervals_batch(start, end, resources=resources, compute_leaves=compute_leaves)
|
||||
for resource in resources:
|
||||
# Make the conjunction between work intervals and calendar validity
|
||||
resource_work_intervals[resource.id] |= work_intervals_batch[resource.id] & resource_calendar_validity_intervals[resource.id][calendar]
|
||||
calendar_work_intervals[calendar.id] = work_intervals_batch[False]
|
||||
|
||||
return resource_work_intervals, calendar_work_intervals
|
||||
|
||||
def _is_fully_flexible(self):
|
||||
""" employee has a fully flexible schedule has no working calendar set """
|
||||
self.ensure_one()
|
||||
return not self.calendar_id
|
||||
|
||||
def _get_calendar_at(self, date_target, tz=False):
|
||||
return {resource: resource.calendar_id for resource in self}
|
||||
|
||||
def _is_flexible(self):
|
||||
""" An employee is considered flexible if the field flexible_hours is True on the calendar
|
||||
or the employee is not assigned any calendar, in which case is considered as Fully flexible.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self._is_fully_flexible() or (self.calendar_id and self.calendar_id.flexible_hours)
|
||||
|
||||
def _get_flexible_resources_default_work_intervals(self, start, end):
|
||||
assert start.tzinfo and end.tzinfo
|
||||
|
||||
start_date = start.date()
|
||||
end_date = end.date()
|
||||
res = {}
|
||||
|
||||
resources_per_tz = defaultdict(list)
|
||||
for resource in self:
|
||||
resources_per_tz[timezone((resource or self.env.user).tz)].append(resource)
|
||||
|
||||
for tz, resources in resources_per_tz.items():
|
||||
start = start_date
|
||||
ranges = []
|
||||
while start <= end_date:
|
||||
start_datetime = tz.localize(datetime.combine(start, datetime.min.time()))
|
||||
end_datetime = tz.localize(datetime.combine(start, datetime.max.time()))
|
||||
ranges.append((start_datetime, end_datetime, self.env['resource.calendar.attendance']))
|
||||
start += timedelta(days=1)
|
||||
|
||||
for resource in resources:
|
||||
res[resource.id] = Intervals(ranges)
|
||||
|
||||
return res
|
||||
|
||||
def _get_flexible_resources_calendars_validity_within_period(self, start, end):
|
||||
assert start.tzinfo and end.tzinfo
|
||||
resource_default_work_intervals = self._get_flexible_resources_default_work_intervals(start, end)
|
||||
|
||||
calendars_within_period_per_resource = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
|
||||
for resource in self:
|
||||
calendars_within_period_per_resource[resource.id][resource.calendar_id] = resource_default_work_intervals[resource.id]
|
||||
|
||||
return calendars_within_period_per_resource
|
||||
|
||||
def _format_leave(self, leave, resource_hours_per_day, resource_hours_per_week, ranges_to_remove, start_day, end_day, locale):
|
||||
leave_start_day = leave[0].date()
|
||||
leave_end_day = leave[1].date()
|
||||
tz = timezone(self.tz or self.env.user.tz)
|
||||
|
||||
while leave_start_day <= leave_end_day:
|
||||
if not self._is_fully_flexible():
|
||||
hours = self.calendar_id.hours_per_day
|
||||
# only days inside the original period
|
||||
if leave_start_day >= start_day and leave_start_day <= end_day:
|
||||
resource_hours_per_day[self.id][leave_start_day] -= hours
|
||||
year_and_week = weeknumber(babel_locale_parse(locale), leave_start_day)
|
||||
resource_hours_per_week[self.id][year_and_week] -= hours
|
||||
|
||||
range_start_datetime = tz.localize(datetime.combine(leave_start_day, datetime.min.time()))
|
||||
range_end_datetime = tz.localize(datetime.combine(leave_start_day, datetime.max.time()))
|
||||
ranges_to_remove.append((range_start_datetime, range_end_datetime, self.env['resource.calendar.attendance']))
|
||||
leave_start_day += timedelta(days=1)
|
||||
|
||||
def _get_flexible_resource_valid_work_intervals(self, start, end):
|
||||
if not self:
|
||||
return {}, {}, {}
|
||||
|
||||
assert all(record._is_flexible() for record in self)
|
||||
assert start.tzinfo and end.tzinfo
|
||||
|
||||
start_day, end_day = start.date(), end.date()
|
||||
week_start_date, week_end_date = start, end
|
||||
locale = babel_locale_parse(get_lang(self.env).code)
|
||||
week_start_date = weekstart(locale, start)
|
||||
week_end_date = weekend(locale, end)
|
||||
end_year, end_week = weeknumber(locale, week_end_date)
|
||||
|
||||
min_start_date = week_start_date + relativedelta(hour=0, minute=0, second=0, microsecond=0)
|
||||
max_end_date = week_end_date + relativedelta(days=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
resource_work_intervals = defaultdict(Intervals)
|
||||
calendar_resources = defaultdict(lambda: self.env['resource.resource'])
|
||||
|
||||
resource_calendar_validity_intervals = self._get_flexible_resources_calendars_validity_within_period(min_start_date, max_end_date)
|
||||
for resource in self:
|
||||
# For each resource, retrieve their calendars validity intervals
|
||||
for calendar, work_intervals in resource_calendar_validity_intervals[resource.id].items():
|
||||
calendar_resources[calendar] |= resource
|
||||
resource_work_intervals[resource.id] |= work_intervals
|
||||
|
||||
resource_by_id = {resource.id: resource for resource in self}
|
||||
|
||||
resource_hours_per_day = defaultdict(lambda: defaultdict(float))
|
||||
resource_hours_per_week = defaultdict(lambda: defaultdict(float))
|
||||
locale = get_lang(self.env).code
|
||||
|
||||
for resource in self:
|
||||
if resource._is_fully_flexible():
|
||||
continue
|
||||
duration_per_day = defaultdict(float)
|
||||
resource_intervals = resource_work_intervals.get(resource.id, Intervals())
|
||||
for interval_start, interval_end, _dummy in resource_intervals:
|
||||
# thanks to default periods structure, start and end should be in same day (with a same timezone !!)
|
||||
day = interval_start.date()
|
||||
# custom timeoff can divide a day to > 1 intervals
|
||||
duration_per_day[day] += (interval_end - interval_start).total_seconds() / 3600
|
||||
|
||||
for day, hours in duration_per_day.items():
|
||||
day_working_hours = min(hours, resource.calendar_id.hours_per_day)
|
||||
# only days inside the original period
|
||||
if day >= start_day and day <= end_day:
|
||||
resource_hours_per_day[resource.id][day] = day_working_hours
|
||||
|
||||
year_week = weeknumber(babel_locale_parse(locale), day)
|
||||
year, week = year_week
|
||||
if (year < end_year) or (year == end_year and week <= end_week):
|
||||
# cap weekly hours to the calendar's configured hours_per_week (not the
|
||||
# company default full_time_required_hours which does not respect
|
||||
# part-time schedules).
|
||||
cap = resource.calendar_id.hours_per_week or resource.calendar_id.full_time_required_hours
|
||||
resource_hours_per_week[resource.id][year_week] = min(cap, day_working_hours + resource_hours_per_week[resource.id][year_week])
|
||||
|
||||
for calendar, resources in calendar_resources.items():
|
||||
domain = [('calendar_id', '=', False)] if not calendar else None
|
||||
leave_intervals = (calendar or self.env['resource.calendar'])._leave_intervals_batch(min_start_date, max_end_date, resources, domain)
|
||||
for resource_id, leaves in leave_intervals.items():
|
||||
if not resource_id:
|
||||
continue
|
||||
|
||||
ranges_to_remove = []
|
||||
for leave in leaves._items:
|
||||
resource_by_id[resource_id]._format_leave(leave, resource_hours_per_day, resource_hours_per_week, ranges_to_remove, start_day, end_day, locale)
|
||||
|
||||
resource_work_intervals[resource_id] -= Intervals(ranges_to_remove)
|
||||
|
||||
for resource_id, work_intervals in resource_work_intervals.items():
|
||||
tz = timezone(resource_by_id[resource_id].tz or self.env.user.tz)
|
||||
resource_work_intervals[resource_id] = work_intervals & Intervals([(start.astimezone(tz), end.astimezone(tz), self.env['resource.calendar.attendance'])])
|
||||
|
||||
return resource_work_intervals, resource_hours_per_day, resource_hours_per_week
|
||||
|
||||
def _get_flexible_resource_work_hours(self, intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week, work_hours_per_day=None):
|
||||
assert self._is_flexible()
|
||||
|
||||
if self._is_fully_flexible():
|
||||
return round(sum_intervals(intervals), 2)
|
||||
|
||||
# start and end for each Interval have the same day thanks to schedule_intervals_per_resource_id format for flexible employees
|
||||
# 2 intervals can cover the same day, in case of custom timeoff at the middle of the day
|
||||
duration_per_day = deepcopy(flexible_resources_hours_per_day)
|
||||
duration_per_week = deepcopy(flexible_resources_hours_per_week)
|
||||
|
||||
interval_duration_per_day = defaultdict(float)
|
||||
# days with custom time off can divide a day to many intervals
|
||||
for start, end, _dummy in intervals:
|
||||
if end.time() == time.max:
|
||||
# flex resource intervals are formatted in days, each day from min time to max time, when getting the difference, one microsecond is lost
|
||||
duration = (end + timedelta(microseconds=1) - start).total_seconds() / 3600
|
||||
else:
|
||||
duration = (end - start).total_seconds() / 3600
|
||||
interval_duration_per_day[start.date()] += duration
|
||||
|
||||
work_hours = 0.0
|
||||
locale = get_lang(self.env).code
|
||||
for day, hours in interval_duration_per_day.items():
|
||||
week = weeknumber(babel_locale_parse(locale), day)
|
||||
day_working_hours = max(0.0, min(
|
||||
hours,
|
||||
duration_per_day.get(day, 0.0),
|
||||
duration_per_week.get(week, 0.0),
|
||||
))
|
||||
work_hours += day_working_hours
|
||||
duration_per_week[week] -= day_working_hours
|
||||
|
||||
if work_hours_per_day is not None:
|
||||
work_hours_per_day[day] += day_working_hours
|
||||
|
||||
return work_hours
|
||||
53
odoo-bringout-oca-ocb-resource/resource/models/utils.py
Normal file
53
odoo-bringout-oca-ocb-resource/resource/models/utils.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from odoo.fields import Domain
|
||||
|
||||
# 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 filter_domain_leaf(domain, field_check, field_name_mapping=None):
|
||||
"""
|
||||
filter_domain_lead only keep the leaves of a domain that verify a given check. Logical operators that involves
|
||||
a leaf that is undetermined (because it does not pass the check) are ignored.
|
||||
|
||||
each operator is a logic gate:
|
||||
- '&' and '|' take two entries and can be ignored if one of them (or the two of them) is undetermined
|
||||
-'!' takes one entry and can be ignored if this entry is undetermined
|
||||
|
||||
params:
|
||||
- domain: the domain that needs to be filtered
|
||||
- field_check: the function that the field name used in the leaf needs to verify to keep the leaf
|
||||
- field_name_mapping: dictionary of the form {'field_name': 'new_field_name', ...}. Occurences of 'field_name'
|
||||
in the first element of domain leaves will be replaced by 'new_field_name'. This is usefull when adapting a
|
||||
domain from one model to another when some field names do not match the names of the corresponding fields in
|
||||
the new model.
|
||||
returns: The filtered version of the domain
|
||||
"""
|
||||
field_name_mapping = field_name_mapping or {}
|
||||
|
||||
def adapt_condition(condition, ignored):
|
||||
field_name = condition.field_expr
|
||||
if not field_check(field_name):
|
||||
return ignored
|
||||
field_name = field_name_mapping.get(field_name)
|
||||
if field_name is None:
|
||||
return condition
|
||||
return Domain(field_name, condition.operator, condition.value)
|
||||
|
||||
def adapt_domain(domain: Domain, ignored) -> Domain:
|
||||
if hasattr(domain, 'OPERATOR'):
|
||||
if domain.OPERATOR in ('&', '|'):
|
||||
domain = domain.apply(adapt_domain(d, domain.ZERO) for d in domain.children)
|
||||
elif domain.OPERATOR == '!':
|
||||
domain = ~adapt_domain(~domain, ~ignored)
|
||||
else:
|
||||
assert False, "domain.OPERATOR = {domain.OPEATOR!r} unhandled"
|
||||
else:
|
||||
domain = domain.map_conditions(lambda condition: adapt_condition(condition, ignored))
|
||||
return ignored if domain.is_true() or domain.is_false() else domain
|
||||
|
||||
domain = Domain(domain)
|
||||
if domain.is_false():
|
||||
return domain
|
||||
return adapt_domain(domain, ignored=Domain.TRUE)
|
||||
Loading…
Add table
Add a link
Reference in a new issue