Refactoring: SLA and Escalation logic. Fixes #3410, fixes #3140, fixes #2579.

This commit is contained in:
Mantas Masalskis 2021-02-15 14:55:00 +01:00 committed by Thorsten Eckel
parent 161dd525ac
commit 1aa4e68c6c
40 changed files with 4094 additions and 2945 deletions

View file

@ -255,8 +255,11 @@ RSpec/ExampleLength:
- 'spec/models/role_group_spec.rb'
- 'spec/models/role_spec.rb'
- 'spec/models/scheduler_spec.rb'
- 'spec/models/sla/has_escalation_calculation_impact_examples.rb'
- 'spec/models/taskbar_spec.rb'
- 'spec/models/ticket/article_spec.rb'
- 'spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb'
- 'spec/models/ticket/escalation_examples.rb'
- 'spec/models/ticket/overviews_spec.rb'
- 'spec/models/ticket_spec.rb'
- 'spec/models/translation_spec.rb'
@ -528,9 +531,12 @@ RSpec/MultipleExpectations:
- 'spec/models/object_manager/attribute_spec.rb'
- 'spec/models/overview_spec.rb'
- 'spec/models/scheduler_spec.rb'
- 'spec/models/sla/has_escalation_calculation_impact_examples.rb'
- 'spec/models/smime_certificate_spec.rb'
- 'spec/models/taskbar_spec.rb'
- 'spec/models/ticket/article_spec.rb'
- 'spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb'
- 'spec/models/ticket/escalation_examples.rb'
- 'spec/models/ticket/number/increment_spec.rb'
- 'spec/models/ticket/number_spec.rb'
- 'spec/models/ticket/overviews_spec.rb'

View file

@ -156,7 +156,6 @@ Metrics/AbcSize:
- 'app/models/link.rb'
- 'app/models/object_manager/attribute.rb'
- 'app/models/observer/chat/leave/background_job.rb'
- 'app/models/observer/sla/ticket_rebuild_escalation.rb'
- 'app/models/observer/ticket/article/communicate_email.rb'
- 'app/models/observer/ticket/article/communicate_facebook.rb'
- 'app/models/observer/ticket/article/communicate_sms.rb'
@ -165,7 +164,6 @@ Metrics/AbcSize:
- 'app/models/observer/ticket/article/fillup_from_email.rb'
- 'app/models/observer/ticket/article/fillup_from_general.rb'
- 'app/models/observer/ticket/article/fillup_from_origin_by_id.rb'
- 'app/models/observer/ticket/article_changes.rb'
- 'app/models/observer/ticket/last_owner_update.rb'
- 'app/models/observer/ticket/ref_object_touch.rb'
- 'app/models/observer/ticket/reset_new_state.rb'
@ -199,6 +197,7 @@ Metrics/AbcSize:
- 'app/models/ticket.rb'
- 'app/models/ticket/article.rb'
- 'app/models/ticket/article/assets.rb'
- 'app/models/ticket/article/has_ticket_contact_attributes_impact.rb'
- 'app/models/ticket/assets.rb'
- 'app/models/ticket/escalation.rb'
- 'app/models/ticket/number/date.rb'
@ -546,7 +545,6 @@ Metrics/CyclomaticComplexity:
- 'app/models/karma/activity_log.rb'
- 'app/models/knowledge_base.rb'
- 'app/models/object_manager/attribute.rb'
- 'app/models/observer/sla/ticket_rebuild_escalation.rb'
- 'app/models/observer/ticket/article/communicate_email.rb'
- 'app/models/observer/ticket/article/communicate_facebook.rb'
- 'app/models/observer/ticket/article/communicate_sms.rb'
@ -554,7 +552,6 @@ Metrics/CyclomaticComplexity:
- 'app/models/observer/ticket/article/fillup_from_email.rb'
- 'app/models/observer/ticket/article/fillup_from_general.rb'
- 'app/models/observer/ticket/article/fillup_from_origin_by_id.rb'
- 'app/models/observer/ticket/article_changes.rb'
- 'app/models/observer/ticket/last_owner_update.rb'
- 'app/models/observer/ticket/ref_object_touch.rb'
- 'app/models/observer/ticket/reset_new_state.rb'
@ -576,6 +573,7 @@ Metrics/CyclomaticComplexity:
- 'app/models/taskbar.rb'
- 'app/models/ticket/article.rb'
- 'app/models/ticket/article/assets.rb'
- 'app/models/ticket/article/has_ticket_contact_attributes_impact.rb'
- 'app/models/ticket/assets.rb'
- 'app/models/ticket/escalation.rb'
- 'app/models/ticket/number/date.rb'
@ -777,7 +775,6 @@ Metrics/PerceivedComplexity:
- 'app/models/karma/activity_log.rb'
- 'app/models/knowledge_base.rb'
- 'app/models/object_manager/attribute.rb'
- 'app/models/observer/sla/ticket_rebuild_escalation.rb'
- 'app/models/observer/ticket/article/communicate_email.rb'
- 'app/models/observer/ticket/article/communicate_facebook.rb'
- 'app/models/observer/ticket/article/communicate_sms.rb'
@ -785,7 +782,6 @@ Metrics/PerceivedComplexity:
- 'app/models/observer/ticket/article/fillup_from_email.rb'
- 'app/models/observer/ticket/article/fillup_from_general.rb'
- 'app/models/observer/ticket/article/fillup_from_origin_by_id.rb'
- 'app/models/observer/ticket/article_changes.rb'
- 'app/models/observer/ticket/last_owner_update.rb'
- 'app/models/observer/ticket/ref_object_touch.rb'
- 'app/models/observer/ticket/reset_new_state.rb'
@ -806,6 +802,7 @@ Metrics/PerceivedComplexity:
- 'app/models/ticket.rb'
- 'app/models/ticket/article.rb'
- 'app/models/ticket/article/assets.rb'
- 'app/models/ticket/article/has_ticket_contact_attributes_impact.rb'
- 'app/models/ticket/escalation.rb'
- 'app/models/ticket/number/date.rb'
- 'app/models/ticket/overviews.rb'

View file

@ -1,8 +0,0 @@
class SlaTicketRebuildEscalationJob < ApplicationJob
include HasActiveJobLock
def perform
Cache.delete('SLA::List::Active')
Ticket::Escalation.rebuild_all
end
end

View file

@ -0,0 +1,16 @@
class TicketEscalationRebuildJob < ApplicationJob
include HasActiveJobLock
def perform
scope.in_batches.each_record do |ticket|
ticket.escalation_calculation(true)
end
end
private
def scope
Ticket.where(state_id: Ticket::State.by_category(:open))
end
end

View file

@ -3,6 +3,7 @@
class Calendar < ApplicationModel
include ChecksClientNotification
include CanUniqName
include HasEscalationCalculationImpact
store :business_hours
store :public_holidays
@ -324,7 +325,7 @@ returns
holidays
end
def biz
def biz(breaks: {})
Biz::Schedule.new do |config|
# get business hours
@ -335,7 +336,10 @@ returns
# get holidays
config.holidays = public_holidays_to_array
config.time_zone = timezone
config.breaks = breaks
end
end

View file

@ -0,0 +1,31 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module HasEscalationCalculationImpact
extend ActiveSupport::Concern
included do
after_commit :enqueue_ticket_escalation_rebuild_job
end
private
def enqueue_ticket_escalation_rebuild_job
# return if we run import mode
return if Setting.get('import_mode') && !Setting.get('import_ignore_sla')
# check if condition has changed
fields_to_check = if instance_of?(Sla)
%w[condition calendar_id first_response_time update_time solution_time]
else
%w[timezone business_hours default ical_url public_holidays]
end
return if fields_to_check.none? do |item|
next if !saved_change_to_attribute(item)
saved_change_to_attribute(item)[0] != saved_change_to_attribute(item)[1]
end
TicketEscalationRebuildJob.perform_later
end
end

View file

@ -1,44 +0,0 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class Observer::Sla::TicketRebuildEscalation < ActiveRecord::Observer
observe 'sla', 'calendar'
def after_commit(record)
return if _check(record)
_rebuild
end
private
def _rebuild
Cache.delete('SLA::List::Active')
# send background job
SlaTicketRebuildEscalationJob.perform_later
end
def _check(record)
# return if we run import mode
return true if Setting.get('import_mode') && !Setting.get('import_ignore_sla')
# check if condition has changed
changed = false
fields_to_check = if record.instance_of?(Sla)
%w[condition calendar_id first_response_time update_time solution_time]
else
%w[timezone business_hours default ical_url public_holidays]
end
fields_to_check.each do |item|
next if !record.saved_change_to_attribute(item)
next if record.saved_change_to_attribute(item)[0] == record.saved_change_to_attribute(item)[1]
changed = true
end
return true if !changed
false
end
end

View file

@ -1,28 +0,0 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class Observer::Ticket::EscalationUpdate < ActiveRecord::Observer
observe 'ticket'
def after_commit(record)
_check(record)
end
private
def _check(record)
# return if we run import mode
return true if Setting.get('import_mode')
# we need to fetch the a new instance of the record
# from the DB instead of using `record.reload`
# because Ticket#reload clears the ActiveMode::Dirty state
# state of the record instance which leads to empty
# Ticket#saved_changes (etc.) results in other callbacks
# later in the chain
updated_ticket = Ticket.find_by(id: record.id)
return true if !updated_ticket
updated_ticket.escalation_calculation
end
end

View file

@ -3,6 +3,7 @@
class Sla < ApplicationModel
include ChecksClientNotification
include ChecksConditionValidation
include HasEscalationCalculationImpact
include Sla::Assets
@ -10,4 +11,21 @@ class Sla < ApplicationModel
store :data
validates :name, presence: true
belongs_to :calendar, optional: true
def condition_matches?(ticket)
query_condition, bind_condition, tables = Ticket.selector2sql(condition)
Ticket.where(query_condition, *bind_condition).joins(tables).exists?(ticket.id)
end
def self.for_ticket(ticket)
fallback = nil
all.order(:name, :created_at).find_each do |record|
if record.condition.present?
return record if record.condition_matches?(ticket)
else
fallback = record
end
end
fallback
end
end

View file

@ -16,12 +16,12 @@ class Ticket < ApplicationModel
include HasObjectManagerAttributesValidation
include HasTaskbars
include Ticket::Escalation
include Ticket::Subject
include Ticket::Assets
include Ticket::SearchIndex
include Ticket::Search
include Ticket::MergeHistory
include ::Ticket::Escalation
include ::Ticket::Subject
include ::Ticket::Assets
include ::Ticket::SearchIndex
include ::Ticket::Search
include ::Ticket::MergeHistory
store :preferences
before_create :check_generate, :check_defaults, :check_title, :set_default_state, :set_default_priority
@ -1334,6 +1334,24 @@ result
Ticket::Article.where(ticket_id: id).order(:created_at, :id)
end
# Get whichever #last_contact_* was later
# This is not identical to #last_contact_at
# It returns time to last original (versus follow up) contact
# @return [Time, nil]
def last_original_update_at
[last_contact_agent_at, last_contact_customer_at].compact.max
end
# true if conversation did happen and agent responded
# false if customer is waiting for response or agent reached out and customer did not respond yet
# @return [Bool]
def agent_responded?
return false if last_contact_customer_at.blank?
return false if last_contact_agent_at.blank?
last_contact_customer_at < last_contact_agent_at
end
private
def check_generate

View file

@ -10,6 +10,7 @@ class Ticket::Article < ApplicationModel
include HasObjectManagerAttributesValidation
include Ticket::Article::Assets
include Ticket::Article::HasTicketContactAttributesImpact
belongs_to :ticket, optional: true
has_one :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', foreign_key: :ticket_article_id, dependent: :destroy, inverse_of: :ticket_article

View file

