144 lines
8.0 KiB
Python
144 lines
8.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import math
|
|
|
|
from odoo import fields, models, api
|
|
from odoo.osv import expression
|
|
|
|
class HelpdeskSLAStatus(models.Model):
|
|
_name = 'helpdesk.sla.status'
|
|
_description = "Ticket SLA Status"
|
|
_table = 'helpdesk_sla_status'
|
|
_order = 'deadline ASC, sla_stage_id'
|
|
_rec_name = 'sla_id'
|
|
|
|
ticket_id = fields.Many2one('helpdesk.ticket', string='Ticket', required=True, ondelete='cascade', index=True)
|
|
sla_id = fields.Many2one('helpdesk.sla', required=True, ondelete='cascade')
|
|
sla_stage_id = fields.Many2one('helpdesk.stage', related='sla_id.stage_id', store=True, export_string_translation=False) # need to be stored for the search in `_sla_reach`
|
|
deadline = fields.Datetime("Deadline", compute='_compute_deadline', compute_sudo=True, store=True)
|
|
reached_datetime = fields.Datetime("Reached Date", help="Datetime at which the SLA stage was reached for the first time")
|
|
status = fields.Selection([('failed', 'Failed'), ('reached', 'Reached'), ('ongoing', 'Ongoing')], string="Status", compute='_compute_status', compute_sudo=True, search='_search_status')
|
|
color = fields.Integer("Color Index", compute='_compute_color')
|
|
exceeded_hours = fields.Float("Exceeded Working Hours", compute='_compute_exceeded_hours', compute_sudo=True, store=True, help="Working hours exceeded for reached SLAs compared with deadline. Positive number means the SLA was reached after the deadline.")
|
|
|
|
@api.depends('ticket_id.create_date', 'sla_id', 'ticket_id.stage_id')
|
|
def _compute_deadline(self):
|
|
for status in self:
|
|
if (status.deadline and status.reached_datetime) or (status.deadline and not status.sla_id.exclude_stage_ids) or (status.status == 'failed'):
|
|
continue
|
|
deadline = status.ticket_id.create_date
|
|
working_calendar = status.ticket_id.team_id.resource_calendar_id
|
|
if not working_calendar:
|
|
# Normally, having a working_calendar is mandatory
|
|
status.deadline = deadline
|
|
continue
|
|
|
|
if status.sla_id.exclude_stage_ids:
|
|
if status.ticket_id.stage_id in status.sla_id.exclude_stage_ids:
|
|
# We are in the freezed time stage: No deadline
|
|
status.deadline = False
|
|
continue
|
|
|
|
avg_hour = working_calendar.hours_per_day or 8 # default to 8 working hours/day
|
|
time_days = math.floor(status.sla_id.time / avg_hour)
|
|
if time_days > 0:
|
|
deadline = working_calendar.plan_days(time_days + 1, deadline, compute_leaves=True)
|
|
# We should also depend on ticket creation time, otherwise for 1 day SLA, all tickets
|
|
# created on monday will have their deadline filled with tuesday 8:00
|
|
create_dt = working_calendar.plan_hours(0, status.ticket_id.create_date)
|
|
deadline = deadline and deadline.replace(hour=create_dt.hour, minute=create_dt.minute, second=create_dt.second, microsecond=create_dt.microsecond)
|
|
|
|
sla_hours = status.sla_id.time % avg_hour
|
|
|
|
if status.sla_id.exclude_stage_ids:
|
|
sla_hours += status._get_freezed_hours(working_calendar)
|
|
|
|
# Except if ticket creation time is later than the end time of the working day
|
|
deadline_for_working_cal = working_calendar.plan_hours(0, deadline)
|
|
if deadline_for_working_cal and deadline.day < deadline_for_working_cal.day and time_days > 0:
|
|
deadline = deadline.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
# We should execute the function plan_hours in any case because, in a 1 day SLA environment,
|
|
# if I create a ticket knowing that I'm not working the day after at the same time, ticket
|
|
# deadline will be set at time I don't work (ticket creation time might not be in working calendar).
|
|
status.deadline = deadline and working_calendar.plan_hours(sla_hours, deadline, compute_leaves=True)
|
|
|
|
@api.depends('deadline', 'reached_datetime')
|
|
def _compute_status(self):
|
|
for status in self:
|
|
if status.reached_datetime and status.deadline: # if reached_datetime, SLA is finished: either failed or succeeded
|
|
status.status = 'reached' if status.reached_datetime < status.deadline else 'failed'
|
|
else: # if not finished, deadline should be compared to now()
|
|
status.status = 'ongoing' if not status.deadline or status.deadline > fields.Datetime.now() else 'failed'
|
|
|
|
@api.model
|
|
def _search_status(self, operator, value):
|
|
""" Supported operators: '=', 'in' and their negative form. """
|
|
# constants
|
|
datetime_now = fields.Datetime.now()
|
|
positive_domain = {
|
|
'failed': ['|', '&', ('reached_datetime', '=', True), ('deadline', '<=', 'reached_datetime'), '&', ('reached_datetime', '=', False), ('deadline', '<=', fields.Datetime.to_string(datetime_now))],
|
|
'reached': ['&', ('reached_datetime', '=', True), ('reached_datetime', '<', 'deadline')],
|
|
'ongoing': ['|', ('deadline', '=', False), '&', ('reached_datetime', '=', False), ('deadline', '>', fields.Datetime.to_string(datetime_now))]
|
|
}
|
|
# in/not in case: we treat value as a list of selection item
|
|
if not isinstance(value, list):
|
|
value = [value]
|
|
# transform domains
|
|
if operator in expression.NEGATIVE_TERM_OPERATORS:
|
|
# "('status', 'not in', [A, B])" tranformed into "('status', '=', C) OR ('status', '=', D)"
|
|
domains_to_keep = [dom for key, dom in positive_domain if key not in value]
|
|
return expression.OR(domains_to_keep)
|
|
else:
|
|
return expression.OR(positive_domain[value_item] for value_item in value)
|
|
|
|
@api.depends('status')
|
|
def _compute_color(self):
|
|
for status in self:
|
|
if status.status == 'failed':
|
|
status.color = 1
|
|
elif status.status == 'reached':
|
|
status.color = 10
|
|
else:
|
|
status.color = 0
|
|
|
|
@api.depends('deadline', 'reached_datetime')
|
|
def _compute_exceeded_hours(self):
|
|
for status in self:
|
|
if status.deadline and status.ticket_id.team_id.resource_calendar_id:
|
|
reached_datetime = status.reached_datetime or fields.Datetime.now()
|
|
if reached_datetime <= status.deadline:
|
|
start_dt = reached_datetime
|
|
end_dt = status.deadline
|
|
factor = -1
|
|
else:
|
|
start_dt = status.deadline
|
|
end_dt = reached_datetime
|
|
factor = 1
|
|
duration_data = status.ticket_id.team_id.resource_calendar_id.get_work_duration_data(start_dt, end_dt, compute_leaves=True)
|
|
status.exceeded_hours = duration_data['hours'] * factor
|
|
else:
|
|
status.exceeded_hours = False
|
|
|
|
def _get_freezed_hours(self, working_calendar):
|
|
self.ensure_one()
|
|
hours_freezed = 0
|
|
|
|
field_stage = self.env['ir.model.fields']._get(self.ticket_id._name, "stage_id")
|
|
freeze_stages = self.sla_id.exclude_stage_ids.ids
|
|
tracking_lines = self.ticket_id.message_ids.tracking_value_ids.filtered(lambda tv: tv.field_id == field_stage).sorted(key="create_date")
|
|
|
|
if not tracking_lines:
|
|
return 0
|
|
|
|
old_time = self.ticket_id.create_date
|
|
for tracking_line in tracking_lines:
|
|
if tracking_line.old_value_integer in freeze_stages:
|
|
# We must use get_work_hours_count to compute real waiting hours (as the deadline computation is also based on calendar)
|
|
hours_freezed += working_calendar.get_work_hours_count(old_time, tracking_line.create_date)
|
|
old_time = tracking_line.create_date
|
|
if tracking_lines[-1].new_value_integer in freeze_stages:
|
|
# the last tracking line is not yet created
|
|
hours_freezed += working_calendar.get_work_hours_count(old_time, fields.Datetime.now())
|
|
return hours_freezed
|