forked from Mapan/odoo17e
676 lines
38 KiB
Python
676 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
import pytz
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.osv import expression
|
|
from odoo.tools import float_utils, DEFAULT_SERVER_DATETIME_FORMAT
|
|
|
|
from odoo.addons.resource.models.utils import Intervals
|
|
|
|
class PlanningSlot(models.Model):
|
|
_inherit = 'planning.slot'
|
|
|
|
start_datetime = fields.Datetime(required=False)
|
|
end_datetime = fields.Datetime(required=False)
|
|
sale_line_id = fields.Many2one('sale.order.line', string='Sales Order Item', domain=[('product_id.type', '=', 'service'), ('state', 'not in', ['draft', 'sent'])],
|
|
index=True, ondelete='cascade', group_expand='_group_expand_sale_line_id',
|
|
help="Sales order item for which this shift will be performed. When sales orders are automatically planned,"
|
|
" the remaining hours of the sales order item, as well as the role defined on the service, are taken into account.")
|
|
sale_order_id = fields.Many2one('sale.order', string='Sales Order', related='sale_line_id.order_id', store=True)
|
|
partner_id = fields.Many2one('res.partner', related='sale_order_id.partner_id')
|
|
role_product_ids = fields.One2many('product.template', related='role_id.product_ids')
|
|
sale_line_plannable = fields.Boolean(related='sale_line_id.product_id.planning_enabled')
|
|
allocated_hours = fields.Float(compute_sudo=True)
|
|
|
|
_sql_constraints = [
|
|
('check_datetimes_set_or_plannable_slot',
|
|
'CHECK((start_datetime IS NOT NULL AND end_datetime IS NOT NULL) OR sale_line_id IS NOT NULL)',
|
|
'Only slots linked to a sale order with a plannable service can be unscheduled.')
|
|
]
|
|
|
|
@api.depends('sale_line_id')
|
|
def _compute_role_id(self):
|
|
slot_with_sol = self.filtered('sale_line_plannable')
|
|
for slot in slot_with_sol:
|
|
if not slot.role_id:
|
|
slot.role_id = slot.sale_line_id.product_id.planning_role_id
|
|
super(PlanningSlot, self - slot_with_sol)._compute_role_id()
|
|
|
|
@api.depends('start_datetime', 'sale_line_id.planning_hours_to_plan', 'sale_line_id.planning_hours_planned')
|
|
def _compute_allocated_hours(self):
|
|
if self.env.context.get('sale_planning_prevent_recompute'):
|
|
return
|
|
planned_slots = self.filtered('start_datetime')
|
|
for slot in self - planned_slots:
|
|
if slot.sale_line_id:
|
|
slot.allocated_hours = max(
|
|
slot.sale_line_id.planning_hours_to_plan - slot.sale_line_id.planning_hours_planned,
|
|
0.0
|
|
)
|
|
super(PlanningSlot, planned_slots)._compute_allocated_hours()
|
|
SaleOrderLine = self.env['sale.order.line']
|
|
self.env.add_to_compute(SaleOrderLine._fields['planning_hours_planned'], self.sale_line_id)
|
|
|
|
@api.depends('start_datetime')
|
|
def _compute_allocated_percentage(self):
|
|
planned_slots = self.filtered('start_datetime')
|
|
super(PlanningSlot, planned_slots)._compute_allocated_percentage()
|
|
|
|
@api.depends('start_datetime')
|
|
def _compute_past_shift(self):
|
|
planned_slots = self.filtered('start_datetime')
|
|
(self - planned_slots).is_past = False
|
|
super(PlanningSlot, planned_slots)._compute_past_shift()
|
|
|
|
@api.depends('start_datetime')
|
|
def _compute_unassign_deadline(self):
|
|
planned_slots = self.filtered('start_datetime')
|
|
(self - planned_slots).unassign_deadline = False
|
|
super(PlanningSlot, planned_slots)._compute_unassign_deadline()
|
|
|
|
@api.depends('start_datetime')
|
|
def _compute_is_unassign_deadline_passed(self):
|
|
planned_slots = self.filtered('start_datetime')
|
|
(self - planned_slots).is_unassign_deadline_passed = False
|
|
super(PlanningSlot, planned_slots)._compute_is_unassign_deadline_passed()
|
|
|
|
@api.depends('start_datetime')
|
|
def _compute_working_days_count(self):
|
|
planned_slots = self.filtered('start_datetime')
|
|
(self - planned_slots).working_days_count = 0
|
|
super(PlanningSlot, planned_slots)._compute_working_days_count()
|
|
|
|
@api.depends('start_datetime')
|
|
def _compute_template_autocomplete_ids(self):
|
|
planned_slots = self.filtered('start_datetime')
|
|
(self - planned_slots).template_autocomplete_ids = self.template_id
|
|
super(PlanningSlot, planned_slots)._compute_template_autocomplete_ids()
|
|
|
|
def _group_expand_sale_line_id(self, sale_lines, domain, order):
|
|
dom_tuples = [(dom[0], dom[1]) for dom in domain if isinstance(dom, (list, tuple)) and len(dom) == 3]
|
|
sale_line_ids = self.env.context.get('filter_sale_line_ids', False)
|
|
if sale_line_ids:
|
|
# search method is used rather than browse since the order needs to be handled
|
|
return self.env['sale.order.line'].search([('id', 'in', sale_line_ids)], order=order)
|
|
elif self._context.get('planning_expand_sale_line_id') and ('start_datetime', '<=') in dom_tuples and ('end_datetime', '>=') in dom_tuples:
|
|
if ('sale_line_id', '=') in dom_tuples or ('sale_line_id', 'ilike') in dom_tuples:
|
|
filter_domain = self._expand_domain_m2o_groupby(domain, 'sale_line_id')
|
|
return self.env['sale.order.line'].search(filter_domain, order=order)
|
|
filters = self._expand_domain_dates(domain)
|
|
sale_lines = self.env['planning.slot'].search(filters).mapped('sale_line_id')
|
|
return sale_lines.search([('id', 'in', sale_lines.ids)], order=order)
|
|
return sale_lines
|
|
|
|
# -----------------------------------------------------------------
|
|
# ORM Override
|
|
# -----------------------------------------------------------------
|
|
|
|
@api.model
|
|
def default_get(self, fields_list):
|
|
res = super().default_get(fields_list)
|
|
if res.get('sale_line_id'):
|
|
sale_line_id = self.env['sale.order.line'].browse(res.get('sale_line_id'))
|
|
if sale_line_id.product_id.planning_enabled and res.get('start_datetime') and res.get('end_datetime'):
|
|
remaining_hours_to_plan = sale_line_id.planning_hours_to_plan - sale_line_id.planning_hours_planned
|
|
if float_utils.float_compare(remaining_hours_to_plan, 0, precision_digits=2) < 1:
|
|
return res
|
|
allocated_hours = (res['end_datetime'] - res['start_datetime']).total_seconds() / 3600.0
|
|
if float_utils.float_compare(remaining_hours_to_plan, allocated_hours, precision_digits=2) < 1:
|
|
res['end_datetime'] = res['start_datetime'] + timedelta(hours=remaining_hours_to_plan)
|
|
return res
|
|
|
|
def _display_name_fields(self):
|
|
""" List of fields that can be displayed in the display_name """
|
|
return ['partner_id'] + super()._display_name_fields() + ['sale_line_id']
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
res = super().create(vals_list)
|
|
if res.sale_line_id:
|
|
res.sale_line_id.sudo()._compute_planning_hours_planned() # ensure it is computed before doing postprocess
|
|
res.sale_line_id.sudo()._post_process_planning_sale_line(ids_to_exclude=res.ids)
|
|
return res
|
|
|
|
def write(self, vals):
|
|
self.assign_slot(vals)
|
|
return True
|
|
|
|
def assign_slot(self, vals):
|
|
sale_order_slots_to_plan = []
|
|
PlanningShift = self.env['planning.slot']
|
|
slots_to_write = PlanningShift
|
|
slots_written = PlanningShift
|
|
if vals.get('start_datetime'):
|
|
# if the previous start_datetime was False, it means the slot has been selected from the
|
|
# unscheduled slots. In this case, slots must be generated automatically to fill the gantt period
|
|
# with the hours remaining to plan of the linked sale order as a limit of hours to allocate.
|
|
slot_vals_list_per_employee = defaultdict(list)
|
|
for slot in self:
|
|
if slot.sale_line_plannable and not slot.start_datetime:
|
|
# This method will generate the planning slots for the given employee and following the numbers of hours still to plan
|
|
# for the given slot's sale order line.
|
|
new_vals, tmp_sale_order_slots_to_plan, resource = slot._get_sale_order_slots_to_plan(vals, slot_vals_list_per_employee)
|
|
if new_vals:
|
|
# Call the write method of the parent
|
|
super(PlanningSlot, slot).write(new_vals[0])
|
|
slots_written += slot
|
|
sale_order_slots_to_plan += tmp_sale_order_slots_to_plan
|
|
if resource:
|
|
slot_vals_list_per_employee[resource] += new_vals + tmp_sale_order_slots_to_plan
|
|
else:
|
|
slots_to_write |= slot
|
|
else:
|
|
slots_to_write |= self
|
|
|
|
super(PlanningSlot, slots_to_write).write(vals)
|
|
slots_written += slots_to_write
|
|
|
|
if sale_order_slots_to_plan:
|
|
slots_written += self.create(sale_order_slots_to_plan)
|
|
|
|
slots_to_unlink = PlanningShift
|
|
for slot in self:
|
|
if slot.sale_line_id and not slot.start_datetime and float_utils.float_compare(slot.allocated_hours, 0.0, precision_digits=2) < 1:
|
|
slots_to_unlink |= slot
|
|
if (self - slots_to_unlink).sale_line_id:
|
|
(self - slots_to_unlink).sale_line_id.sudo()._post_process_planning_sale_line(ids_to_exclude=self.ids)
|
|
slots_to_unlink.unlink()
|
|
return slots_written - slots_to_unlink
|
|
|
|
# -----------------------------------------------------------------
|
|
# Actions
|
|
# -----------------------------------------------------------------
|
|
|
|
def action_view_sale_order(self):
|
|
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders")
|
|
action['views'] = [(False, 'form')]
|
|
action['res_id'] = self.sale_order_id.id
|
|
return action
|
|
|
|
# -----------------------------------------------------------------
|
|
# Business methods
|
|
# -----------------------------------------------------------------
|
|
|
|
def _get_domain_template_slots(self):
|
|
domain = super()._get_domain_template_slots()
|
|
if self.sale_line_plannable:
|
|
domain = expression.AND([domain, ['|', ('role_id', '=', self.sale_line_id.product_id.planning_role_id.id), ('role_id', '=', False)]])
|
|
return domain
|
|
|
|
def _get_sale_order_slots_to_plan(self, vals, slot_vals_list_per_resource):
|
|
"""
|
|
Returns the vals which will be used to update self, a vals_list of the slots
|
|
to create for the same related sale_order_line and the resource.
|
|
|
|
:param vals: the vals passed to the write orm method.
|
|
:param slot_vals_list_per_resource: a dict of vals list of slots to be created, sorted per resource
|
|
This dict is used to be aware of the slots which will be created and are not in the database yet.
|
|
"""
|
|
# Gets work interval in order to know if the employee can work or not
|
|
# Gets its slots which are partially allocated (allocated_percentage < 100) in order to avoid planning slots in conflict.
|
|
self.ensure_one()
|
|
to_allocate = self.sale_line_id.planning_hours_to_plan - self.sale_line_id.planning_hours_planned
|
|
if to_allocate < 0.0:
|
|
return [], [], None
|
|
work_intervals, unforecastable_intervals, resource, partial_interval_slots = self.sudo().with_context(
|
|
default_end_datetime=self.env.context.get('default_end_datetime')
|
|
)._get_resource_work_info(vals, slot_vals_list_per_resource)
|
|
following_slots_vals_list = []
|
|
if work_intervals:
|
|
following_slots_vals_list = self._get_slots_values(
|
|
vals, work_intervals, partial_interval_slots, unforecastable_intervals, to_allocate=to_allocate, resource=resource
|
|
)
|
|
if following_slots_vals_list:
|
|
# In order to have slots on multiple days, the slots filling the resource's work intervals must be
|
|
# merged. The consequence is that it will be forecasted slots (regarding `allocation_type`) rather than short planning slots
|
|
following_slots_vals_list = self._merge_slots_values(following_slots_vals_list, unforecastable_intervals)
|
|
return following_slots_vals_list[:1], following_slots_vals_list[1:], resource
|
|
return [], [], resource
|
|
|
|
def _get_slots_values(self, vals, work_intervals, partial_interval_slots, unforecastable_intervals, to_allocate, resource):
|
|
"""
|
|
This method returns the generated slots values related to self.sale_line_id for the given resource.
|
|
|
|
Params :
|
|
- `vals` : the vals sent in the write/reschedule call;
|
|
- `work_intervals`: Intervals during which resource works/is available
|
|
- `partial_interval_slots`: Intervals during which the resource have slots partially planned (`allocated_percentage` < 100)
|
|
- `unforecastable_intervals`: Intervals during which the resource cannot have a slot with `allocation_type` == 'forecast'
|
|
(see _merge_slots_values for further explanation)
|
|
- `to_allocate`: The number of hours there is still to allocate for this self.sale_line_id
|
|
- `resource`: The recordset of the resource for whom the information are given and who will be assigned to the slots
|
|
If None, the information is the one of the company.
|
|
|
|
Algorithm :
|
|
- General principle :
|
|
- For each work interval, a planning slot is assigned to the employee, until there are no more hours to allocate
|
|
- Details :
|
|
- If the interval is in conflict with a partial_interval_slots, the algorithm must find each time the sum of allocated_percentage increases/decreases:
|
|
- The algorithm retrieve this information by building a dict where the keys are the datetime where the allocated_percentage changes :
|
|
- The algorithm adds start and end of the interval in the dict with 0 as value to increase/decrease
|
|
- For each slot conflicting with the work_interval:
|
|
- allocated_percentage is added with start_datetime as a key,
|
|
- allocated_percentage is substracted with end_datetime as a key
|
|
- For each datetime where the allocated_percentage changes:
|
|
- if there are no allocated percentage change (sum = 0) in the next allocated percentage change:
|
|
- It will create a merged slot and not divide it in small parts
|
|
- the allocable percentage (default=100) is decreased by the value in the dict for the previous datetime (which will be the start datetime of the slot)
|
|
- if there are still time to allocate
|
|
- Otherwise, it continues with the next datetime with allocated percentage change.
|
|
- if the datetimes are contained in the interval
|
|
- Otherwise, it continues with the next datetime with allocated percentage change.
|
|
- The slot is build with the previous datetime with allocated percentage change and the actual datetime.
|
|
- Otherwise,
|
|
- Take the start of the interval as the start_datetime of the slot
|
|
- Take the min value between the end of the interval and the sum of the interval start and to_allocate hours.
|
|
- Generate an unplanned slot if there are still hours to allocate.
|
|
|
|
Returns :
|
|
- A vals_list with slots to create :
|
|
NB : The first item of the list will be used to update the current slot.
|
|
"""
|
|
self.ensure_one()
|
|
following_slots_vals_list = []
|
|
for interval in work_intervals:
|
|
if float_utils.float_compare(to_allocate, 0.0, precision_digits=2) < 1:
|
|
break
|
|
start_interval = interval[0].astimezone(pytz.utc).replace(tzinfo=None)
|
|
end_interval = interval[1].astimezone(pytz.utc).replace(tzinfo=None)
|
|
if partial_interval_slots[interval]:
|
|
# here we'll create slots with partially allocated hours - which is not trivial btw - read above the full explanation
|
|
# 1. Create a dict with a datetime as `key`, which represent the total *increment* of allocated time, starting from time: `key`
|
|
# So for each slot, the increment is increased with allocated percentage at start datetime and decrease it at end datetime
|
|
# 2. Create a list with all the start and end dates (allocated_dict keys), which will be sorted in order to have all the intervals.
|
|
# 3. Allocable percentage are tracked by decreasing previous allocable percentage with the *increment* of allocated time.
|
|
allocated_dict = defaultdict(float)
|
|
allocated_dict.update({
|
|
start_interval: 0,
|
|
end_interval: 0,
|
|
})
|
|
for slot in partial_interval_slots[interval]:
|
|
allocated_dict[slot['start_datetime']] += float_utils.float_round(slot['allocated_percentage'], precision_digits=1)
|
|
allocated_dict[slot['end_datetime']] += float_utils.float_round(-slot['allocated_percentage'], precision_digits=1)
|
|
datetime_list = list(allocated_dict.keys())
|
|
datetime_list.sort()
|
|
allocable = 100.0
|
|
for i in range(1, len(datetime_list)):
|
|
start_dt = datetime_list[i - 1]
|
|
end_dt = datetime_list[i]
|
|
if i != len(datetime_list) - 1 and float_utils.float_is_zero(allocated_dict[datetime_list[i]], precision_digits=2):
|
|
# there is no increment so we will build a single slot with the same allocated_percentage
|
|
datetime_list[i] = datetime_list[i - 1]
|
|
continue
|
|
allocable -= float_utils.float_round(allocated_dict[datetime_list[i - 1]], precision_digits=1)
|
|
if float_utils.float_compare(allocable, 0.0, precision_digits=2) < 1:
|
|
unforecastable_intervals |= Intervals([(
|
|
pytz.utc.localize(start_dt),
|
|
pytz.utc.localize(end_dt),
|
|
self.env['resource.calendar.leaves'])])
|
|
continue
|
|
if end_dt <= start_interval or start_dt >= end_interval:
|
|
continue
|
|
start_dt = max(start_dt, start_interval)
|
|
end_dt = min(end_dt, end_interval)
|
|
end_dt = min(end_dt, start_dt + timedelta(hours=to_allocate * (100.0 / allocable)))
|
|
to_allocate -= ((end_dt - start_dt).total_seconds() / 3600.0) * (allocable / 100.0)
|
|
self._add_slot_to_list(start_dt, end_dt, resource, following_slots_vals_list, allocable=allocable)
|
|
else:
|
|
end_dt = min(start_interval + timedelta(hours=to_allocate), end_interval)
|
|
to_allocate -= (end_dt - start_interval).total_seconds() / 3600.0
|
|
self._add_slot_to_list(start_interval, end_dt, resource, following_slots_vals_list)
|
|
|
|
if float_utils.float_compare(to_allocate, 0.0, precision_digits=2) == 1 and following_slots_vals_list:
|
|
planning_slot_values = self.sale_line_id._planning_slot_values()
|
|
planning_slot_values.update(allocated_hours=to_allocate)
|
|
following_slots_vals_list.append(planning_slot_values)
|
|
|
|
return following_slots_vals_list
|
|
|
|
def _add_slot_to_list(self, start_datetime, end_datetime, resource, following_slots_vals_list, allocable=100.0):
|
|
if end_datetime <= start_datetime:
|
|
return
|
|
allocated_hours = ((end_datetime - start_datetime).total_seconds() / 3600.0) * (allocable / 100.0)
|
|
following_slots_vals_list.append({
|
|
**self.sale_line_id._planning_slot_values(),
|
|
'start_datetime': start_datetime,
|
|
'end_datetime': end_datetime,
|
|
'allocated_percentage': allocable,
|
|
'allocated_hours': allocated_hours,
|
|
'resource_id': resource.id,
|
|
})
|
|
|
|
def _get_resource_work_info(self, vals, slot_vals_list_per_resource):
|
|
"""
|
|
This method returns the resource work intervals and a dict representing
|
|
the work_intervals which has conflicting partial slots (slot with allocated percentage < 100.0).
|
|
|
|
It retrieves the work intervals and removes the intervals where a complete
|
|
slot exists (allocated_percentage == 100.0).
|
|
It takes into account the slots already added to the vals list.
|
|
|
|
:param vals: the vals dict passed to the write method
|
|
:param slot_vals_list_per_resource: a dict with the vals list that will be passed to the create method - sorted per key:resource_id
|
|
"""
|
|
self.ensure_one()
|
|
assert self.env.context.get('default_end_datetime')
|
|
if isinstance(vals['start_datetime'], str):
|
|
start_dt = pytz.utc.localize(datetime.strptime(vals['start_datetime'], DEFAULT_SERVER_DATETIME_FORMAT))
|
|
else:
|
|
start_dt = pytz.utc.localize(vals['start_datetime'])
|
|
end_dt = pytz.utc.localize(datetime.strptime(self.env.context['default_end_datetime'], DEFAULT_SERVER_DATETIME_FORMAT))
|
|
# retrieve the resource and its calendar validity intervals
|
|
resource_calendar_validity_intervals, resource = self._get_slot_calendar_and_resource(vals, start_dt, end_dt)
|
|
attendance_intervals = Intervals()
|
|
unavailability_intervals = Intervals()
|
|
# retrieves attendances and unavailabilities of the resource
|
|
for calendar, validity_intervals in resource_calendar_validity_intervals.items():
|
|
attendance = calendar._attendance_intervals_batch(
|
|
start_dt, end_dt, resources=resource)[resource.id]
|
|
leaves = calendar._leave_intervals_batch(
|
|
start_dt, end_dt, resources=resource)[resource.id]
|
|
# The calendar is valid only during its validity interval (see resource_resource:_get_calendars_validity_within_period)
|
|
attendance_intervals |= attendance & validity_intervals
|
|
unavailability_intervals |= leaves & validity_intervals
|
|
partial_slots = {}
|
|
partial_interval_slots = defaultdict(list)
|
|
if resource:
|
|
# gets slots which exists in the period [start_dt;end_dt]
|
|
slots = self.search_read([
|
|
('resource_id', '=', resource.id),
|
|
('start_datetime', '<', end_dt.replace(tzinfo=None)),
|
|
('end_datetime', '>', start_dt.replace(tzinfo=None)),
|
|
], ['start_datetime', 'end_datetime', 'allocated_percentage'])
|
|
# add the vals list of the resource (slots that will be created at the end of the write method.)
|
|
slots += slot_vals_list_per_resource[resource]
|
|
planning_slots_intervals = Intervals()
|
|
partial_slots = []
|
|
# generate partial intervals and complete intervals
|
|
for slot in slots:
|
|
if not slot['start_datetime']:
|
|
# this slot is a future unscheduled slots coming from the slot_vals_list_per_resource[resource]
|
|
continue
|
|
if float_utils.float_compare(slot['allocated_percentage'], 100.0, precision_digits=0) < 0:
|
|
partial_slots.append(slot)
|
|
else:
|
|
interval = Intervals([(
|
|
pytz.utc.localize(slot['start_datetime']),
|
|
pytz.utc.localize(slot['end_datetime']),
|
|
self.env['resource.calendar.leaves']
|
|
)])
|
|
planning_slots_intervals |= interval
|
|
# adds the full planning_slots to the unavailibility intervals
|
|
unavailability_intervals |= planning_slots_intervals
|
|
work_intervals = attendance_intervals - unavailability_intervals
|
|
if partial_slots:
|
|
# for the partial slots, add it to a list with key = interval, value = list of slots which exists during the interval (at least during a while).
|
|
for interval in work_intervals:
|
|
# for each interval, add partial slots that conflict.
|
|
for slot in partial_slots:
|
|
if pytz.utc.localize(slot['start_datetime']) < interval[1] and pytz.utc.localize(slot['end_datetime']) > interval[0]:
|
|
partial_interval_slots[interval].append(slot)
|
|
else:
|
|
work_intervals = attendance_intervals - unavailability_intervals
|
|
return work_intervals, unavailability_intervals, resource, partial_interval_slots
|
|
|
|
def _get_slot_calendar_and_resource(self, vals, start, end):
|
|
"""
|
|
This method is meant to access easily to slot's resource and the resource's calendars with their validity
|
|
"""
|
|
self.ensure_one()
|
|
resource = self.resource_id
|
|
if vals.get('resource_id'):
|
|
resource = self.env['resource.resource'].browse(vals.get('resource_id'))
|
|
resource_calendar_validity_intervals = resource._get_calendars_validity_within_period(start, end, default_company=self.company_id)[resource.id]
|
|
return resource_calendar_validity_intervals, resource
|
|
|
|
# -------------------------------------------
|
|
# Slots Assignation
|
|
# -------------------------------------------
|
|
|
|
@api.model
|
|
def _get_employee_to_assign_priority_list(self):
|
|
return ['previous_slot', 'default_role', 'roles']
|
|
|
|
def _get_employee_per_priority(self, priority, employee_ids_to_exclude, cache):
|
|
"""
|
|
This method returns the id of an employee filling the priority criterias and
|
|
not present in the employee_ids_to_exclude.
|
|
"""
|
|
if priority in cache:
|
|
return cache[priority].pop(0) if cache.get(priority) else None
|
|
if priority == 'previous_slot':
|
|
search = self._read_group([
|
|
('sale_line_id', '=', self.sale_line_id.id),
|
|
('employee_id', '!=', False),
|
|
('start_datetime', '!=', False),
|
|
('employee_id', 'not in', employee_ids_to_exclude),
|
|
], ['employee_id'], order='end_datetime:max desc, employee_id')
|
|
cache[priority] = [employee.id for [employee] in search]
|
|
elif priority == 'default_role':
|
|
search = self.env['hr.employee'].sudo().search([
|
|
('default_planning_role_id', '=', self.role_id.id),
|
|
('id', 'not in', employee_ids_to_exclude),
|
|
])
|
|
cache[priority] = search.ids
|
|
elif priority == 'roles':
|
|
search = self.env['hr.employee'].search([
|
|
('planning_role_ids', '=', self.role_id.id),
|
|
('id', 'not in', employee_ids_to_exclude),
|
|
])
|
|
cache[priority] = search.ids
|
|
return cache[priority].pop(0) if cache.get(priority) else None
|
|
|
|
def _get_employee_to_assign(self, default_priority, employee_ids_to_exclude, cache, employee_per_sol):
|
|
"""
|
|
Returns the id of the employee to assign and its corresponding priority
|
|
"""
|
|
self.ensure_one()
|
|
if self.sale_line_id.id in employee_per_sol:
|
|
employee_id = next(
|
|
(employee_id
|
|
for employee_id in employee_per_sol[self.sale_line_id.id]
|
|
if employee_id not in employee_ids_to_exclude),
|
|
None
|
|
)
|
|
return employee_id, default_priority
|
|
# This method is written to be overridden, so as every module can keep the priority ordered as its required.
|
|
priority_list = self._get_employee_to_assign_priority_list()
|
|
for priority in priority_list:
|
|
if not default_priority or priority == default_priority:
|
|
# if default_priority is given, the search only starts with this priority.
|
|
default_priority = None
|
|
employee_id = self._get_employee_per_priority(priority, employee_ids_to_exclude, cache)
|
|
if employee_id:
|
|
return employee_id, priority
|
|
return None, None
|
|
|
|
@api.model
|
|
def _get_ordered_slots_to_assign(self, domain):
|
|
"""
|
|
Returns an ordered list of slots (linked to sol) to plan while using the action_plan_sale_order.
|
|
|
|
This method is meant to be easily overriden.
|
|
"""
|
|
return self.search(domain, order='sale_line_id desc')
|
|
|
|
@api.model
|
|
def _get_employee_per_sol_within_period(self, slots, start, end):
|
|
""" Gets the employees already assigned during this period.
|
|
|
|
:returns: a dict with key : SOL id, and values : a list of employee ids
|
|
"""
|
|
assert start and end
|
|
if isinstance(end, str):
|
|
end = datetime.strptime(end, DEFAULT_SERVER_DATETIME_FORMAT)
|
|
|
|
employee_per_sol = self.env['planning.slot']._read_group([
|
|
('sale_line_id', 'in', slots.sale_line_id.ids),
|
|
('start_datetime', '<', end),
|
|
('end_datetime', '>', start),
|
|
('employee_id', '!=', False),
|
|
], ['sale_line_id'], ['employee_id:array_agg'])
|
|
|
|
return {
|
|
sale_line.id: employee_ids
|
|
for sale_line, employee_ids in employee_per_sol
|
|
}
|
|
|
|
def _get_shifts_to_plan_domain(self, view_domain=None):
|
|
new_view_domain = []
|
|
if view_domain:
|
|
for clause in view_domain:
|
|
if isinstance(clause, str) or clause[0] not in ['start_datetime', 'end_datetime']:
|
|
new_view_domain.append(clause)
|
|
elif clause[0] in ['start_datetime', 'end_datetime']:
|
|
new_view_domain.append([clause[0], '=', False])
|
|
else:
|
|
new_view_domain = [('start_datetime', '=', False)]
|
|
domain = expression.AND([new_view_domain, [('sale_line_id', '!=', False)]])
|
|
if self.env.context.get('planning_gantt_active_sale_order_id'):
|
|
domain = expression.AND([domain, [('sale_order_id', '=', self.env.context.get('planning_gantt_active_sale_order_id'))]])
|
|
return domain
|
|
|
|
@api.model
|
|
def auto_plan_ids(self, view_domain):
|
|
res = super(PlanningSlot, self).auto_plan_ids(view_domain)
|
|
if self._context.get('planning_slot_id'):
|
|
# It means we are looking to assign one shift in particular to an available resource, which we do in planning.
|
|
return res
|
|
slots_to_assign = self._get_ordered_slots_to_assign(self._get_shifts_to_plan_domain(view_domain))
|
|
start_datetime = max(datetime.strptime(self.env.context.get('default_start_datetime'), DEFAULT_SERVER_DATETIME_FORMAT), fields.Datetime.now().replace(hour=0, minute=0, second=0))
|
|
employee_per_sol = self._get_employee_per_sol_within_period(slots_to_assign, start_datetime, self.env.context.get('default_end_datetime'))
|
|
PlanningShift = self.env['planning.slot']
|
|
slots_assigned = PlanningShift
|
|
employee_ids_to_exclude = []
|
|
for slot in slots_to_assign:
|
|
slot_assigned = PlanningShift
|
|
previous_priority = None
|
|
cache = {}
|
|
while not slot_assigned:
|
|
# Retrieve an employee_id that may be assigned to this slot, excluding the ones who have no time left.
|
|
# The previous priority is given in order to get the second employee that respects the previous criterias
|
|
employee_id, previous_priority = slot._get_employee_to_assign(previous_priority, employee_ids_to_exclude, cache, employee_per_sol)
|
|
if not employee_id:
|
|
break
|
|
# The browse is mandatory to access the resource calendar
|
|
employee = self.env['hr.employee'].browse(employee_id)
|
|
vals = {
|
|
'start_datetime': start_datetime,
|
|
'end_datetime': start_datetime + timedelta(days=1),
|
|
'resource_id': employee.resource_id.id
|
|
}
|
|
# With the context keys, the maximal date to assign the slot will be self.env.context.get('default_end_datetime')
|
|
slot_assigned = slot.assign_slot(vals)
|
|
if not slot_assigned:
|
|
# if no slot was generated (it uses the write method), then the employee_id is excluded from the employees assignable on this slot.
|
|
employee_ids_to_exclude.append(employee_id)
|
|
slots_assigned += slot_assigned
|
|
return res + slots_assigned.ids
|
|
|
|
# -------------------------------------------
|
|
# Copy slots
|
|
# -------------------------------------------
|
|
|
|
def _init_remaining_hours_to_plan(self, remaining_hours_to_plan):
|
|
"""
|
|
Fills the remaining_hours_to_plan dict for a given slot and returns wether
|
|
there are enough remaining hours.
|
|
|
|
:return a bool representing wether or not there are still hours remaining
|
|
"""
|
|
self.ensure_one()
|
|
res = super()._init_remaining_hours_to_plan(remaining_hours_to_plan)
|
|
if self.sale_line_id.product_id.planning_enabled:
|
|
# if the slot is linked to a slot, we only need to allocate the remaining hours to plan
|
|
# we keep track of those hours in a dict and decrease it each time we create a slot.
|
|
if self.sale_line_id not in remaining_hours_to_plan:
|
|
self.sale_line_id._compute_planning_hours_planned()
|
|
remaining_hours_to_plan[self.sale_line_id] = self.sale_line_id.planning_hours_to_plan - self.sale_line_id.planning_hours_planned
|
|
if float_utils.float_compare(remaining_hours_to_plan[self.sale_line_id], 0.0, precision_digits=2) != 1:
|
|
return False # nothing left to allocate.
|
|
return res
|
|
|
|
def _update_remaining_hours_to_plan_and_values(self, remaining_hours_to_plan, values):
|
|
"""
|
|
Update the remaining_hours_to_plan with the allocated hours of the slot in `values`
|
|
and returns wether there are enough remaining hours.
|
|
|
|
If remaining_hours is strictly positive, and the allocated hours of the slot in `values` is
|
|
higher than remaining hours, than update the values in order to consume at most the
|
|
number of remaining_hours still available.
|
|
|
|
:return a bool representing wether or not there are still hours remaining
|
|
"""
|
|
if self.allocated_percentage and self.sale_line_id.product_id.planning_enabled:
|
|
if float_utils.float_compare(remaining_hours_to_plan[self.sale_line_id], 0.0, precision_digits=2) != 1:
|
|
return False
|
|
# The allocated hours of the slot can be computed as for a slot with allocation_type == 'planning'
|
|
# since it is build from an employee work interval, thus will last less than 24hours.
|
|
allocated_hours = (values['end_datetime'] - values['start_datetime']).total_seconds() / 3600
|
|
# Allocated_hours is discounted from remaining hours with a maximum of : remaining_hours
|
|
# So, the difference between the two values must be checked, if remaining_hours is less than the
|
|
# allocated hours, than update the end_datetime.
|
|
ratio = self.allocated_percentage / 100.00
|
|
remaining_hours = min(remaining_hours_to_plan[self.sale_line_id] / ratio, allocated_hours)
|
|
values['end_datetime'] = values['start_datetime'] + timedelta(hours=remaining_hours)
|
|
values.pop('allocated_hours', None) # we want that to be computed again.
|
|
remaining_hours_to_plan[self.sale_line_id] -= remaining_hours * ratio
|
|
return True
|
|
|
|
def action_unschedule(self):
|
|
self.ensure_one()
|
|
if self.sale_line_id.product_id.planning_enabled:
|
|
if self.sale_line_id.planning_hours_to_plan - self.sale_line_id.planning_hours_planned > 0.0:
|
|
unscheduled_slot = self.search([
|
|
('sale_line_id', '=', self.sale_line_id.id),
|
|
('start_datetime', '=', False),
|
|
])
|
|
if unscheduled_slot:
|
|
self.unlink()
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
return self.write({
|
|
'start_datetime': False,
|
|
'end_datetime': False,
|
|
'employee_id': False,
|
|
})
|
|
|
|
# -----------------------------------
|
|
# Gantt Progress Bar
|
|
# -----------------------------------
|
|
def _gantt_progress_bar_sale_line_id(self, res_ids):
|
|
if not self.env['sale.order.line'].check_access_rights('read', raise_exception=False):
|
|
return {}
|
|
return {
|
|
sol.id: {
|
|
'value': sol.planning_hours_planned,
|
|
'max_value': sol.planning_hours_to_plan,
|
|
}
|
|
for sol in self.env['sale.order.line'].search([('id', 'in', res_ids)])
|
|
}
|
|
|
|
def _gantt_progress_bar(self, field, res_ids, start, stop):
|
|
if field == 'sale_line_id':
|
|
return dict(
|
|
self._gantt_progress_bar_sale_line_id(res_ids),
|
|
warning=_("This Sale Order Item doesn't have a target value of planned hours. Planned hours :")
|
|
)
|
|
return super()._gantt_progress_bar(field, res_ids, start, stop)
|
|
|
|
def _prepare_shift_vals(self):
|
|
return {
|
|
**super()._prepare_shift_vals(),
|
|
'sale_line_id': self.sale_line_id.id,
|
|
}
|
|
|
|
def _gantt_progress_bar_resource_id(self, res_ids, start, stop):
|
|
results = super()._gantt_progress_bar_resource_id(res_ids, start, stop)
|
|
resource_per_id = {r.id: r for r in self.env['resource.resource'].browse(list(results.keys()))}
|
|
for key, val in results.items():
|
|
resource = resource_per_id[key]
|
|
val['role_ids'] = resource.role_ids.ids
|
|
return results
|