@ -1,124 +1,131 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class Observer::Ticket::ArticleChanges < ActiveRecord::Observer
observe 'ticket::_article'
module Ticket::Article::HasTicketContactAttributesImpact
extend ActiveSupport::Concern
def after_create(record)
changed = false
if article_count_update(record)
changed = true
end
if first_response_at_update(record)
changed = true
end
if sender_type_update(record)
changed = true
end
if last_contact_update_at(record)
changed = true
end
# save ticket
if !changed
record.ticket.touch # rubocop:disable Rails/SkipsModelValidations
return
end
record.ticket.save!
included do
after_create :update_ticket_article_attributes
after_destroy :update_ticket_count_attribute
end
def after_destroy(record)
private
def update_ticket_article_attributes
changed = false
if article_count_update(record)
if article_count_update
changed = true
end
if first_response_at_update
changed = true
end
if sender_type_update
changed = true
end
if last_contact_update_at
changed = true
end
# save ticket
if !changed
record.ticket.touch # rubocop:disable Rails/SkipsModelValidations
ticket.touch # rubocop:disable Rails/SkipsModelValidations
return
end
record.ticket.save!
ticket.save!
end
def update_ticket_count_attribute
changed = false
if article_count_update
changed = true
end
# save ticket
if !changed
ticket.touch # rubocop:disable Rails/SkipsModelValidations
return
end
ticket.save!
end
# get article count
def article_count_update(record)
current_count = record.ticket.article_count
def article_count_update
current_count = ticket.article_count
sender = Ticket::Article::Sender.lookup(name: 'System')
count = Ticket::Article.where(ticket_id: record.ticket_id).where.not(sender_id: sender.id).count
count = Ticket::Article.where(ticket_id: ticket_id).where.not(sender_id: sender.id).count
return false if current_count == count
record.ticket.article_count = count
ticket.article_count = count
true
end
# set first response
def first_response_at_update(record)
def first_response_at_update
# return if we run import mode
return false if Setting.get('import_mode')
# if article in internal
return false if record.internal
return false if internal
# if sender is not agent
sender = Ticket::Article::Sender.lookup(id: record.sender_id)
sender = Ticket::Article::Sender.lookup(id: sender_id)
return false if sender.name != 'Agent'
# if article is a message to customer
type = Ticket::Article::Type.lookup(id: record.type_id)
type = Ticket::Article::Type.lookup(id: type_id)
return false if !type.communication
# check if first_response_at is already set
return false if record.ticket.first_response_at
return false if ticket.first_response_at
# set first_response_at
record.ticket.first_response_at = record.created_at
ticket.first_response_at = created_at
true
end
# set sender type
def sender_type_update(record)
def sender_type_update
# ignore if create channel is already set
count = Ticket::Article.where(ticket_id: record.ticket_id).count
count = Ticket::Article.where(ticket_id: ticket_id).count
return false if count > 1
record.ticket.create_article_type_id = record.type_id
record.ticket.create_article_sender_id = record.sender_id
ticket.create_article_type_id = type_id
ticket.create_article_sender_id = sender_id
true
end
# set last contact
def last_contact_update_at(record)
def last_contact_update_at
# if article in internal
return false if record.internal
return false if internal
# if sender is system
sender = Ticket::Article::Sender.lookup(id: record.sender_id)
sender = Ticket::Article::Sender.lookup(id: sender_id)
return false if sender.name == 'System'
# if article is a message to customer
return false if !Ticket::Article::Type.lookup(id: record.type_id).communication
return false if !Ticket::Article::Type.lookup(id: type_id).communication
# if sender is customer
sender = Ticket::Article::Sender.lookup(id: record.sender_id)
ticket = record.ticket
sender = Ticket::Article::Sender.lookup(id: sender_id)
ticket = self.ticket
if sender.name == 'Customer'
# in case, update last_contact_customer_at on any customer follow-up
if Setting.get('ticket_last_contact_behaviour') == 'based_on_customer_reaction'
# set last_contact_at customer
record.ticket.last_contact_customer_at = record.created_at
self.ticket.last_contact_customer_at = created_at
# set last_contact
record.ticket.last_contact_at = record.created_at
self.ticket.last_contact_at = created_at
return true
end
@ -134,10 +141,10 @@ class Observer::Ticket::ArticleChanges < ActiveRecord::Observer
ticket.last_contact_agent_at.to_i > ticket.last_contact_customer_at.to_i
# set last_contact_at customer
record.ticket.last_contact_customer_at = record.created_at
self.ticket.last_contact_customer_at = created_at
# set last_contact
record.ticket.last_contact_at = record.created_at
self.ticket.last_contact_at = created_at
end
return true
end
@ -146,11 +153,12 @@ class Observer::Ticket::ArticleChanges < ActiveRecord::Observer
return false if sender.name != 'Agent'
# set last_contact_agent_at
record.ticket.last_contact_agent_at = record.created_at
self.ticket.last_contact_agent_at = created_at
# set last_contact
record.ticket.last_contact_at = record.created_at
self.ticket.last_contact_at = created_at
true
end
end

View file

@ -1,5 +1,12 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
require_dependency 'escalation'
module Ticket::Escalation
extend ActiveSupport::Concern
included do
after_commit :update_escalation_information
end
=begin
@ -14,14 +21,9 @@ returns
=end
def self.rebuild_all
state_list_open = Ticket::State.by_category(:open)
ActiveSupport::Deprecation.warn("Method 'rebuild_all' is deprecated. Run `TicketEscalationRebuildJob.perform_now` instead")
ticket_ids = Ticket.where(state_id: state_list_open).limit(20_000).pluck(:id)
ticket_ids.each do |ticket_id|
next if !Ticket.exists?(ticket_id)
Ticket.find(ticket_id).escalation_calculation(true)
end
TicketEscalationRebuildJob.perform_now
end
=begin
@ -38,378 +40,24 @@ returns
=end
def escalation_calculation(force = false)
return if !escalation_calculation_int(force)
self.callback_loop = true
save!
self.callback_loop = false
true
end
def escalation_calculation_int(force = false)
return if callback_loop == true
# return if we run import mode
return if Setting.get('import_mode') && !Setting.get('import_ignore_sla')
# set escalation off if current state is not escalation relative (e.g. ticket is closed)
return if !state_id
state = Ticket::State.lookup(id: state_id)
escalation_disabled = false
if state.ignore_escalation?
escalation_disabled = true
# early exit if nothing current state is not escalation relative
if !force
return false if escalation_at.nil?
self.escalation_at = nil
if preferences['escalation_calculation']
preferences['escalation_calculation']['escalation_disabled'] = escalation_disabled
end
return true
end
end
# get sla for ticket
calendar = nil
sla = escalation_calculation_get_sla
if sla
calendar = sla.calendar
end
# if no escalation is enabled
if !sla || !calendar
# nothing to change
return false if !escalation_at && !first_response_escalation_at && !update_escalation_at && !close_escalation_at
preferences['escalation_calculation'] = {}
self.escalation_at = nil
self.first_response_escalation_at = nil
self.escalation_at = nil
self.update_escalation_at = nil
self.close_escalation_at = nil
if preferences['escalation_calculation']
preferences['escalation_calculation']['escalation_disabled'] = escalation_disabled
end
return true
end
# get last_update_at
# rubocop:disable Lint/DuplicateBranch
if !last_contact_customer_at && !last_contact_agent_at
last_update_at = created_at
elsif !last_contact_customer_at && last_contact_agent_at
last_update_at = last_contact_agent_at
elsif last_contact_customer_at && !last_contact_agent_at
last_update_at = last_contact_customer_at
elsif last_contact_agent_at > last_contact_customer_at
last_update_at = last_contact_agent_at
elsif last_contact_agent_at < last_contact_customer_at
last_update_at = last_contact_customer_at
end
# rubocop:enable Lint/DuplicateBranch
# check if calculation need be done
escalation_calculation = preferences[:escalation_calculation] || {}
sla_changed = true
if escalation_calculation['sla_id'] == sla.id && escalation_calculation['sla_updated_at'] == sla.updated_at
sla_changed = false
end
calendar_changed = true
if escalation_calculation['calendar_id'] == calendar.id && escalation_calculation['calendar_updated_at'] == calendar.updated_at
calendar_changed = false
end
if sla_changed == true || calendar_changed == true
force = true
end
first_response_at_changed = true
if escalation_calculation['first_response_at'] == first_response_at
first_response_at_changed = false
end
last_update_at_changed = true
if escalation_calculation['last_update_at'] == last_update_at && !saved_change_to_attribute('state_id')
last_update_at_changed = false
end
close_at_changed = true
if escalation_calculation['close_at'] == close_at
close_at_changed = false
end
if !force &&
preferences[:escalation_calculation] &&
first_response_at_changed == false &&
last_update_at_changed == false &&
close_at_changed == false &&
sla_changed == false &&
calendar_changed == false &&
escalation_calculation['escalation_disabled'] == escalation_disabled
return false
end
# reset escalation attributes
self.escalation_at = nil
if force == true
self.first_response_escalation_at = nil
self.update_escalation_at = nil
self.close_escalation_at = nil
end
biz = calendar.biz
# get history data
history_data = nil
# calculate first response escalation
if force == true || first_response_at_changed == true
if !history_data
history_data = history_get
end
if sla.first_response_time
self.first_response_escalation_at = destination_time(created_at, sla.first_response_time, biz, history_data)
end
# get response time in min
if first_response_at
self.first_response_in_min = period_working_minutes(created_at, first_response_at, biz, history_data)
end
# set time to show if sla is raised or not
if sla.first_response_time && first_response_in_min
self.first_response_diff_in_min = sla.first_response_time - first_response_in_min
end
end
# calculate update time escalation
if force == true || last_update_at_changed == true
if !history_data
history_data = history_get
end
if sla.update_time && last_update_at
self.update_escalation_at = destination_time(last_update_at, sla.update_time, biz, history_data)
end
# get update time in min
if last_update_at && last_update_at != created_at
self.update_in_min = period_working_minutes(created_at, last_update_at, biz, history_data)
end
# set sla time
if sla.update_time && update_in_min
self.update_diff_in_min = sla.update_time - update_in_min
end
end
# calculate close time escalation
if force == true || close_at_changed == true
if !history_data
history_data = history_get
end
if sla.solution_time
self.close_escalation_at = destination_time(created_at, sla.solution_time, biz, history_data)
end
# get close time in min
if close_at
self.close_in_min = period_working_minutes(created_at, close_at, biz, history_data)
end
# set time to show if sla is raised or not
if sla.solution_time && close_in_min
self.close_diff_in_min = sla.solution_time - close_in_min
end
end
# set closest escalation time
if escalation_disabled
self.escalation_at = nil
else
if !first_response_at && first_response_escalation_at
self.escalation_at = first_response_escalation_at
end
if update_escalation_at && ((!escalation_at && update_escalation_at) || update_escalation_at < escalation_at)
self.escalation_at = update_escalation_at
end
if !close_at && close_escalation_at && ((!escalation_at && close_escalation_at) || close_escalation_at < escalation_at)
self.escalation_at = close_escalation_at
end
end
# remember already counted time to do on next update only the diff
preferences[:escalation_calculation] = {
first_response_at: first_response_at,
last_update_at: last_update_at,
close_at: close_at,
sla_id: sla.id,
sla_updated_at: sla.updated_at,
calendar_id: calendar.id,
calendar_updated_at: calendar.updated_at,
escalation_disabled: escalation_disabled,
}
true
end
=begin
return sla for ticket
ticket = Ticket.find(123)
result = ticket.escalation_calculation_get_sla
returns
result = selected_sla
=end
def escalation_calculation_get_sla
sla_selected = nil
sla_list = Cache.get('SLA::List::Active')
if sla_list.nil?
sla_list = Sla.all.order(:name, :created_at)
Cache.write('SLA::List::Active', sla_list, { expires_in: 1.hour })
end
sla_list.each do |sla|
if sla.condition.blank?
sla_selected = sla
elsif sla.condition
query_condition, bind_condition, tables = Ticket.selector2sql(sla.condition)
ticket = Ticket.where(query_condition, *bind_condition).joins(tables).find_by(id: id)
next if !ticket
sla_selected = sla
break
end
end
sla_selected
::Escalation.new(self, force: force).calculate!
end
private
=begin
def update_escalation_information
# return if we run import mode
return if Setting.get('import_mode')
return destination_time for time range
# return if ticket was destroyed in this transaction
return if destroyed?
destination_time = destination_time(start_time, move_minutes, biz, history_data)
return if callback_loop
returns
destination_time = Time.zone.parse('2016-08-02T11:11:11Z')
=end
def destination_time(start_time, move_minutes, biz, history_data)
local_destination_time = biz.time(move_minutes, :minutes).after(start_time)
# go step by step to end of move_minutes until move_minutes is 0
200.times.each do |_count|
# check if we have pending time in the range to the destination time
working_minutes = period_working_minutes(start_time, local_destination_time, biz, history_data, true)
move_minutes -= working_minutes
# skip if no pending time is given
break if move_minutes <= 0
# set pending destination to start time and add pending time to destination time
start_time = local_destination_time
local_destination_time = biz.time(move_minutes, :minutes).after(start_time)
end
local_destination_time
# needs to operate on a copy because otherwise caching breaks
record_copy = Ticket.find(id)
record_copy.callback_loop = true
# needs saving explicitly because this is after_commit!
record_copy.escalation_calculation
end
# get period working minutes time in minutes
def period_working_minutes(start_time, end_time, biz, history_list, add_current = false)
working_time_in_min = 0
last_state = nil
last_state_change = nil
ignore_escalation_states = Ticket::State.where(
ignore_escalation: true,
).map(&:name)
# add state changes till now
if add_current && saved_change_to_attribute('state_id') && saved_change_to_attribute('state_id')[0] && saved_change_to_attribute('state_id')[1]
last_history_state = nil
history_list.each do |history_item|
next if !history_item['attribute']
next if history_item['attribute'] != 'state'
next if history_item['id']
last_history_state = history_item
end
local_updated_at = updated_at
if saved_change_to_attribute('updated_at') && saved_change_to_attribute('updated_at')[1]
local_updated_at = saved_change_to_attribute('updated_at')[1]
end
history_item = {
'attribute' => 'state',
'created_at' => local_updated_at,
'value_from' => Ticket::State.find(saved_change_to_attribute('state_id')[0]).name,
'value_to' => Ticket::State.find(saved_change_to_attribute('state_id')[1]).name,
}
if last_history_state
last_history_state = history_item
else
history_list.push history_item
end
end
history_list.each do |history|
# ignore if it isn't a state change
next if !history['attribute']
next if history['attribute'] != 'state'
created_at = history['created_at']
# ignore all newer state before start_time
next if created_at < start_time
# ignore all older state changes after end_time
next if last_state_change && last_state_change > end_time
# if created_at is later then end_time, use end_time as last time
if created_at > end_time
created_at = end_time
end
# get initial state and time
if !last_state
last_state = history['value_from']
last_state_change = start_time
end
# check if time need to be counted
counted = true
if ignore_escalation_states.include?(history['value_from'])
counted = false
end
if counted
diff = biz.within(last_state_change, created_at).in_minutes
working_time_in_min += diff
end
# remember for next loop last state
last_state = history['value_to']
last_state_change = created_at
end
# if we have time to count after history entries has finished
if last_state_change && last_state_change < end_time
diff = biz.within(last_state_change, end_time).in_minutes
working_time_in_min += diff
end
# if we have not had any state change
if !last_state_change
diff = biz.within(start_time, end_time).in_minutes
working_time_in_min += diff
end
working_time_in_min
end
end

View file

