1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/sale_planning/models/planning_slot.py
2024-12-10 09:04:09 +07:00

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