This commit is contained in:
parent
161dd525ac
commit
1aa4e68c6c
40 changed files with 4094 additions and 2945 deletions
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
class SlaTicketRebuildEscalationJob < ApplicationJob
|
||||
include HasActiveJobLock
|
||||
|
||||
def perform
|
||||
Cache.delete('SLA::List::Active')
|
||||
Ticket::Escalation.rebuild_all
|
||||
end
|
||||
end
|
16
app/jobs/ticket_escalation_rebuild_job.rb
Normal file
16
app/jobs/ticket_escalation_rebuild_job.rb
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
31
app/models/concerns/has_escalation_calculation_impact.rb
Normal file
31
app/models/concerns/has_escalation_calculation_impact.rb
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
included do
|
||||
after_create :update_ticket_article_attributes
|
||||
after_destroy :update_ticket_count_attribute
|
||||
end
|
||||
|
||||
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(record)
|
||||
if first_response_at_update
|
||||
changed = true
|
||||
end
|
||||
|
||||
if sender_type_update(record)
|
||||
if sender_type_update
|
||||
changed = true
|
||||
end
|
||||
|
||||
if last_contact_update_at(record)
|
||||
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 after_destroy(record)
|
||||
def update_ticket_count_attribute
|
||||
changed = false
|
||||
if article_count_update(record)
|
||||
if article_count_update
|
||||
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
|
||||
|
||||
# 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
|
|
@ -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)
|
||||
# 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
|
||||
local_destination_time
|
||||
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
|
||||
|
|
|
@ -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
237
lib/escalation.rb
Normal 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
|
13
lib/escalation/destination_time.rb
Normal file
13
lib/escalation/destination_time.rb
Normal 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
|
24
lib/escalation/period_working_minutes.rb
Normal file
24
lib/escalation/period_working_minutes.rb
Normal 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
|
78
lib/escalation/ticket_biz_break.rb
Normal file
78
lib/escalation/ticket_biz_break.rb
Normal 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
|
71
lib/escalation/ticket_preferences.rb
Normal file
71
lib/escalation/ticket_preferences.rb
Normal 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
|
|
@ -4,7 +4,24 @@ FactoryBot.define do
|
|||
timezone { 'Europe/Berlin' }
|
||||
default { true }
|
||||
ical_url { nil }
|
||||
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: {
|
||||
|
@ -37,9 +54,7 @@ FactoryBot.define do
|
|||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_by_id { 1 }
|
||||
updated_by_id { 1 }
|
||||
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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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
|
40
spec/jobs/ticket_escalation_rebuild_job_spec.rb
Normal file
40
spec/jobs/ticket_escalation_rebuild_job_spec.rb
Normal 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
|
43
spec/lib/escalation/destination_time_spec.rb
Normal file
43
spec/lib/escalation/destination_time_spec.rb
Normal 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
|
97
spec/lib/escalation/period_working_minutes_spec.rb
Normal file
97
spec/lib/escalation/period_working_minutes_spec.rb
Normal 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
|
313
spec/lib/escalation/ticket_biz_break_spec.rb
Normal file
313
spec/lib/escalation/ticket_biz_break_spec.rb
Normal 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
|
256
spec/lib/escalation/ticket_preferences_spec.rb
Normal file
256
spec/lib/escalation/ticket_preferences_spec.rb
Normal 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
511
spec/lib/escalation_spec.rb
Normal 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
|
|
@ -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
|
||||
|
|
238
spec/models/sla/has_escalation_calculation_impact_examples.rb
Normal file
238
spec/models/sla/has_escalation_calculation_impact_examples.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 doesn’t like them)' do
|
||||
|
|
1136
spec/models/ticket/escalation_examples.rb
Normal file
1136
spec/models/ticket/escalation_examples.rb
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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 SLA’s #update_time' do
|
||||
it 'is updated based on the SLA’s #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 SLA’s #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 SLA’s #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
|
||||
|
|
|
@ -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,
|
||||
let!(:mail_group) { create(:group, email_address: create(:email_address) ) }
|
||||
|
||||
let(:calendar) { create(:calendar, :'24/7') }
|
||||
let(:sla) do
|
||||
create(:sla,
|
||||
calendar: calendar,
|
||||
)
|
||||
end
|
||||
let!(:mail_group) do
|
||||
create(:group, email_address: create(:email_address) )
|
||||
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: {
|
||||
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: {
|
||||
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
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
RSpec.configure do |config|
|
||||
config.around(:each, :time_zone) do |example|
|
||||
if example.metadata[:type] == :system
|
||||
old_tz = ENV['TZ']
|
||||
ENV['TZ'] = example.metadata[:time_zone]
|
||||
|
||||
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
Loading…
Reference in a new issue