@ -31,7 +31,6 @@ module Zammad
'observer::_ticket::_last_owner_update',
'observer::_ticket::_pending_time',
'observer::_ticket::_user_ticket_counter',
'observer::_ticket::_article_changes',
'observer::_ticket::_article::_fillup_from_origin_by_id',
'observer::_ticket::_article::_fillup_from_general',
'observer::_ticket::_article::_fillup_from_email',
@ -44,12 +43,10 @@ module Zammad
'observer::_ticket::_ref_object_touch',
'observer::_ticket::_online_notification_seen',
'observer::_ticket::_stats_reopen',
'observer::_ticket::_escalation_update',
'observer::_tag::_ticket_history',
'observer::_user::_ref_object_touch',
'observer::_user::_ticket_organization',
'observer::_user::_geo',
'observer::_sla::_ticket_rebuild_escalation',
'observer::_transaction'
config.active_job.queue_adapter = :delayed_job

237
lib/escalation.rb Normal file
View file

@ -0,0 +1,237 @@
class Escalation
attr_reader :ticket
def initialize(ticket, force: false)
@ticket = ticket
@force = force
end
def preferences
@preferences ||= Escalation::TicketPreferences.new(ticket)
end
def biz
@biz ||= calendar&.biz breaks: biz_breaks
end
def biz_breaks
@biz_breaks ||= Escalation::TicketBizBreak.new(ticket, calendar).biz_breaks
end
def escalation_disabled?
@escalation_disabled ||= Ticket::State.lookup(id: ticket.state_id).ignore_escalation?
end
def sla
@sla ||= Sla.for_ticket(ticket)
end
def calendar
@calendar ||= sla&.calendar
end
def forced?
!!@force
end
def force!
@force = true
end
def calculatable?
!escalation_disabled? || preferences.close_at_changed?(ticket)
end
def calculate!
calculate
ticket.save! if ticket.has_changes_to_save?
end
def calculate
if !calculatable? && !forced?
calculate_not_calculatable
elsif !calendar
calculate_no_calendar
elsif forced? || any_changes?
enforce_if_needed
update_escalations
update_statistics
apply_preferences
end
end
def any_changes?
preferences.any_changes?(ticket, sla, escalation_disabled?)
end
def assign_reset
ticket.assign_attributes(
escalation_at: nil,
first_response_escalation_at: nil,
update_escalation_at: nil,
close_escalation_at: nil
)
end
def calculate_not_calculatable
assign_reset
apply_preferences if !preferences.hash[:escalation_disabled]
end
def calculate_no_calendar
assign_reset
end
def apply_preferences
preferences.update_preferences(ticket, sla, escalation_disabled?)
end
def enforce_if_needed
return if !preferences.sla_changed?(sla) && !preferences.calendar_changed?(sla.calendar)
force!
end
def update_escalations
ticket.assign_attributes [escalation_first_response, escalation_update, escalation_close]
.compact
.each_with_object({}) { |elem, memo| memo.merge!(elem) }
ticket.escalation_at = calculate_next_escalation
end
def update_statistics
ticket.assign_attributes [statistics_first_response, statistics_update, statistics_close]
.compact
.each_with_object({}) { |elem, memo| memo.merge!(elem) }
end
private
# escalation
# skip escalation neither forced
# nor state switched from closed to open
def skip_escalation?
!forced? && !preferences.escalation_became_enabled?(escalation_disabled?)
end
def escalation_first_response
return if skip_escalation? && !preferences.first_response_at_changed?(ticket)
nullify = escalation_disabled? || ticket.first_response_at.present?
{
first_response_escalation_at: nullify ? nil : calculate_time(ticket.created_at, sla.first_response_time)
}
end
def escalation_update
return if skip_escalation? && !preferences.last_update_at_changed?(ticket)
nullify = escalation_disabled? || ticket.agent_responded?
timestamp = nullify ? nil : ticket.last_contact_customer_at
{
update_escalation_at: timestamp ? calculate_time(timestamp, sla.update_time) : nil
}
end
def escalation_close
return if skip_escalation? && !preferences.close_at_changed?(ticket)
nullify = escalation_disabled? || ticket.close_at.present?
{
close_escalation_at: nullify ? nil : calculate_time(ticket.created_at, sla.solution_time)
}
end
def calculate_time(start_time, span)
return if span.nil? || !span.positive?
Escalation::DestinationTime.new(start_time, span, biz).destination_time
end
def calculate_next_escalation
return if escalation_disabled?
[
(ticket.first_response_escalation_at if !ticket.first_response_at),
ticket.update_escalation_at,
(ticket.close_escalation_at if !ticket.close_at)
].compact.min
end
# statistics
def skip_statistics_first_response?
return true if !forced? && !preferences.first_response_at_changed?(ticket)
ticket.first_response_at.blank? || sla.first_response_time.blank?
end
def statistics_first_response
return if skip_statistics_first_response?
minutes = calculate_minutes(ticket.created_at, ticket.first_response_at)
{
first_response_in_min: minutes,
first_response_diff_in_min: minutes ? (sla.first_response_time - minutes) : nil
}
end
def skip_statistics_update?
return true if !forced? && !preferences.last_update_at_changed?(ticket)
return true if !sla.update_time
!ticket.agent_responded?
end
# ATTENTION: Recalculation after SLA change won't happen
# SLA change will cause wrong statistics in some edge cases.
# Since this changes `update_in_min` calculation to retain longest timespan.
# But it does not keep track of previous update times.
def statistics_update_applicable?(minutes)
ticket.update_in_min.blank? || minutes > ticket.update_in_min # keep longest timespan
end
def statistics_update
return if skip_statistics_update?
minutes = calculate_minutes(ticket.last_contact_customer_at, ticket.last_contact_agent_at)
return if !forced? && !statistics_update_applicable?(minutes)
{
update_in_min: minutes,
update_diff_in_min: minutes ? (sla.update_time - minutes) : nil
}
end
def skip_statistics_close?
return true if !forced? && !preferences.close_at_changed?(ticket)
ticket.close_at.blank? || sla.solution_time.blank?
end
def statistics_close
return if skip_statistics_close?
minutes = calculate_minutes(ticket.created_at, ticket.close_at)
{
close_in_min: minutes,
close_diff_in_min: minutes ? (sla.solution_time - minutes) : nil
}
end
def calculate_minutes(start_time, end_time)
return if !end_time || !start_time
Escalation::PeriodWorkingMinutes.new(start_time, end_time, ticket, biz).period_working_minutes
end
end

View file

@ -0,0 +1,13 @@
class Escalation
class DestinationTime
def initialize(start_time, span, biz)
@start_time = start_time
@span = span
@biz = biz
end
def destination_time
@biz.time(@span, :minutes).after(@start_time)
end
end
end

View file

@ -0,0 +1,24 @@
class Escalation
class PeriodWorkingMinutes
def initialize(start_time, end_time, ticket, biz)
@start_time = start_time
@end_time = end_time
@ticket = ticket
@biz = biz
end
def period_working_minutes
@biz.within(timeframe_start, timeframe_end).in_minutes
end
private
def timeframe_start
[@ticket.created_at, @start_time].compact.max
end
def timeframe_end
[@ticket.close_at, @end_time].compact.min
end
end
end

View file

@ -0,0 +1,78 @@
class Escalation
class TicketBizBreak
def initialize(ticket, calendar)
@ticket = ticket
@history_list = ticket.history_get
@calendar = calendar
end
def biz_breaks
accumulate_breaks(history_list_in_break.map { |from, to| history_range_to_breaks(from, to) })
end
private
def history_list_states
@history_list_states ||= @history_list.select { |elem| elem['attribute'] == 'state' }
end
def history_list_in_break
@history_list_in_break ||= begin
history_list_states
.tap { |elem| elem.unshift(mock_initial_state) if mock_initial_state }
.each_cons(2) # group in from/to pairs
.select { |from, to| range_on_break?(from, to) }
end
end
def ignored_escalation_state_names
@ignored_escalation_state_names ||= Ticket::State.where(ignore_escalation: true).map(&:name)
end
def range_on_break?(from, _to)
ignored_escalation_state_names.include? from['value_to']
end
def history_range_to_breaks(from, to)
date_from = from['created_at'].in_time_zone(@calendar.timezone)
date_to = to['created_at'].in_time_zone(@calendar.timezone)
(date_from.to_date..date_to.to_date).each_with_object({}) do |elem, memo|
key = history_range_break_key(elem, date_from)
value = history_range_break_value(elem, date_to)
memo[elem] = { key => value }
end
end
def history_range_break_key(elem, date_from)
elem == date_from.to_date ? date_from.strftime('%H:%M') : '00:00'
end
def history_range_break_value(elem, date_to)
elem == date_to.to_date ? date_to.strftime('%H:%M') : '24:00'
end
def accumulate_breaks(input)
input.each_with_object({}) do |elem, memo|
memo.deep_merge! elem
end
end
def mock_initial_state
@mock_initial_state ||= begin
first_state = history_list_states.first
# if history set right on ticket creation, no need for extra step
if first_state&.dig('created_at') == @ticket.created_at
nil
else
{
'value_to' => first_state&.dig('value_from') || @ticket.state.name, # if no history yet, use current state
'created_at' => @ticket.created_at
}
end
end
end
end
end

View file

@ -0,0 +1,71 @@
class Escalation
class TicketPreferences
KEYS = %i[escalation_disabled
first_response_at last_update_at close_at escalation_at
sla_id sla_updated_at
calendar_id calendar_updated_at].freeze
attr_reader :hash
def initialize(ticket)
@hash = {}
KEYS.each do |key|
@hash[key] = ticket.preferences.dig(:escalation_calculation, key) || nil
end
end
def escalation_became_enabled?(escalation_disabled)
!escalation_disabled && @hash[:escalation_disabled] != escalation_disabled
end
def sla_changed?(sla)
@hash[:sla_id] != sla&.id || @hash[:sla_updated_at] != sla&.updated_at
end
def calendar_changed?(calendar)
@hash[:calendar_id] != calendar&.id || @hash[:calendar_updated_at] != calendar&.updated_at
end
def first_response_at_changed?(ticket)
@hash[:first_response_at] != ticket.first_response_at
end
def last_update_at_changed?(ticket)
@hash[:last_update_at] != ticket.last_original_update_at || ticket.saved_change_to_state_id?
end
def close_at_changed?(ticket)
@hash[:close_at] != ticket.close_at
end
def property_changes?(ticket)
%i[first_response_at last_update_at close_at].any? { |elem| send("#{elem}_changed?", ticket) }
end
def any_changes?(ticket, sla, escalation_disabled)
property_changes?(ticket) || sla_changed?(sla) || calendar_changed?(sla&.calendar) || @hash[:escalation_disabled] != escalation_disabled
end
def update_preferences(ticket, sla, escalation_disabled)
new_hash = hash_of(ticket, sla, escalation_disabled)
return if new_hash == { escalation_disabled: false } && !@hash[:escalation_disabled] # do not update when update not necessary
ticket.preferences[:escalation_calculation] = new_hash
end
def hash_of(ticket, sla, escalation_disabled)
{
first_response_at: ticket.first_response_at,
last_update_at: ticket.last_original_update_at,
close_at: ticket.close_at,
sla_id: sla&.id,
sla_updated_at: sla&.updated_at,
calendar_id: sla&.calendar&.id,
calendar_updated_at: sla&.calendar&.updated_at,
escalation_disabled: escalation_disabled,
}.compact
end
end
end

View file

@ -4,43 +4,58 @@ FactoryBot.define do
timezone { 'Europe/Berlin' }
default { true }
ical_url { nil }
business_hours do
{
mon: {
active: true,
timeframes: [['09:00', '17:00']]
},
tue: {
active: true,
timeframes: [['09:00', '17:00']]
},
wed: {
active: true,
timeframes: [['09:00', '17:00']]
},
thu: {
active: true,
timeframes: [['09:00', '17:00']]
},
fri: {
active: true,
timeframes: [['09:00', '17:00']]
},
sat: {
active: false,
timeframes: [['09:00', '17:00']]
},
sun: {
active: false,
timeframes: [['09:00', '17:00']]
}
}
end
created_by_id { 1 }
updated_by_id { 1 }
transient do
public_holiday_date { nil }
end
public_holidays do
next if public_holiday_date.blank?
Array(public_holiday_date).each_with_object({}) do |elem, memo|
memo[elem.to_s] = { active: true, summary: 'public holiday trait' }
end
end
business_hours_9_17
trait :business_hours_9_17 do
business_hours do
{
mon: {
active: true,
timeframes: [['09:00', '17:00']]
},
tue: {
active: true,
timeframes: [['09:00', '17:00']]
},
wed: {
active: true,
timeframes: [['09:00', '17:00']]
},
thu: {
active: true,
timeframes: [['09:00', '17:00']]
},
fri: {
active: true,
timeframes: [['09:00', '17:00']]
},
sat: {
active: false,
timeframes: [['09:00', '17:00']]
},
sun: {
active: false,
timeframes: [['09:00', '17:00']]
}
}
end
end
trait :'24/7' do
business_hours do
{
@ -82,6 +97,12 @@ FactoryBot.define do
timeframe_alldays { ['00:00', '23:59'] }
end
trait :'9-18/7' do
business_hours_generated
timeframe_alldays { ['09:00', '18:00'] }
end
trait :business_hours_generated do
transient do
timeframe_alldays { nil }

View file

@ -2,14 +2,24 @@ FactoryBot.define do
factory :history do
transient do
o { Ticket.first }
history_type { 'update' }
history_attribute { 'state' }
end
association :history_type, factory: :'history/type'
o_id { o.id }
created_by_id { 1 }
history_type_id do
History.type_lookup(history_type).id
end
history_attribute_id do
History.attribute_lookup(history_attribute).id
end
history_object_id do
History::Object.lookup(name: o.class.name)&.id || create(:'history/object', name: o.class.name).id
History.object_lookup(o.class.name).id
end
end
end

View file

@ -13,5 +13,26 @@ FactoryBot.define do
},
}
end
trait :condition_blank do
condition do
{}
end
end
trait :condition_title do
transient do
condition_title { nil }
end
condition do
{
'ticket.title' => {
operator: 'contains',
value: condition_title
}
}
end
end
end
end

View file

@ -48,6 +48,22 @@ FactoryBot.define do
to { ticket.customer.fullname }
end
trait :outbound_note do
transient do
type_name { 'note' }
sender_name { 'Agent' }
end
from { ticket.group.name }
end
trait :inbound_web do
transient do
type_name { 'web' }
sender_name { 'Customer' }
end
end
factory :twitter_article do
transient do
type_name { 'twitter status' }

View file

@ -1,15 +0,0 @@
require 'rails_helper'
RSpec.describe SlaTicketRebuildEscalationJob, type: :job do
it 'clears the SLA Cache' do
allow(Cache).to receive(:delete)
expect(Cache).to receive(:delete).with('SLA::List::Active')
described_class.perform_now
end
it 'triggers Ticket::Escalation rebuild' do
expect(Ticket::Escalation).to receive(:rebuild_all)
described_class.perform_now
end
end

View file

@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe TicketEscalationRebuildJob, type: :job do
before do
travel_to(DateTime.parse('2013-03-21 09:30:00 UTC'))
end
context 'when relevant Ticket is present' do
subject(:ticket) { create(:ticket) }
before do
create(:sla, :condition_blank, first_response_time: 60, update_time: 120, solution_time: 180)
create(:'ticket/article', :inbound_email, ticket: ticket)
ticket.update_column(:escalation_at, 2.hours.ago)
travel(1.hour)
end
it 'en-force-es new escalation calculation' do
expect { described_class.perform_now }.to change { ticket.reload.escalation_at }
end
end
context 'when not relevant Ticket is present' do
subject(:ticket) { create(:ticket) }
before do
create(:'ticket/article', :inbound_email, ticket: ticket)
ticket.update_column(:escalation_at, 2.hours.ago)
travel(1.hour)
end
it 'does not not change escalation_at' do
expect { described_class.perform_now }.to change { ticket.reload.escalation_at }
end
end
end

View file

@ -0,0 +1,43 @@
require 'rails_helper'
RSpec.describe Escalation::DestinationTime do
let(:instance) { described_class.new start_time, span, biz }
let(:start_time) { Time.current }
let(:span) { 30 }
let(:ticket) { create(:ticket) }
let(:calendar) { create(:calendar, :'24/7') }
let(:biz) { calendar.biz breaks: Escalation::TicketBizBreak.new(ticket, calendar).biz_breaks }
describe '#destination_time' do
subject(:result) { instance.send(:destination_time) }
before do
freeze_time
ticket.update! state: Ticket::State.lookup(name: 'new')
travel 1.hour
ticket.update! state: Ticket::State.lookup(name: 'open')
travel 30.minutes
ticket.update! state: Ticket::State.lookup(name: 'pending close')
travel 30.minutes
ticket.update! state: Ticket::State.lookup(name: 'closed'), close_at: Time.current
end
context 'when whole span fits' do
let(:start_time) { ticket.created_at }
it { is_expected.to eq 90.minutes.ago }
end
context 'when timeframe starts before and ends after ticket life' do
let(:start_time) { ticket.created_at + 75.minutes }
it { is_expected.to eq 15.minutes.from_now }
end
context 'when timeframe starts in the middle of ticket life' do
let(:start_time) { Time.current }
it { is_expected.to eq 30.minutes.from_now }
end
end
end

View file

@ -0,0 +1,97 @@
require 'rails_helper'
RSpec.describe Escalation::PeriodWorkingMinutes do
let(:instance) { described_class.new start_time, end_time, ticket, biz }
let(:start_time) { 1.week.ago }
let(:end_time) { 1.day.ago }
let(:ticket) { create(:ticket) }
let(:calendar) { create(:calendar, :'24/7') }
let(:biz) { calendar.biz breaks: Escalation::TicketBizBreak.new(ticket, calendar).biz_breaks }
describe '#period_working_minutes' do
subject(:result) { instance.send(:period_working_minutes) }
before do
freeze_time
ticket.update! state: Ticket::State.lookup(name: 'new')
travel 1.hour
ticket.update! state: Ticket::State.lookup(name: 'open')
travel 30.minutes
ticket.update! state: Ticket::State.lookup(name: 'pending close')
travel 30.minutes
ticket.update! state: Ticket::State.lookup(name: 'closed'), close_at: Time.current
end
context 'when timeframe takes whole ticket life' do
let(:start_time) { ticket.created_at }
let(:end_time) { ticket.close_at + 2.hours }
it { is_expected.to be 90 }
end
context 'when timeframe starts before and ends after ticket life' do
let(:start_time) { ticket.created_at - 1.week }
let(:end_time) { ticket.close_at + 1.week }
it { is_expected.to be 90 }
end
context 'when timeframe starts in the middle of ticket life' do
let(:start_time) { ticket.created_at + 30.minutes }
let(:end_time) { ticket.close_at }
it { is_expected.to be 60 }
end
context 'when timeframe end in the middle of ticket life' do
let(:start_time) { ticket.created_at }
let(:end_time) { ticket.created_at + 30.minutes }
it { is_expected.to be 30 }
end
end
describe '#timeframe_start' do
subject(:result) { instance.send(:timeframe_start) }
context 'when start_time is early' do
it { is_expected.to eq ticket.created_at }
end
context 'when start_time is later than #created_at' do
let(:start_time) { 1.week.from_now }
it { is_expected.to eq start_time }
end
end
describe '#timeframe_end' do
subject(:result) { instance.send(:timeframe_end) }
context 'when end_time is late' do
let(:end_time) { 1.week.from_now }
before do
ticket.update! close_at: 1.day.from_now
end
it { is_expected.to eq ticket.close_at }
end
context 'when end_time is before closing' do
let(:end_time) { 1.week.from_now }
before do
ticket.update! close_at: 2.weeks.from_now
end
it { is_expected.to eq end_time }
end
context 'when #close_at is not set' do
let(:end_time) { 1.week.ago }
it { is_expected.to eq end_time }
end
end
end

View file

@ -0,0 +1,313 @@
require 'rails_helper'
RSpec.describe Escalation::TicketBizBreak, time_zone: 'Europe/Berlin' do
let(:ticket) { create(:ticket) }
let(:calendar) { create(:calendar) }
let(:instance) { described_class.new(ticket, calendar) }
describe '#biz_breaks' do
let(:result) { instance.biz_breaks }
context 'when ticket history is empty' do
it { expect(result).to be_a(Hash) }
it { expect(result).to be_empty }
end
context 'when ticket is opened' do
before do
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'open')
end
it { expect(result).to be_a(Hash) }
it { expect(result).to be_empty }
end
context 'when ticket was opened and closed' do
before do
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'open')
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'closed')
end
it { expect(result).to be_a(Hash) }
it { expect(result).to be_empty }
end
context 'when ticket was started in non-escalated state and closed' do
let(:ticket) { create(:ticket, state: Ticket::State.lookup(name: 'pending reminder')) }
before do
travel_to Time.current.noon
ticket
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'closed')
end
it { expect(result).to be_a(Hash) }
it { expect(result).to be_one }
end
context 'when ticket was suspended and reopened multiple times' do
before do
travel_to Time.current.noon
ticket.update! state: Ticket::State.lookup(name: 'open')
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'pending reminder')
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'open')
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'pending close')
travel 15.minutes
ticket.update! state: Ticket::State.lookup(name: 'closed')
end
let(:first_value) { result.values.first }
it { expect(result).to be_a(Hash) }
it { expect(result.keys).to be_one }
it { expect(result.keys.first).to be_a(Date) }
it { expect(result.keys.first).to eq(Time.current.to_date) }
it { expect(first_value).to be_a(Hash) }
it { expect(first_value.keys).to eq %w[12:15 12:45] }
it { expect(first_value['12:15']).to eq '12:30' }
it { expect(first_value['12:45']).to eq '13:00' }
end
context 'when ticket was suspended over midnight in UTC', time_zone: 'UTC' do
before do
travel_to Time.current.change(month: 11).utc.midnight
travel(-15.minutes)
ticket.update! state: Ticket::State.lookup(name: 'pending reminder')
travel 30.minutes
ticket.update! state: Ticket::State.lookup(name: 'open')
end
let(:first_value) { result.values.first }
it { expect(result.keys).to be_one }
it { expect(result.keys.first).to be_a(Date) }
it { expect(result.keys.first).to eq(Time.current.to_date) }
it { expect(first_value).to be_a(Hash) }
it { expect(first_value.keys).to eq %w[00:45] }
it { expect(first_value['00:45']).to eq '01:15' }
end
context 'when ticket was suspended over midnight in timezone' do
before do
travel_to Time.current.midnight
travel(-15.minutes)
ticket.update! state: Ticket::State.lookup(name: 'pending reminder')
travel 1.hour
ticket.update! state: Ticket::State.lookup(name: 'open')
end
let(:first_value) { result.values.first }
let(:second_value) { result.values.second }
it { expect(result.keys.count).to be(2) }
it { expect(result.keys).to eq [Time.current.yesterday.to_date, Time.current.to_date] }
it { expect(first_value).to be_a(Hash) }
it { expect(first_value.keys).to eq %w[23:45] }
it { expect(first_value['23:45']).to eq '24:00' }
it { expect(second_value).to be_a(Hash) }
it { expect(second_value.keys).to eq %w[00:00] }
it { expect(second_value['00:00']).to eq '00:45' }
end
context 'when ticket was suspended for multiple days' do
before do
travel_to Time.current.noon
ticket.update! state: Ticket::State.lookup(name: 'pending reminder')
travel 5.days
ticket.update! state: Ticket::State.lookup(name: 'open')
end
let(:first_value) { result.values.first }
let(:second_value) { result.values.second }
it { expect(result.keys.count).to be(6) }
it { expect(result.keys).to eq ((Time.current - 5.days).to_date..Time.current).to_a }
it { expect(result.values[0].keys).to eq %w[12:00] }
it { expect(result.values[0]['12:00']).to eq '24:00' }
it { expect(result.values[1].keys).to eq %w[00:00] }
it { expect(result.values[1]['00:00']).to eq '24:00' }
it { expect(result.values[2].keys).to eq %w[00:00] }
it { expect(result.values[2]['00:00']).to eq '24:00' }
it { expect(result.values[3].keys).to eq %w[00:00] }
it { expect(result.values[3]['00:00']).to eq '24:00' }
it { expect(result.values[4].keys).to eq %w[00:00] }
it { expect(result.values[4]['00:00']).to eq '24:00' }
it { expect(result.values[5].keys).to eq %w[00:00] }
it { expect(result.values[5]['00:00']).to eq '12:00' }
end
end
describe '#history_list_states' do
let(:result) { instance.send(:history_list_states) }
it 'empty when history log is empty' do
expect(result).to be_empty
end
it 'empty when history log has non-state records' do
ticket.update! title: '2nd title'
expect(result).to be_empty
end
it 'returns array of Hashes when history log has various records' do
ticket.update! title: '2nd title', state: Ticket::State.lookup(name: 'open')
expect(result.first).to be_a Hash
end
it 'lists changes in specific order when history log has various records' do
ticket.update! title: 'title', state: Ticket::State.lookup(name: 'open')
ticket.update! title: 'another title'
ticket.update! state: Ticket::State.lookup(name: 'closed')
expect(result.pluck('value_to')).to eq %w[open closed]
end
end
describe '#ignored_escalation_state_names' do
let(:result) { instance.send(:ignored_escalation_state_names) }
it { expect(result).to be_a Array }
it { expect(result).to be_all String }
it { expect(result).to include 'closed' }
it { expect(result).not_to include 'open' }
end
describe '#history_list_in_break' do
let(:result) { instance.send(:history_list_in_break) }
it { expect(result).to be_a Array }
it 'empty history returns minutes in timeframe' do
expect(result).to be_empty
end
context 'when contains 4 history points' do
before do
allow(instance).to receive(:history_list_states).and_return(history_list_4)
end
let(:history_list_4) do
[
mock_state_hash(ticket.created_at, nil, 'new'),
mock_state_hash(ticket.created_at + 1.hour, 'new', 'open'),
mock_state_hash(ticket.created_at + 90.minutes, 'open', 'pending close'),
mock_state_hash(ticket.created_at + 2.hours, 'pending close', 'closed')
]
end
it 'returns one range' do
expect(result).to be_one
end
it 'returns range from pending close' do
expect(result.first.first['value_to']).to eq 'pending close'
end
it 'returns range to closed' do
expect(result.first.second['value_to']).to eq 'closed'
end
it 'calls #range_on_break? thrice' do
allow(instance).to receive(:range_on_break?)
result
expect(instance).to have_received(:range_on_break?).exactly(3).times
end
end
end
describe '#accumulate_breaks' do
let(:input_a) { { Time.current.to_date => { '10:00' => '14:00' }, Time.current.tomorrow.to_date => { '10:00' => '14:05' } } }
let(:input_b) { { Time.current.to_date => { '17:00' => '18:00' } } }
let(:result) { instance.send(:accumulate_breaks, [input_a, input_b]) }
it { expect(result.keys).to eq [Time.current.to_date, Time.current.tomorrow.to_date] }
it { expect(result[Time.current.to_date]).to eq({ '10:00' => '14:00', '17:00' => '18:00' }) }
it { expect(result[Time.current.tomorrow.to_date]).to eq({ '10:00' => '14:05' }) }
end
describe '#history_range_to_breaks' do
before { travel_to Time.current.noon }
let(:result) { instance.send(:history_range_to_breaks, history_from, history_to) }
context 'when fits in a single day' do
let(:history_from) { mock_state_hash(ticket.created_at + 90.minutes, 'open', 'pending close') }
let(:history_to) { mock_state_hash(ticket.created_at + 2.hours, 'pending close', 'closed') }
it { expect(result).to be_a Hash }
it { expect(result.keys).to eq [Time.current.to_date] }
it { expect(result.values.first).to eq({ '13:30' => '14:00' }) }
end
context 'when spills over to multiple days' do
let(:history_from) { mock_state_hash(ticket.created_at + 90.minutes, 'open', 'pending close') }
let(:history_to) { mock_state_hash(ticket.created_at + 2.days, 'pending close', 'closed') }
it { expect(result).to be_a Hash }
it { expect(result.keys).to eq [Time.current.to_date, Time.current.tomorrow.to_date, (Time.current + 2.days).to_date] }
it { expect(result.values.first).to eq({ '13:30' => '24:00' }) }
it { expect(result.values.second).to eq({ '00:00' => '24:00' }) }
it { expect(result.values.third).to eq({ '00:00' => '12:00' }) }
end
end
describe '#mock_initial_state' do
let(:result) { instance.send(:mock_initial_state) }
it { expect(result).to have_key('created_at').and(have_key('value_to')) }
context 'when ticket has no history' do
it { expect(result).to include('created_at' => ticket.created_at) }
it { expect(result).to include('value_to' => ticket.state.name) }
end
shared_context 'when ticket has state changes' do
let(:initial_state_name) { 'pending reminder' }
let(:ticket) { create :ticket, state: Ticket::State.lookup(name: initial_state_name) }
before do
freeze_time
ticket
travel timespan
ticket.update! state: Ticket::State.lookup(name: 'closed')
end
end
context 'when ticket has state changes later' do
let(:timespan) { 30.minutes }
include_examples 'when ticket has state changes'
it { expect(result).to include('created_at' => ticket.created_at) }
it { expect(result).to include('value_to' => initial_state_name) }
end
context 'when ticket has state changes at creation' do
let(:timespan) { 0.minutes }
include_examples 'when ticket has state changes'
it { expect(result).to be_nil }
end
end
def mock_state_hash(time, from, to)
{
'attribute' => 'state',
'created_at' => time,
'value_from' => from,
'value_to' => to
}
end
end

View file

@ -0,0 +1,256 @@
require 'rails_helper'
RSpec.describe Escalation::TicketPreferences do
let(:instance) { described_class.new ticket }
let(:ticket) { create(:ticket) }
describe '#sla_changed?' do
it 'false when using same sla' do
sla = create(:sla)
instance.hash[:sla_id] = sla.id
instance.hash[:sla_updated_at] = sla.updated_at
expect(instance).not_to be_sla_changed(sla)
end
it 'true when using another sla' do
sla = create(:sla)
sla2 = create(:sla)
instance.hash[:sla_id] = sla.id
instance.hash[:sla_updated_at] = sla.updated_at
expect(instance).to be_sla_changed(sla2)
end
it 'true when using updated sla' do
sla = create(:sla)
instance.hash[:sla_id] = sla.id
instance.hash[:sla_updated_at] = sla.updated_at
sla.touch
expect(instance).to be_sla_changed(sla)
end
it 'doe not fail given nil' do
expect { instance.sla_changed?(nil) }.not_to raise_error
end
end
describe '#calendar_changed?' do
it 'false when using same calendar' do
calendar = create(:calendar)
instance.hash[:calendar_id] = calendar.id
instance.hash[:calendar_updated_at] = calendar.updated_at
expect(instance).not_to be_calendar_changed(calendar)
end
it 'true when using another calendar' do
calendar = create(:calendar)
calendar2 = create(:calendar)
instance.hash[:calendar_id] = calendar.id
instance.hash[:calendar_updated_at] = calendar.updated_at
expect(instance).to be_calendar_changed(calendar2)
end
it 'true when using updated calendar' do
calendar = create(:calendar)
instance.hash[:calendar_id] = calendar.id
instance.hash[:calendar_updated_at] = calendar.updated_at
calendar.touch
expect(instance).to be_calendar_changed(calendar)
end
it 'doe not fail given nil' do
expect { instance.calendar_changed?(nil) }.not_to raise_error
end
end
describe '#first_response_at_changed?' do
before { freeze_time }
it 'true when changed' do
instance.hash[:first_response_at] = 1.day.ago
ticket.update! first_response_at: 1.week.ago
expect(instance).to be_first_response_at_changed(ticket)
end
it 'false when matching' do
instance.hash[:first_response_at] = 7.days.ago
ticket.update! first_response_at: 1.week.ago
expect(instance).not_to be_first_response_at_changed(ticket)
end
end
describe '#last_update_at_changed?' do
before { freeze_time }
it 'true when changed' do
instance.hash[:last_update_at] = 1.day.ago
ticket.update! last_contact_customer_at: 1.week.ago
expect(instance).to be_last_update_at_changed(ticket)
end
it 'false when matching' do
instance.hash[:last_update_at] = 7.days.ago
ticket.update! last_contact_customer_at: 1.week.ago
expect(instance).not_to be_last_update_at_changed(ticket)
end
end
describe '#close_at_changed?' do
before { freeze_time }
it 'true when changed' do
instance.hash[:close_at] = 1.day.ago
ticket.update! close_at: 1.week.ago
expect(instance).to be_close_at_changed(ticket)
end
it 'false when matching' do
instance.hash[:close_at] = 7.days.ago
ticket.update! close_at: 1.week.ago
expect(instance).not_to be_close_at_changed(ticket)
end
end
describe '#property_changes?' do
before { freeze_time }
it 'false when no changes' do
ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).not_to be_property_changes(ticket)
end
it 'true when #first_response_at changes' do
ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 4.days.ago, close_at: 1.day.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).to be_property_changes(ticket)
end
it 'true when last_update_at changes' do
ticket.update! last_contact_customer_at: 2.weeks.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).to be_property_changes(ticket)
end
it 'true when #close_at changes' do
ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 2.days.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).to be_property_changes(ticket)
end
end
describe '#any_changes?' do
let(:sla) { create(:sla) }
before do
freeze_time
sla
instance.hash[:sla_id] = sla.id
instance.hash[:sla_updated_at] = sla.updated_at
instance.hash[:calendar_id] = sla.calendar.id
instance.hash[:calendar_updated_at] = sla.calendar.updated_at
end
it 'false when no changes' do
instance.hash[:escalation_disabled] = false
ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).not_to be_any_changes(ticket, sla, false)
end
it 'true when time property changed' do
instance.hash[:escalation_disabled] = false
ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 2.days.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).to be_any_changes(ticket, sla, false)
end
it 'true when sla changes' do
sla2 = create(:sla)
instance.hash[:escalation_disabled] = false
ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).to be_any_changes(ticket, sla2, false)
end
it 'true when escalability status changes' do
instance.hash[:escalation_disabled] = true
ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago
expect(instance).to be_any_changes(ticket, sla, false)
end
end
describe '#update_preferences' do
it 'sets escalation_calculation in ticket preferences' do
response = :spec
allow(instance).to receive(:hash_of).and_return(response)
instance.update_preferences(ticket, nil, false)
expect(ticket.preferences[:escalation_calculation]).to eq response
end
end
describe '#hash_of' do
let(:last_contact) { 6.days.ago }
let(:first_response_at) { 1.week.ago }
let(:close_at) { 1.day.ago }
let(:sla) { create(:sla, updated_at: 1.week.from_now) }
let(:escalation_disabled) { false }
let(:result) { instance.hash_of(ticket, sla, escalation_disabled) }
before do
freeze_time
sla
ticket.update! last_contact_customer_at: last_contact, first_response_at: first_response_at, close_at: close_at
end
it { expect(result).to be_a(Hash) }
it { expect(result[:first_response_at]).to eq ticket.first_response_at }
it { expect(result[:last_update_at]).to eq last_contact }
it { expect(result[:close_at]).to eq ticket.close_at }
it { expect(result[:sla_id]).to eq sla.id }
it { expect(result[:sla_updated_at]).to eq sla.updated_at }
it { expect(result[:calendar_id]).to eq sla.calendar.id }
it { expect(result[:calendar_updated_at]).to eq sla.calendar.updated_at }
it { expect(result[:escalation_disabled]).to eq escalation_disabled }
context 'when sla not given' do
let(:sla) { nil }
it 'sla and calendar meta data are not included' do
expect(result.keys).to eq %i[first_response_at last_update_at close_at escalation_disabled]
end
end
end
end

511
spec/lib/escalation_spec.rb Normal file
View file

@ -0,0 +1,511 @@
require 'rails_helper'
RSpec.describe ::Escalation do
let(:instance) { described_class.new ticket, force: force }
let(:instance_with_history) { described_class.new ticket_with_history, force: force }
let(:ticket) { create(:ticket) }
let(:force) { false }
let(:sla) { nil }
let(:sla_247) { create(:sla, :condition_blank, first_response_time: 60, update_time: 60, solution_time: 75, calendar: create(:calendar, :'24/7')) }
let(:calendar) { nil }
let(:ticket_with_history) do
freeze_time
ticket = create(:ticket)
ticket.update! state: Ticket::State.lookup(name: 'new')
travel 1.hour
ticket.update! state: Ticket::State.lookup(name: 'open')
travel 30.minutes
ticket.update! state: Ticket::State.lookup(name: 'pending close')
travel 30.minutes
ticket.update! state: Ticket::State.lookup(name: 'closed'), close_at: Time.current
ticket
end
let(:open_ticket_with_history) do
freeze_time
article = create(:ticket_article, :inbound_email)
travel 10.minutes
article.ticket.update! state: Ticket::State.lookup(name: 'pending close')
travel 10.minutes
article.ticket.update! state: Ticket::State.lookup(name: 'open')
article.ticket
end
describe '#preferences' do
it { expect(instance.preferences).to be_a Escalation::TicketPreferences }
end
describe '#escalation_disabled?' do
it 'true when ticket is not open' do
ticket.update! state: Ticket::State.lookup(name: 'pending close')
expect(instance).to be_escalation_disabled
end
it 'false when ticket is open' do
expect(instance).not_to be_escalation_disabled
end
end
describe '#calculatable?' do
it 'false when ticket is not open' do
ticket.update! state: Ticket::State.lookup(name: 'pending close')
expect(instance).not_to be_calculatable
end
it 'true when ticket is open' do
expect(instance).to be_calculatable
end
# https://github.com/zammad/zammad/issues/2579
it 'true when ticket was just closed' do
ticket
travel 30.minutes
without_update_escalation_information_callback { ticket.update close_at: Time.current, state: Ticket::State.lookup(name: 'closed') }
expect(instance).to be_calculatable
end
end
describe '#calculate' do
it 'works and updates' do
ticket
sla_247
expect { instance.calculate }.to change(ticket, :has_changes_to_save?).to(true)
end
it 'exit early when escalation is disabled' do
allow(instance).to receive(:escalation_disabled?).and_return(true)
allow(instance).to receive(:calendar) # next method called after checking escalation state
instance.calculate
expect(instance).not_to have_received(:calendar)
end
it 'recalculate when escalation is disabled but it is forced' do
instance_forced = described_class.new ticket, force: true
allow(instance_forced).to receive(:escalation_disabled?).and_return(true)
allow(instance_forced).to receive(:calendar) # next method called after checking escalation state
instance_forced.calculate
expect(instance_forced).to have_received(:calendar)
end
it 'no calendar is early exit' do
allow(instance).to receive(:calendar).and_return(nil)
allow(instance.preferences).to receive(:any_changes?) # next method after the check
instance.calculate
expect(instance.preferences).not_to have_received(:any_changes?)
end
it 'no calendar resets' do
allow(instance).to receive(:calendar).and_return(nil)
allow(instance).to receive(:forced?).and_return(true)
allow(instance).to receive(:calculate_no_calendar)
instance.calculate
expect(instance).to have_received(:calculate_no_calendar)
end
context 'with SLA 24/7' do
before { sla_247 }
it 'forces recalculation when SLA touched' do
allow(instance.preferences).to receive(:sla_changed?).and_return(true)
allow(instance).to receive(:force!)
instance.calculate
expect(instance).to have_received(:force!)
end
it 'calculates when ticket was touched in a related manner' do
allow(instance.preferences).to receive(:any_changes?).and_return(true)
allow(instance).to receive(:update_escalations)
instance.calculate
expect(instance).to have_received(:update_escalations)
end
it 'skips calculating escalation times when ticket was not touched in a related manner' do
allow(instance.preferences).to receive(:any_changes?).and_return(false)
allow(instance).to receive(:update_escalations)
instance.calculate
expect(instance).not_to have_received(:update_escalations)
end
it 'calculates statistics when ticket was touched in a related manner' do
allow(instance.preferences).to receive(:any_changes?).and_return(true)
allow(instance).to receive(:update_statistics)
instance.calculate
expect(instance).to have_received(:update_statistics)
end
it 'skips calculating statistics when ticket was not touched in a related manner' do
allow(instance.preferences).to receive(:any_changes?).and_return(false)
allow(instance).to receive(:update_statistics)
instance.calculate
expect(instance).not_to have_received(:update_statistics)
end
it 'setting #first_response_at does not nullify other escalations' do
ticket.update! first_response_at: 30.minutes.from_now
expect(ticket.reload.close_escalation_at).not_to be_nil
end
it 'setting ticket to non-escalatable state clears #escalation_at' do
ticket.update! state: Ticket::State.lookup(name: 'closed')
expect(ticket.escalation_at).to be_nil
end
# https://github.com/zammad/zammad/issues/2579
it 'calculates closing statistics on closing ticket' do
ticket
travel 30.minutes
without_update_escalation_information_callback { ticket.update close_at: Time.current, state: Ticket::State.lookup(name: 'closed') }
expect { instance.calculate }.to change(ticket, :close_in_min).from(nil)
end
end
end
describe '#force!' do
it 'sets forced? to true' do
expect { instance.send(:force!) }.to change(instance, :forced?).from(false).to(true)
end
end
describe 'calculate_not_calculatable' do
it 'sets escalation dates to nil' do
sla_247
open_ticket_with_history
instance = described_class.new open_ticket_with_history
instance.calculate_not_calculatable
expect(open_ticket_with_history).to have_attributes(escalation_at: nil, first_response_escalation_at: nil, update_escalation_at: nil, close_escalation_at: nil)
end
end
describe '#sla' do
it 'returns SLA when it exists' do
sla_247
expect(instance.sla).to be_a(Sla)
end
it 'returns nil when no SLA' do
expect(instance.sla).to be_nil
end
end
describe '#calendar' do
it 'returns calendar when it exists' do
sla_247
expect(instance.calendar).to be_a(Calendar)
end
it 'returns nil when no calendar' do
expect(instance.calendar).to be_nil
end
end
describe '#forced?' do
it 'true when given true' do
instance = described_class.new ticket, force: true
expect(instance).to be_forced
end
it 'false when given false' do
instance = described_class.new ticket, force: false
expect(instance).not_to be_forced
end
it 'false when given nil' do
instance = described_class.new ticket, force: nil
expect(instance).not_to be_forced
end
end
describe '#update_escalations' do
it 'sets escalation times' do
instance = described_class.new open_ticket_with_history
sla_247
expect { instance.update_escalations }
.to change(open_ticket_with_history, :escalation_at).from(nil)
end
# https://github.com/zammad/zammad/issues/3140
it 'agent follow up does not set #update_escalation_at' do
sla_247
ticket
create(:ticket_article, :outbound_email, ticket: ticket)
expect(ticket.reload.update_escalation_at).to be_nil
end
# https://github.com/zammad/zammad/issues/3140
it 'customer contact sets #update_escalation_at' do
sla_247
ticket
create(:ticket_article, :inbound_email, ticket: ticket)
expect(ticket.reload.update_escalation_at).to be_a(Time)
end
context 'with ticket with sla and customer enquiry' do
before do
sla_247
ticket
travel 10.minutes
create(:ticket_article, :inbound_email, ticket: ticket)
travel 10.minutes
end
# https://github.com/zammad/zammad/issues/3140
it 'agent response clears #update_escalation_at' do
expect { create(:ticket_article, :outbound_email, ticket: ticket) }
.to change { ticket.reload.update_escalation_at }.to(nil)
end
# https://github.com/zammad/zammad/issues/3140
it 'repeated customer requests do not #update_escalation_at' do
expect { create(:ticket_article, :inbound_email, ticket: ticket) }
.not_to change { ticket.reload.update_escalation_at }
end
end
end
describe '#escalation_first_response' do
let(:force) { true } # initial calculation
it 'returns attribute' do
sla_247
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_first_response)
expect(result).to include first_response_escalation_at: 60.minutes.ago
end
it 'returns nil when no sla#first_response_time' do
sla_247.update! first_response_time: nil
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_first_response)
expect(result).to include(first_response_escalation_at: nil)
end
end
describe '#escalation_update' do
it 'returns attribute' do
sla_247
ticket_with_history.last_contact_customer_at = 2.hours.ago
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_update)
expect(result).to include update_escalation_at: 60.minutes.ago
end
it 'returns nil when no sla#update_time' do
sla_247.update! update_time: nil
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_update)
expect(result).to include(update_escalation_at: nil)
end
end
describe '#escalation_close' do
it 'returns attribute' do
sla_247
ticket_with_history.update! state: Ticket::State.lookup(name: 'open'), close_at: nil
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_close)
expect(result).to include close_escalation_at: 45.minutes.ago
end
it 'returns nil when no sla#solution_time' do
sla_247.update! solution_time: nil
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_close)
expect(result).to include(close_escalation_at: nil)
end
end
describe '#calculate_time' do
before do
sla_247
start
end
let(:start) { 75.minutes.from_now.change(sec: 0) }
it 'calculates target time that is given working minutes after start time' do
expect(instance_with_history.send(:calculate_time, start, 30)).to eq(start + 1.hour)
end
it 'returns nil when given 0 span' do
expect(instance_with_history.send(:calculate_time, start, 0)).to be_nil
end
it 'returns nil when given no span' do
expect(instance_with_history.send(:calculate_time, start, nil)).to be_nil
end
end
describe '#calculate_next_escalation' do
it 'nil when escalation is disabled' do
ticket.update! state: Ticket::State.lookup(name: 'closed')
expect(instance.send(:calculate_next_escalation)).to be_nil
end
it 'first_response_escalation_at when earliest' do
ticket.update! first_response_escalation_at: 1.hour.from_now, update_escalation_at: 2.hours.from_now, close_escalation_at: 3.hours.from_now
expect(instance.send(:calculate_next_escalation)).to eq ticket.first_response_escalation_at
end
it 'update_escalation_at when earliest' do
ticket.update! first_response_escalation_at: 2.hours.from_now, update_escalation_at: 1.hour.from_now, close_escalation_at: 3.hours.from_now
expect(instance.send(:calculate_next_escalation)).to eq ticket.update_escalation_at
end
it 'close_escalation_at when earliest' do
ticket.update! first_response_escalation_at: 2.hours.from_now, update_escalation_at: 1.hour.from_now, close_escalation_at: 30.minutes.from_now
expect(instance.send(:calculate_next_escalation)).to eq ticket.close_escalation_at
end
it 'works when one of escalation times is not present' do
ticket.update! first_response_escalation_at: 1.hour.from_now, update_escalation_at: nil, close_escalation_at: nil
expect { instance.send(:calculate_next_escalation) }.not_to raise_error
end
end
describe '#statistics_first_response' do
it 'calculates statistics' do
sla_247
ticket_with_history.first_response_at = 45.minutes.ago
instance_with_history.force!
result = instance_with_history.send(:statistics_first_response)
expect(result).to include(first_response_in_min: 75, first_response_diff_in_min: -15)
end
it 'does not touch statistics when sla time is nil' do
sla_247.update! first_response_time: nil
ticket_with_history.first_response_at = 45.minutes.ago
instance_with_history.force!
result = instance_with_history.send(:statistics_first_response)
expect(result).to be_nil
end
end
describe '#statistics_update' do
before do
sla_247
freeze_time
end
it 'calculates statistics' do
ticket_with_history.last_contact_customer_at = 61.minutes.ago
ticket_with_history.last_contact_agent_at = 60.minutes.ago
result = instance_with_history.send(:statistics_update)
expect(result).to include(update_in_min: 1, update_diff_in_min: 59)
end
it 'does not calculate statistics when customer respose is last' do
ticket_with_history.last_contact_customer_at = 59.minutes.ago
ticket_with_history.last_contact_agent_at = 60.minutes.ago
result = instance_with_history.send(:statistics_update)
expect(result).to be_nil
end
it 'does not calculate statistics when only customer enquiry present' do
create(:ticket_article, :inbound_email, ticket: ticket)
result = instance.send(:statistics_update)
expect(result).to be_nil
end
it 'calculates update statistics of last exchange' do
create(:ticket_article, :inbound_email, ticket: ticket)
travel 10.minutes
create(:ticket_article, :outbound_email, ticket: ticket)
instance.force!
expect(instance.send(:statistics_update)).to include(update_in_min: 10, update_diff_in_min: 50)
end
context 'with multiple exchanges and later one being quicker' do
before do
create(:ticket_article, :inbound_email, ticket: ticket)
travel 10.minutes
create(:ticket_article, :outbound_email, ticket: ticket)
travel 10.minutes
create(:ticket_article, :inbound_email, ticket: ticket)
travel 5.minutes
create(:ticket_article, :outbound_email, ticket: ticket)
end
it 'keeps statistics of longest exchange' do
expect(ticket.reload).to have_attributes(update_in_min: 10, update_diff_in_min: 50)
end
end
it 'does not touch statistics when sla time is nil' do
sla_247.update! update_time: nil
ticket_with_history.last_contact_customer_at = 60.minutes.ago
instance_with_history.force!
result = instance_with_history.send(:statistics_update)
expect(result).to be_nil
end
it 'does not touch statistics when last update is nil' do
ticket_with_history.assign_attributes last_contact_agent_at: nil, last_contact_customer_at: nil
instance_with_history.force!
result = instance_with_history.send(:statistics_update)
expect(result).to be_nil
end
end
describe '#statistics_close' do
it 'calculates statistics' do
sla_247
ticket_with_history.close_at = 50.minutes.ago
instance_with_history.force!
result = instance_with_history.send(:statistics_close)
expect(result).to include(close_in_min: 70, close_diff_in_min: 5)
end
it 'does not touch statistics when sla time is nil' do
sla_247.update! solution_time: nil
ticket_with_history.close_at = 50.minutes.ago
instance_with_history.force!
result = instance_with_history.send(:statistics_close)
expect(result).to be_nil
end
end
describe '#calculate_minutes' do
it 'calculates working minutes up to given time' do
sla_247
expect(instance_with_history.send(:calculate_minutes, ticket_with_history.created_at, 90.minutes.ago)).to be 30
end
it 'returns nil when given nil' do
sla_247
expect(instance.send(:calculate_minutes, ticket.created_at, nil)).to be_nil
end
end
it 'switching state pushes escalation date' do
sla_247
open_ticket_with_history.reload
expect(open_ticket_with_history.update_escalation_at).to eq open_ticket_with_history.created_at + 70.minutes
end
def without_update_escalation_information_callback(&block)
Ticket.without_callback(:commit, :after, :update_escalation_information, &block)
end
end

View file

@ -149,7 +149,7 @@ RSpec.describe Calendar, type: :model do
end
it 'does create a background job for escalation rebuild' do
expect { calendar.sync }.to have_enqueued_job(SlaTicketRebuildEscalationJob)
expect { calendar.sync }.to have_enqueued_job(TicketEscalationRebuildJob)
end
end
@ -257,4 +257,187 @@ RSpec.describe Calendar, type: :model do
it { expect(result[:day_2]).to eq({ '09:00' => '17:00', '00:01' => '02:00' }) }
end
end
context 'when updated Calendar no longer matches Ticket', :performs_jobs do
subject(:ticket) { create(:ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') }
let(:calendar) do
create(:calendar,
business_hours: {
mon: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
tue: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
wed: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
thu: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
fri: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
sat: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
sun: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
},
public_holidays: {
'2016-11-01' => {
'active' => true,
'summary' => 'test 1',
},
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) }
before do
queue_adapter.perform_enqueued_jobs = true
queue_adapter.perform_enqueued_at_jobs = true
sla
ticket
create(:'ticket/article', :inbound_web, ticket: ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC')
ticket.reload
create(:'ticket/article', :outbound_email, ticket: ticket, created_at: '2016-11-07 13:26:36 UTC', updated_at: '2016-11-07 13:26:36 UTC')
ticket.reload
end
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at).to be_nil
expect(ticket.first_response_escalation_at).to be_nil
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at).to be_nil
# set sla's for timezone "Europe/Berlin" wintertime (+1), so UTC times are 3:00-18:00
calendar.update!(
business_hours: {
mon: {
active: true,
timeframes: [ ['04:00', '20:00'] ]
},
tue: {
active: true,
timeframes: [ ['04:00', '20:00'] ]
},
wed: {
active: true,
timeframes: [ ['04:00', '20:00'] ]
},
thu: {
active: true,
timeframes: [ ['04:00', '20:00'] ]
},
fri: {
active: true,
timeframes: [ ['04:00', '20:00'] ]
},
sat: {
active: false,
timeframes: [ ['04:00', '13:00'] ] # this changed from '17:00' => '13:00'
},
sun: {
active: false,
timeframes: [ ['04:00', '17:00'] ]
},
},
public_holidays: {
'2016-11-01' => {
'active' => true,
'summary' => 'test 1',
},
},
)
ticket.reload
expect(ticket.escalation_at).to be_nil
expect(ticket.first_response_escalation_at).to be_nil
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at).to be_nil
end
end
context 'when SLA relevant timezone holidays are configured' do
let(:calendar) do
create(:calendar,
public_holidays: {
'2015-09-22' => {
'active' => true,
'summary' => 'test 1',
},
'2015-09-23' => {
'active' => false,
'summary' => 'test 2',
},
'2015-09-24' => {
'removed' => false,
'summary' => 'test 3',
},
})
end
let(:sla) do
create(:sla,
calendar: calendar,
condition: {},
first_response_time: 120,
update_time: 180,
solution_time: 240)
end
before do
sla
ticket.reload
end
context 'when a Ticket is created in working hours but not affected by the configured holidays' do
subject(:ticket) { create(:ticket, created_at: '2013-10-21 09:30:00 UTC', updated_at: '2013-10-21 09:30:00 UTC') }
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at.gmtime.to_s).to eq('2013-10-21 11:30:00 UTC')
expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-10-21 11:30:00 UTC')
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-10-21 13:30:00 UTC')
end
end
context 'when a Ticket is created before the working hours but not affected by the configured holidays' do
subject(:ticket) { create(:ticket, created_at: '2013-10-21 05:30:00 UTC', updated_at: '2013-10-21 05:30:00 UTC') }
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at.gmtime.to_s).to eq('2013-10-21 09:00:00 UTC')
expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-10-21 09:00:00 UTC')
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-10-21 11:00:00 UTC')
end
end
context 'when a Ticket is created before the holidays but escalation should take place while holidays are' do
subject(:ticket) { create(:ticket, created_at: '2015-09-21 14:30:00 UTC', updated_at: '2015-09-21 14:30:00 UTC') }
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at.gmtime.to_s).to eq('2015-09-23 08:30:00 UTC')
expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2015-09-23 08:30:00 UTC')
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at.gmtime.to_s).to eq('2015-09-23 10:30:00 UTC')
end
end
end
end

View file

@ -0,0 +1,238 @@
RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do
before do
queue_adapter.perform_enqueued_jobs = true
queue_adapter.perform_enqueued_at_jobs = true
travel_to(DateTime.parse('2013-03-21 09:30:00 UTC'))
end
context 'when affected Ticket existed' do
subject(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 180, solution_time: 240) }
let(:calendar) { create(:calendar, :business_hours_9_17) }
let!(:ticket) { create(:ticket) }
it 'calculates escalation_at' do
expect { sla }.to change { ticket.reload.escalation_at }.to eq(ticket.created_at + 1.hour)
end
it 'calculates first_response_escalation_at' do
expect { sla }.to change { ticket.reload.first_response_escalation_at }.to eq(ticket.created_at + 1.hour)
end
it 'calculates update_escalation_at' do
expect { sla }.not_to change { ticket.reload.update_escalation_at }.from nil
end
it 'calculates close_escalation_at' do
expect { sla }.to change { ticket.reload.close_escalation_at }.to eq(ticket.created_at + 4.hours)
end
context 'when SLA gets updated' do
before do
sla
end
def first_response_time_change
sla.update!(first_response_time: 120)
end
it 'calculates escalation_at' do
expect { first_response_time_change }.to change { ticket.reload.escalation_at }.to eq(ticket.created_at + 2.hours)
end
it 'calculates first_response_escalation_at' do
expect { first_response_time_change }.to change { ticket.reload.first_response_escalation_at }.to eq(ticket.created_at + 2.hours)
end
it 'calculates update_escalation_at' do
expect { first_response_time_change }.not_to change { ticket.reload.update_escalation_at }.from nil
end
it 'calculates close_escalation_at' do
expect { first_response_time_change }.not_to change { ticket.reload.close_escalation_at }.from eq(ticket.created_at + 4.hours)
end
end
end
context 'when matching conditions' do
context "when matching indirect via 'is not'" do
subject(:ticket) { create(:ticket, created_at: '2013-03-21 09:30:00 UTC', updated_at: '2013-03-21 09:30:00 UTC') }
let(:calendar) { create(:calendar) }
let(:sla_not_matching) do
create(:sla,
calendar: calendar,
condition: {
'ticket.priority_id' => {
operator: 'is not',
value: %w[1 2 3],
},
},
first_response_time: 10,
update_time: 20,
solution_time: 300)
end
let(:sla_matching_indirect) do
create(:sla,
calendar: calendar,
condition: {
'ticket.priority_id' => {
operator: 'is not',
value: '1',
},
},
first_response_time: 120,
update_time: 180,
solution_time: 240)
end
before do
sla_not_matching
sla_matching_indirect
ticket
ticket.reload
end
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC')
expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC')
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 13:30:00 UTC')
end
end
context 'when matching ticket.priority_id and article.subject' do
subject(:ticket) { create(:ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') }
let(:calendar) { create(:calendar) }
let(:sla) do
create(:sla,
condition: {
'ticket.priority_id' => {
operator: 'is',
value: %w[1 2 3],
},
'article.subject' => {
operator: 'contains',
value: 'SLA TEST',
},
},
calendar: calendar,
first_response_time: 60,
update_time: 120,
solution_time: 180)
end
before do
sla
ticket
create(:'ticket/article', :inbound_email, subject: 'SLA TEST', ticket: ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC')
ticket.reload
end
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC')
expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC')
expect(ticket.first_response_in_min).to be_nil
expect(ticket.first_response_diff_in_min).to be_nil
expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-03-21 14:30:00 UTC')
expect(ticket.close_escalation_at.gmtime.to_s).to eq('2016-03-21 15:30:00 UTC')
expect(ticket.close_in_min).to be_nil
expect(ticket.close_diff_in_min).to be_nil
end
end
context 'when matching ticket.priority_id and ticket.title' do
subject(:ticket) { create(:ticket, title: 'SLA TEST', created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') }
let(:calendar) { create(:calendar) }
let(:sla) do
create(:sla,
condition: {
'ticket.priority_id' => {
operator: 'is',
value: %w[1 2 3],
},
'ticket.title' => {
operator: 'contains',
value: 'SLA TEST',
},
},
calendar: calendar,
first_response_time: 60,
update_time: 120,
solution_time: 180)
end
before do
sla
ticket
create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC')
ticket.reload
end
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC')
expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC')
expect(ticket.first_response_in_min).to be_nil
expect(ticket.first_response_diff_in_min).to be_nil
expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-03-21 14:30:00 UTC')
expect(ticket.close_escalation_at.gmtime.to_s).to eq('2016-03-21 15:30:00 UTC')
expect(ticket.close_in_min).to be_nil
expect(ticket.close_diff_in_min).to be_nil
end
end
context 'when matching ticket.priority_id BUT NOT ticket.title' do
subject(:ticket) { create(:ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') }
let(:calendar) { create(:calendar) }
let(:sla) do
create(:sla,
condition: {
'ticket.priority_id' => {
operator: 'is',
value: %w[1 2 3],
},
'ticket.title' => {
operator: 'contains',
value: 'SLA TEST',
},
},
calendar: calendar,
first_response_time: 60,
update_time: 120,
solution_time: 180)
end
before do
sla
ticket
create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC')
ticket.reload
end
it 'DOES NOT calculate escalation_at attributes' do
expect(ticket.escalation_at).to be_nil
expect(ticket.first_response_escalation_at).to be_nil
expect(ticket.first_response_in_min).to be_nil
expect(ticket.first_response_diff_in_min).to be_nil
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at).to be_nil
expect(ticket.close_in_min).to be_nil
expect(ticket.close_diff_in_min).to be_nil
end
end
end
end

View file

@ -1,6 +1,59 @@
require 'rails_helper'
require 'models/application_model_examples'
require 'models/sla/has_escalation_calculation_impact_examples'
RSpec.describe Sla, type: :model do
it_behaves_like 'ApplicationModel', can_assets: { associations: :calendar, selectors: :condition }
it_behaves_like 'HasEscalationCalculationImpact'
context 'when matching Ticket' do
let(:sla) { create(:sla, :condition_title, condition_title: 'matching') }
let(:sla_blank) { create(:sla, :condition_blank) }
let(:ticket_matching) { create(:ticket, title: 'matching title') }
let(:ticket_not_matching) { create(:ticket, title: 'nope') }
describe '#condition_matches?' do
it 'returns true when condition matches ticket' do
expect(sla).to be_condition_matches(ticket_matching)
end
it 'returns false when condition does not match ticket' do
expect(sla).not_to be_condition_matches(ticket_not_matching)
end
it 'returns false when condition does not match ticket while matching tickets exist' do
ticket_matching
expect(sla).not_to be_condition_matches(ticket_not_matching)
end
it 'returns true when SLA condition is blank ticket' do
expect(sla_blank).to be_condition_matches(ticket_not_matching)
end
end
describe '.for_ticket' do
it 'returns matching SLA for the ticket' do
sla
expect(described_class.for_ticket(ticket_matching)).to eq sla
end
it 'returns nil when no SLA matches ticket' do
sla
expect(described_class.for_ticket(ticket_not_matching)).to be_nil
end
it 'returns blank SLA for the ticket' do
sla_blank
expect(described_class.for_ticket(ticket_matching)).to eq sla_blank
end
it 'returns non-blank SLA over blank SLA for the ticket' do
sla
sla_blank
expect(described_class.for_ticket(ticket_matching)).to eq sla
end
end
end
end

View file

@ -0,0 +1,301 @@
RSpec.shared_examples 'Ticket::Article::HasTicketContactAttributesImpact' do
describe '#update_ticket_article_attributes callback' do
subject(:ticket) { create(:ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC') }
let(:calendar) do
create(:calendar,
business_hours: {
mon: {
active: true,
timeframes: [ ['08:00', '18:00'] ]
},
tue: {
active: true,
timeframes: [ ['08:00', '18:00'] ]
},
wed: {
active: true,
timeframes: [ ['08:00', '18:00'] ]
},
thu: {
active: true,
timeframes: [ ['08:00', '18:00'] ]
},
fri: {
active: true,
timeframes: [ ['08:00', '18:00'] ]
},
sat: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
sun: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
})
end
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: 180) }
before do
sla
ticket
end
context 'when inbound email Article is created' do
it 'updates ticket article attributes' do
article_inbound = create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_agent_at).to be_nil
expect(ticket.first_response_at).to be_nil
expect(ticket.close_at).to be_nil
end
end
context 'when outbound email Article is created in response to inbound email Article' do
it 'updates ticket article attributes' do
article_inbound = create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC')
ticket.reload
article_outbound = create(:'ticket/article', :outbound_email, ticket: ticket, created_at: '2013-03-29 07:00:03 UTC', updated_at: '2013-03-29 07:00:03 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article_outbound.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_agent_at.to_s).to eq(article_outbound.created_at.to_s)
expect(ticket.first_response_at.to_s).to eq(article_outbound.created_at.to_s)
expect(ticket.first_response_in_min).to eq(0)
expect(ticket.first_response_diff_in_min).to eq(60)
expect(ticket.close_at).to be_nil
end
end
context 'when inbound phone Article is created' do
it 'updates ticket article attributes' do
article_inbound = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_agent_at).to be_nil
expect(ticket.first_response_at).to be_nil
expect(ticket.close_at).to be_nil
end
end
context 'when outbound note Article is created in response to inbound phone Article' do
it 'updates ticket article attributes' do
article_inbound = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC')
ticket.reload
create(:'ticket/article', :outbound_note, ticket: ticket, created_at: '2013-03-28 23:52:00 UTC', updated_at: '2013-03-28 23:52:00 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_agent_at).to be_nil
expect(ticket.first_response_at).to be_nil
expect(ticket.close_at).to be_nil
end
end
context 'when outbound phone Article is created after outbound note Article is created in response to inbound phone Article' do
it 'updates ticket article attributes' do
article_inbound = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC')
ticket.reload
create(:'ticket/article', :outbound_note, ticket: ticket, created_at: '2013-03-28 23:52:00 UTC', updated_at: '2013-03-28 23:52:00 UTC')
ticket.reload
article_outbound = create(:'ticket/article', :outbound_phone, ticket: ticket, created_at: '2013-03-28 23:55:00 UTC', updated_at: '2013-03-28 23:55:00 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article_outbound.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s)
expect(ticket.last_contact_agent_at.to_s).to eq(article_outbound.created_at.to_s)
expect(ticket.first_response_at.to_s).to eq(article_outbound.created_at.to_s)
expect(ticket.close_at).to be_nil
end
end
context 'when inbound web Article is created' do
subject(:ticket) { create(:ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') }
let(:calendar) do
create(:calendar,
business_hours: {
mon: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
tue: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
wed: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
thu: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
fri: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
sat: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
sun: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
},
public_holidays: {
'2016-11-01' => {
'active' => true,
'summary' => 'test 1',
},
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) }
before do
sla
ticket
create(:'ticket/article', :inbound_web, ticket: ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC')
ticket.reload
end
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at.gmtime.to_s).to eq('2016-11-02 08:00:00 UTC')
expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2016-11-02 08:00:00 UTC')
expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-11-02 09:00:00 UTC')
expect(ticket.close_escalation_at).to be_nil
end
context 'when replied via outbound email' do
before do
create(:'ticket/article', :outbound_email, ticket: ticket, created_at: '2016-11-07 13:26:36 UTC', updated_at: '2016-11-07 13:26:36 UTC')
ticket.reload
end
it 'calculates escalation_at attributes' do
expect(ticket.escalation_at).to be_nil
expect(ticket.first_response_escalation_at).to be_nil
expect(ticket.update_escalation_at).to be_nil
expect(ticket.close_escalation_at).to be_nil
end
end
end
context "when Setting 'ticket_last_contact_behaviour' is set to 'based_on_customer_reaction'" do
subject(:ticket) { create(:ticket, created_at: '2018-05-01 13:56:21 UTC', updated_at: '2018-05-01 13:56:21 UTC') }
let(:calendar) do
create(:calendar,
business_hours: {
mon: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
tue: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
wed: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
thu: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
fri: {
active: true,
timeframes: [ ['08:00', '20:00'] ]
},
sat: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
sun: {
active: false,
timeframes: [ ['08:00', '17:00'] ]
},
},
public_holidays: {
'2016-11-01' => {
'active' => true,
'summary' => 'test 1',
},
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) }
before do
Setting.set('ticket_last_contact_behaviour', 'based_on_customer_reaction')
sla
end
it 'updates ticket article attributes' do
ticket
article = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2018-05-01 13:56:21 UTC', updated_at: '2018-05-01 13:56:21 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article.created_at.to_s)
expect(ticket.last_contact_agent_at).to be_nil
expect(ticket.first_response_at).to be_nil
expect(ticket.close_at).to be_nil
article = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2018-05-01 14:56:21 UTC', updated_at: '2018-05-01 14:56:21 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article.created_at.to_s)
expect(ticket.last_contact_agent_at).to be_nil
expect(ticket.first_response_at).to be_nil
expect(ticket.close_at).to be_nil
article_customer = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2018-05-01 15:56:21 UTC', updated_at: '2018-05-01 15:56:21 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article_customer.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article_customer.created_at.to_s)
expect(ticket.last_contact_agent_at).to be_nil
expect(ticket.first_response_at).to be_nil
expect(ticket.close_at).to be_nil
article_agent = create(:'ticket/article', :outbound_phone, ticket: ticket, created_at: '2018-05-01 16:56:21 UTC', updated_at: '2018-05-01 16:56:21 UTC')
ticket.reload
expect(ticket.last_contact_at.to_s).to eq(article_agent.created_at.to_s)
expect(ticket.last_contact_customer_at.to_s).to eq(article_customer.created_at.to_s)
expect(ticket.last_contact_agent_at.to_s).to eq(article_agent.created_at.to_s)
expect(ticket.first_response_at.to_s).to eq(article_agent.created_at.to_s)
expect(ticket.close_at).to be_nil
end
end
end
end

View file

@ -4,6 +4,7 @@ require 'models/concerns/can_be_imported_examples'
require 'models/concerns/can_csv_import_examples'
require 'models/concerns/has_history_examples'
require 'models/concerns/has_object_manager_attributes_validation_examples'
require 'models/ticket/article/has_ticket_contact_attributes_impact_examples'
RSpec.describe Ticket::Article, type: :model do
subject(:article) { create(:ticket_article) }
@ -14,6 +15,8 @@ RSpec.describe Ticket::Article, type: :model do
it_behaves_like 'HasHistory'
it_behaves_like 'HasObjectManagerAttributesValidation'
it_behaves_like 'Ticket::Article::HasTicketContactAttributesImpact'
describe 'Callbacks, Observers, & Async Transactions -' do
describe 'NULL byte handling (via ChecksAttributeValuesAndLength concern):' do
it 'removes them from #subject on creation, if necessary (postgres doesnt like them)' do

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ require 'models/concerns/has_tags_examples'
require 'models/concerns/has_taskbars_examples'
require 'models/concerns/has_xss_sanitized_note_examples'
require 'models/concerns/has_object_manager_attributes_validation_examples'
require 'models/ticket/escalation_examples'
RSpec.describe Ticket, type: :model do
subject(:ticket) { create(:ticket) }
@ -19,6 +20,7 @@ RSpec.describe Ticket, type: :model do
it_behaves_like 'HasTaskbars'
it_behaves_like 'HasXssSanitizedNote', model_factory: :ticket
it_behaves_like 'HasObjectManagerAttributesValidation'
it_behaves_like 'Ticket::Escalation'
describe 'Class methods:' do
describe '.selectors' do
@ -634,6 +636,52 @@ RSpec.describe Ticket, type: :model do
end
end
end
describe '#last_original_update_at' do
let(:result) { ticket.last_original_update_at }
it 'returns initial customer enquiry time when customer contacted repeatedly' do
ticket
target = create(:ticket_article, :inbound_email, ticket: ticket)
travel 10.minutes
create(:ticket_article, :inbound_email, ticket: ticket)
expect(result).to eq target.created_at
end
it 'returns agent contact time when customer did not respond to agent reach out' do
ticket
create(:ticket_article, :outbound_email, ticket: ticket)
expect(result).to eq ticket.last_contact_agent_at
end
it 'returns nil if no customer response' do
ticket
expect(result).to be_nil
end
context 'with customer enquiry and agent response' do
before do
ticket
create(:ticket_article, :inbound_email, ticket: ticket)
travel 10.minutes
create(:ticket_article, :outbound_email, ticket: ticket)
travel 10.minutes
end
it 'returns last customer enquiry time when agent did not respond yet' do
target = create(:ticket_article, :inbound_email, ticket: ticket)
expect(result).to eq target.created_at
end
it 'returns agent response time when agent responded to customer enquiry' do
expect(result).to eq ticket.last_contact_agent_at
end
end
end
end
describe 'Attributes:' do
@ -837,12 +885,12 @@ RSpec.describe Ticket, type: :model do
let(:article) { create(:ticket_article, ticket: ticket, sender_name: 'Agent') }
it 'is updated based on the SLAs #update_time' do
it 'is updated based on the SLAs #close_escalation_at' do
travel(1.minute) # time is frozen: if we don't travel forward, pre- and post-update values will be the same
expect { article }
.to change { ticket.reload.escalation_at.to_i }
.to eq(3.hours.from_now.to_i)
.to change { ticket.reload.escalation_at }
.to(ticket.reload.close_escalation_at)
end
context 'when new #update_time is later than original #solution_time' do
@ -850,8 +898,8 @@ RSpec.describe Ticket, type: :model do
travel(2.hours) # time is frozen: if we don't travel forward, pre- and post-update values will be the same
expect { article }
.to change { ticket.reload.escalation_at.to_i }
.to eq(4.hours.after(ticket.created_at).to_i)
.to change { ticket.reload.escalation_at }
.to(4.hours.after(ticket.created_at))
end
end
end
@ -978,8 +1026,8 @@ RSpec.describe Ticket, type: :model do
let(:article) { create(:ticket_article, ticket: ticket, sender_name: 'Agent') }
it 'does not change' do
expect { article }.not_to change(ticket, :first_response_escalation_at)
it 'is cleared' do
expect { article }.to change { ticket.reload.first_response_escalation_at }.to(nil)
end
end
end
@ -1001,6 +1049,9 @@ RSpec.describe Ticket, type: :model do
before { sla } # create sla
it 'is set based on SLAs #update_time' do
travel 1.minute
create(:ticket_article, ticket: ticket, sender_name: 'Customer')
expect(ticket.reload.update_escalation_at.to_i)
.to eq(3.hours.from_now.to_i)
end
@ -1011,11 +1062,12 @@ RSpec.describe Ticket, type: :model do
let(:article) { create(:ticket_article, ticket: ticket, sender_name: 'Agent') }
it 'is updated based on the SLAs #update_time' do
travel(1.minute) # time is frozen: if we don't travel forward, pre- and post-update values will be the same
create(:ticket_article, ticket: ticket, sender_name: 'Customer')
travel(1.minute)
expect { article }
.to change { ticket.reload.update_escalation_at.to_i }
.to(3.hours.from_now.to_i)
.to change { ticket.reload.update_escalation_at }
.to(nil)
end
end
end

View file

@ -1,153 +1,164 @@
require 'rails_helper'
RSpec.describe 'Ticket Escalation', type: :request do
let(:sla_first_response) { 1.hour }
let(:sla_update) { 3.hours }
let(:sla_close) { 4.hours }
let!(:agent) do
create(:agent, groups: Group.all)
end
let!(:customer) do
create(:customer)
end
let!(:calendar) do
create(
:calendar,
name: 'Escalation Test',
timezone: 'Europe/Berlin',
business_hours: {
mon: {
active: true,
timeframes: [ ['00:00', '23:59'] ]
},
tue: {
active: true,
timeframes: [ ['00:00', '23:59'] ]
},
wed: {
active: true,
timeframes: [ ['00:00', '23:59'] ]
},
thu: {
active: true,
timeframes: [ ['00:00', '23:59'] ]
},
fri: {
active: true,
timeframes: [ ['00:00', '23:59'] ]
},
sat: {
active: true,
timeframes: [ ['00:00', '23:59'] ]
},
sun: {
active: true,
timeframes: [ ['00:00', '23:59'] ]
},
},
default: true,
ical_url: nil,
)
end
let!(:sla) do
create(
:sla,
name: 'test sla 1',
condition: {
'ticket.title' => {
operator: 'contains',
value: 'some value 123',
},
},
first_response_time: 60,
update_time: 180,
solution_time: 240,
calendar: calendar,
)
end
let!(:mail_group) do
create(:group, email_address: create(:email_address) )
let!(:mail_group) { create(:group, email_address: create(:email_address) ) }
let(:calendar) { create(:calendar, :'24/7') }
let(:sla) do
create(:sla,
calendar: calendar,
first_response_time: sla_first_response / 1.minute,
update_time: sla_update / 1.minute,
solution_time: sla_close / 1.minute)
end
describe 'request handling' do
define :json_equal_date do
match do
actual&.sub(/.\d\d\dZ$/, 'Z') == expected&.iso8601
end
end
it 'does escalate by ticket created via web' do
shared_examples 'response matching object' do
%w[escalation_at first_response_escalation_at update_escalation_at close_escalation_at].each do |attribute|
it "#{attribute} is representing the same time" do
expect(json_response[attribute]).to json_equal_date ticket[attribute]
end
end
end
before do
freeze_time
sla
end
context 'when customer creates ticket via web', authenticated_as: :customer do
subject(:ticket) { Ticket.find(json_response['id']) }
let(:customer) { create(:customer) }
before do
params = {
title: 'some value 123',
group: mail_group.name,
article: {
body: 'some test 123',
type_id: Ticket::Article::Type.find_by(name: 'web').id,
body: 'some test 123',
},
}
authenticated_as(customer)
post '/api/v1/tickets', params: params, as: :json
expect(response).to have_http_status(:created)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['state_id']).to eq(Ticket::State.lookup(name: 'new').id)
expect(json_response['title']).to eq('some value 123')
expect(json_response['updated_by_id']).to eq(customer.id)
expect(json_response['created_by_id']).to eq(customer.id)
ticket_p = Ticket.find(json_response['id'])
expect(json_response['escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['escalation_at'].iso8601)
expect(json_response['first_response_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['first_response_escalation_at'].iso8601)
expect(json_response['update_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['update_escalation_at'].iso8601)
expect(json_response['close_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['close_escalation_at'].iso8601)
expect(ticket_p.escalation_at).to be_truthy
expect(ticket_p.first_response_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i)
expect(ticket_p.update_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 3.hours).to_i)
expect(ticket_p.close_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i)
expect(ticket_p.escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i)
end
it 'does escalate by ticket got created via email - reply by agent via web' do
it_behaves_like 'response matching object'
email = "From: Bob Smith <customer@example.com>
To: #{mail_group.email_address.email}
Subject: some value 123
it 'first response escalation in 1h' do
expect(ticket.first_response_escalation_at).to eq 1.hour.from_now
end
Some Text"
it 'update_escalation in 3h' do
expect(ticket.update_escalation_at).to eq 3.hours.from_now
end
ticket_p, _article_p, user_p, _mail = Channel::EmailParser.new.process({}, email)
ticket_p.reload
expect(ticket_p.escalation_at).to be_truthy
expect(ticket_p.first_response_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i)
expect(ticket_p.update_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 3.hours).to_i)
expect(ticket_p.close_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i)
expect(ticket_p.escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i)
it 'close escalation in 4h' do
expect(ticket.close_escalation_at).to eq 4.hours.from_now
end
travel 3.hours
it 'next escalation is closest escalation' do
expect(ticket.escalation_at).to eq 1.hour.from_now
end
end
context 'when customer sends email' do
subject(:ticket) { ticket_mail_in }
before { ticket }
it 'first response escalation in 1h' do
expect(ticket.first_response_escalation_at).to eq 1.hour.from_now
end
it 'update_escalation in 3h' do
expect(ticket.update_escalation_at).to eq 3.hours.from_now
end
it 'close escalation in 4h' do
expect(ticket.close_escalation_at).to eq 4.hours.from_now
end
it 'next escalation is closest escalation' do
expect(ticket.escalation_at).to eq 1.hour.from_now
end
end
context 'when agent responds via web', authenticated_as: :agent do
subject(:ticket) { ticket_mail_in }
let(:agent) { create(:agent, groups: Group.all) }
before { ticket && travel(3.hours) }
it_behaves_like 'response matching object' do
before { ticket_respond_web }
end
it 'clears first response escalation' do
expect { ticket_respond_web }.to change(ticket, :first_response_escalation_at).to(nil)
end
it 'changes update escalation' do
expect { ticket_respond_web }.to change(ticket, :update_escalation_at)
end
it 'update escalation is nil since agent responded' do
ticket_respond_web
expect(ticket.update_escalation_at).to be_nil
end
it 'does not change close escalation' do
expect { ticket_respond_web }.not_to change(ticket, :close_escalation_at)
end
it 'change next escalation' do
expect { ticket_respond_web }.to change(ticket, :escalation_at)
end
it 'next escalation is closest escalation which is close escalation' do
ticket_respond_web
expect(ticket.escalation_at).to eq 1.hour.from_now
end
def ticket_respond_web
params = {
title: 'some value 123 - update',
article: {
body: 'some test 123',
type: 'email',
to: 'customer@example.com',
type_id: Ticket::Article::Type.find_by(name: 'email').id,
body: 'some test 123',
type: 'email',
to: 'customer@example.com',
},
}
authenticated_as(agent)
put "/api/v1/tickets/#{ticket_p.id}", params: params, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['state_id']).to eq(Ticket::State.lookup(name: 'open').id)
expect(json_response['title']).to eq('some value 123 - update')
expect(json_response['updated_by_id']).to eq(agent.id)
expect(json_response['created_by_id']).to eq(user_p.id)
put "/api/v1/tickets/#{ticket.id}", params: params, as: :json
ticket_p.reload
expect(json_response['escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['escalation_at'].iso8601)
expect(json_response['first_response_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['first_response_escalation_at'].iso8601)
expect(json_response['update_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['update_escalation_at'].iso8601)
expect(json_response['close_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['close_escalation_at'].iso8601)
expect(ticket_p.first_response_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i)
expect(ticket_p.update_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.last_contact_agent_at + 3.hours).to_i)
expect(ticket_p.close_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i)
expect(ticket_p.escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i)
ticket.reload
end
end
def ticket_mail_in
email = <<~EMAIL
From: Bob Smith <customer@example.com>
To: #{mail_group.email_address.email}
Subject: some value 123
Some Text
EMAIL
ticket, _article_p, _user_p, _mail = Channel::EmailParser.new.process({}, email)
ticket
end
end

View file

@ -1,10 +1,14 @@
RSpec.configure do |config|
config.around(:each, :time_zone) do |example|
old_tz = ENV['TZ']
ENV['TZ'] = example.metadata[:time_zone]
if example.metadata[:type] == :system
old_tz = ENV['TZ']
ENV['TZ'] = example.metadata[:time_zone]
example.run
example.run
else
Time.use_zone(example.metadata[:time_zone]) { example.run }
end
ensure
ENV['TZ'] = old_tz
ENV['TZ'] = old_tz if example.metadata[:type] == :system
end
end

File diff suppressed because it is too large Load diff