Fixes #3140 - Update time SLAs escalates tickets with agent response.

This commit is contained in:
Mantas Masalskis 2021-11-02 12:33:18 +01:00 committed by Rolf Schmidt
parent 3f1057c190
commit 2c26e82354
25 changed files with 585 additions and 114 deletions

View file

@ -418,11 +418,11 @@ class App.ControllerForm extends App.Controller
if !@constructor.fieldIsMandatory(field_by_name)
field_by_name.attr('required', true)
field_by_name.parents('.form-group').find('label span').html('*')
field_by_name.closest('.form-group').find('label span').html('*')
field_by_name.closest('.form-group').addClass('is-required')
if !@constructor.fieldIsMandatory(field_by_data)
field_by_data.attr('required', true)
field_by_data.parents('.form-group').find('label span').html('*')
field_by_data.closest('.form-group').find('label span').html('*')
field_by_data.closest('.form-group').addClass('is-required')
optional: (name, el = @form) ->
@ -434,11 +434,11 @@ class App.ControllerForm extends App.Controller
if @constructor.fieldIsMandatory(field_by_name)
field_by_name.attr('required', false)
field_by_name.parents('.form-group').find('label span').html('')
field_by_name.closest('.form-group').find('label span').html('')
field_by_name.closest('.form-group').removeClass('is-required')
if @constructor.fieldIsMandatory(field_by_data)
field_by_data.attr('required', false)
field_by_data.parents('.form-group').find('label span').html('')
field_by_data.closest('.form-group').find('label span').html('')
field_by_data.closest('.form-group').removeClass('is-required')
readonly: (name, el = @form) ->

View file

@ -8,9 +8,11 @@ class App.UiElement.sla_times
item = $( App.view('generic/sla_times')(
attribute: attribute
first_response_time: params.first_response_time
response_time: params.response_time
update_time: params.update_time
solution_time: params.solution_time
first_response_time_in_text: @toText(params.first_response_time)
response_time_in_text: @toText(params.response_time)
update_time_in_text: @toText(params.update_time)
solution_time_in_text: @toText(params.solution_time)
) )
@ -26,12 +28,16 @@ class App.UiElement.sla_times
row = element.closest('tr')
if element.prop('checked')
row.addClass('is-active')
if row.has('.js-updateTypeSelector').length > 0 && row.has('.js-updateTypeSelector:checked').length == 0
row.find('.js-updateTypeSelector:first').prop('checked', true)
else
row.removeClass('is-active')
# reset data item
row.find('.js-timeConvertFrom').val('')
row.find('.js-timeConvertTo').val('')
row.find('.js-updateTypeSelector').attr('checked', false)
row.find('.help-inline').empty()
row.removeClass('has-error')
)
@ -42,12 +48,16 @@ class App.UiElement.sla_times
inText = element.val()
row = element.closest('tr')
row.find('.js-activateRow').prop('checked', true)
row
.find('.js-activateRow')
.prop('checked', true)
.trigger('change')
row.addClass('is-active')
element
.closest('td')
.find('.js-timeConvertTo')
row
.find("[name='#{element.data('name')}']")
.val(@toMinutes(inText) || '')
)
@ -56,9 +66,19 @@ class App.UiElement.sla_times
$(e.currentTarget).closest('tr').find('.checkbox-replacement').click()
)
# toggle update type on clicking around the element
item.find('.js-forward-radio').bind('click', (e) ->
elem = $(e.currentTarget).closest('p').find('.js-updateTypeSelector')
elem.prop('checked', true)
elem.trigger('change')
)
# focus time input on clicking surrounding cell
item.find('.js-focus-input').bind('click', (e) ->
$(e.currentTarget).find('.form-control').focus()
$(e.currentTarget)
.find('.form-control:visible')
.focus()
)
# show placeholder instead of 00:00
@ -67,15 +87,36 @@ class App.UiElement.sla_times
$(e.currentTarget).val('')
)
# switch update/response times when type is selected accordingly
item.find('.js-updateTypeSelector').bind('change', (e) ->
element = $(e.target)
row = element.closest('tr')
row.find('.js-activateRow').prop('checked', true)
row.addClass('is-active')
row
.find('.js-timeConvertFrom')
.addClass('hidden')
.val('')
row
.find('.js-timeConvertTo')
.val('')
row
.find("[data-name='#{element.val()}_time']")
.removeClass('hidden')
)
# set initial active/inactive rows
item.find('.js-timeConvertFrom').each(->
row = $(@).closest('tr')
checkbox = row.find('.js-activateRow')
if $(@).val()
checkbox.prop('checked', true)
row.addClass('is-active')
else
checkbox.prop('checked', false)
return if !$(@).val()
checkbox.prop('checked', true)
row.addClass('is-active')
)
item

View file

@ -26,12 +26,6 @@ class Sla extends App.ControllerSubContent
sortBy: 'name'
)
for sla in slas
if sla.first_response_time
sla.first_response_time_in_text = @toText(sla.first_response_time)
if sla.update_time
sla.update_time_in_text = @toText(sla.update_time)
if sla.solution_time
sla.solution_time_in_text = @toText(sla.solution_time)
sla.rules = App.UiElement.ticket_selector.humanText(sla.condition)
sla.calendar = App.Calendar.find(sla.calendar_id)
@ -99,17 +93,4 @@ class Sla extends App.ControllerSubContent
container: @el.closest('.content')
)
toText: (m) ->
m = parseInt(m)
return if !m
minutes = m % 60
hours = Math.floor(m / 60)
if minutes < 10
minutes = "0#{minutes}"
if hours < 10
hours = "0#{hours}"
"#{hours}:#{minutes}"
App.Config.set('Sla', { prio: 2900, name: 'SLAs', parent: '#manage', target: '#manage/slas', controller: Sla, permission: ['admin.sla'] }, 'NavBarAdmin')

View file

@ -36,7 +36,7 @@ App.ViewHelpers =
App.Utils.decimal(data, positions)
# define time_duration / mm:ss / hh:mm:ss format helper
time_duration: (time) ->
time_duration: (time, show_seconds = true) ->
return '' if !time
return '' if isNaN(parseInt(time))
@ -48,7 +48,7 @@ App.ViewHelpers =
# Output like "1:01" or "4:03:59" or "123:03:59"
mins = "0#{mins}" if mins < 10
secs = "0#{secs}" if secs < 10
if hrs > 0
if hrs > 0 && show_seconds
return "#{hrs}:#{mins}:#{secs}"
"#{mins}:#{secs}"

View file

@ -1,5 +1,5 @@
class App.Sla extends App.Model
@configure 'Sla', 'name', 'first_response_time', 'update_time', 'solution_time', 'condition', 'calendar_id'
@configure 'Sla', 'name', 'first_response_time', 'response_time', 'update_time', 'solution_time', 'condition', 'calendar_id'
@extend Spine.Model.Ajax
@url: @apiPath + '/slas'
@configure_attributes = [
@ -12,6 +12,7 @@ class App.Sla extends App.Model
{ name: 'updated_by_id', display: 'Updated by', relation: 'User', readonly: 1 },
{ name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 },
{ name: 'first_response_time',skipRendering: true },
{ name: 'response_time', skipRendering: true },
{ name: 'update_time', skipRendering: true },
{ name: 'solution_time', skipRendering: true },
]

View file

@ -6,7 +6,7 @@
<th><%- @T('Time') %> <span class="text-muted"><%- @T('in hours') %></span>
</thead>
<tbody>
<tr class="form-group">
<tr>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen dont-grey-out">
<input type="checkbox" class="js-activateRow" id="first_response_time" name="first_response_time_enabled">
@ -16,25 +16,51 @@
<td class="u-clickable js-forward-click">
<div><%- @T('First Response Time') %></div>
<p class="subtle"><%- @T('Timeframe for the first response.') %></p>
<td class="u-clickable js-focus-input">
<td class="u-clickable js-focus-input form-group">
<input type="hidden" name="first_response_time" value="<%= @first_response_time %>" class="js-timeConvertTo">
<input type="text" value="<%= @first_response_time_in_text %>" class="form-control form-control--small timeframe js-timeConvertFrom" placeholder="hh:mm" name="first_response_time_in_text" data-name="first_response_time">
<tr class="form-group">
<tr>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen dont-grey-out">
<input type="checkbox" class="js-activateRow" id="update_time" name="update_time_enabled">
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-clickable js-forward-click">
<div><%- @T('Update Time') %></div>
<p class="subtle"><%- @T('Timeframe for every following response.') %></p>
<td>
<input type="hidden" name="update_time" value="<%= @update_time %>" class="js-timeConvertTo">
<input type="text" value="<%= @update_time_in_text %>" class="form-control form-control--small timeframe js-timeConvertFrom" placeholder="hh:mm" name="update_time_in_text" data-name="update_time">
<div class="u-clickable js-forward-click">
<div><%- @T('Update Time') %></div>
<p class="subtle"><%- @T('Timeframe between agent updates or for an agent to respond.') %></p>
</div>
<tr class="form-group">
<p class="sla_radio_container js-forward-radio">
<label class="inline-label radio-replacement">
<input class="js-updateTypeSelector" type="radio" name="update_type" value="update" <% if @update_time: %>checked<% end %>>
<%- @Icon('radio', 'icon-unchecked') %>
<%- @Icon('radio-checked', 'icon-checked') %>
<span class="label-text"><%- @T('between agent updates') %></span>
</label>
</p>
<p class="sla_radio_container js-forward-radio u-clickable">
<label class="inline-label radio-replacement">
<input class="js-updateTypeSelector" type="radio" name="update_type" value="response" <% if @response_time: %>checked<% end %>>
<%- @Icon('radio', 'icon-unchecked') %>
<%- @Icon('radio-checked', 'icon-checked') %>
<span class="label-text"><%- @T('for an agent to respond') %></span>
</label>
</p>
<td class="form-group u-clickable js-focus-input u-clickable">
<span class="form-group">
<input type="hidden" name="update_time" value="<%= @update_time %>" class="js-timeConvertTo">
<input type="text" value="<%= @update_time_in_text %>" class="form-control form-control--small timeframe js-timeConvertFrom <% if @response_time: %>hidden<% end %>" placeholder="hh:mm" name="update_time_in_text" data-name="update_time">
</span>
<span class="form-group">
<input type="hidden" name="response_time" value="<%= @response_time %>" class="js-timeConvertTo">
<input type="text" value="<%= @response_time_in_text %>" class="form-control form-control--small timeframe js-timeConvertFrom <% if !@response_time: %>hidden<% end %>" placeholder="hh:mm" name="response_time_in_text" data-name="response_time">
</span>
<tr>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen dont-grey-out">
<input type="checkbox" id="solution_time" class="js-activateRow" name="solution_time_enabled">
@ -44,7 +70,7 @@
<td class="u-clickable js-forward-click">
<div><%- @T('Solution Time') %></div>
<p class="subtle"><%- @T('Timeframe for solving the problem.') %></p>
<td>
<td class="form-group u-clickable js-focus-input">
<input type="hidden" name="solution_time" value="<%= @solution_time %>" class="js-timeConvertTo">
<input type="text" value="<%= @solution_time_in_text %>" class="form-control form-control--small timeframe js-timeConvertFrom" placeholder="hh:mm" name="solution_time_in_text" data-name="solution_time">

View file

@ -34,15 +34,19 @@
<div class="action-block action-block--flex">
<div class="label"><%- @T('Escalation Times') %></div>
<% if sla.first_response_time: %>
<%- sla.first_response_time_in_text %> <%- @T('hours') %> - <%- @T('First Response Time') %>
<%- @time_duration(sla.first_response_time, false) %> <%- @T('hours') %> - <%- @T('First Response Time') %>
<% end %>
<% if sla.response_time: %>
<br>
<%- @time_duration(sla.response_time, false) %> <%- @T('hours') %> - <%- @T('Response Time') %>
<% end %>
<% if sla.update_time: %>
<br>
<%- sla.update_time_in_text %> <%- @T('hours') %> - <%- @T('Update Time') %>
<%- @time_duration(sla.update_time, false) %> <%- @T('hours') %> - <%- @T('Update Time') %>
<% end %>
<% if sla.solution_time: %>
<br>
<%- sla.solution_time_in_text %> <%- @T('hours') %> - <%- @T('Solution Time') %>
<%- @time_duration(sla.solution_time, false) %> <%- @T('hours') %> - <%- @T('Solution Time') %>
<% end %>
<br>
</div>

View file

@ -13157,3 +13157,10 @@ span.is-disabled {
.text-modules-box {
max-height: 40vh;
}
.sla_times {
.sla_radio_container {
padding-top: 0.5em;
padding-left: 0.5em;
}
}

View file

@ -16,7 +16,13 @@ class CoreWorkflow::Custom::AdminSla < CoreWorkflow::Custom::Backend
end
def update_time_enabled
return 'set_mandatory' if params['update_time_enabled'].present?
return 'set_mandatory' if params['update_time_enabled'].present? && params['update_type'] == 'update'
'set_optional'
end
def response_time_enabled
return 'set_mandatory' if params['update_time_enabled'].present? && params['update_type'] == 'response'
'set_optional'
end
@ -32,6 +38,7 @@ class CoreWorkflow::Custom::AdminSla < CoreWorkflow::Custom::Backend
# make fields mandatory if checkbox is checked
result(first_response_time_enabled, 'first_response_time_in_text')
result(update_time_enabled, 'update_time_in_text')
result(response_time_enabled, 'response_time_in_text')
result(solution_time_enabled, 'solution_time_in_text')
end
end

View file

@ -13,6 +13,8 @@ class Sla < ApplicationModel
validates :name, presence: true
validate :cannot_have_response_and_update
store :condition
store :data
@ -32,4 +34,12 @@ class Sla < ApplicationModel
end
fallback
end
private
def cannot_have_response_and_update
return if response_time.blank? || update_time.blank?
errors.add :base, 'cannot have both response time and update time'
end
end

View file

@ -446,6 +446,7 @@ class CreateTicket < ActiveRecord::Migration[4.2]
t.references :calendar, null: false
t.column :name, :string, limit: 150, null: true
t.column :first_response_time, :integer, null: true
t.column :response_time, :integer, null: true
t.column :update_time, :integer, null: true
t.column :solution_time, :integer, null: true
t.column :condition, :text, limit: 500.kilobytes + 1, null: true

View file

@ -0,0 +1,13 @@
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
class SlaAddResponseTime < ActiveRecord::Migration[5.2]
def change
return if !Setting.exists?(name: 'system_init_done')
change_table :slas do |t|
t.integer :response_time
end
Sla.reset_column_information
end
end

View file

@ -97,7 +97,7 @@ class Escalation
end
def update_escalations
ticket.assign_attributes [escalation_first_response, escalation_update, escalation_close]
ticket.assign_attributes [escalation_first_response, escalation_response, escalation_update, escalation_close]
.compact
.each_with_object({}) { |elem, memo| memo.merge!(elem) }
@ -105,7 +105,7 @@ class Escalation
end
def update_statistics
ticket.assign_attributes [statistics_first_response, statistics_update, statistics_close]
ticket.assign_attributes [statistics_first_response, statistics_response, statistics_update, statistics_close]
.compact
.each_with_object({}) { |elem, memo| memo.merge!(elem) }
end
@ -130,11 +130,41 @@ class Escalation
}
end
def escalation_update
def escalation_update_reset
return if skip_escalation? && !preferences.last_update_at_changed?(ticket)
return if sla.response_time.present? || sla.update_time.present?
{ update_escalation_at: nil }
end
def escalation_response_timestamp
return if escalation_disabled? || ticket.agent_responded?
ticket.last_contact_customer_at
end
def escalation_response
return if sla.response_time.nil?
return if skip_escalation? && !preferences.last_update_at_changed?(ticket)
nullify = escalation_disabled? || ticket.agent_responded?
timestamp = nullify ? nil : ticket.last_contact_customer_at
timestamp = escalation_response_timestamp
{
update_escalation_at: timestamp ? calculate_time(timestamp, sla.response_time) : nil
}
end
def escalation_update_timestamp
return if escalation_disabled?
ticket.last_contact_agent_at || ticket.created_at
end
def escalation_update
return if sla.update_time.nil?
return if skip_escalation? && !preferences.last_update_at_changed?(ticket)
timestamp = escalation_update_timestamp
{
update_escalation_at: timestamp ? calculate_time(timestamp, sla.update_time) : nil
@ -186,11 +216,39 @@ class Escalation
}
end
def skip_statistics_response?
return true if !forced? && !preferences.last_update_at_changed?(ticket)
return true if !sla.response_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_response_applicable?(minutes)
ticket.update_in_min.blank? || minutes > ticket.update_in_min # keep longest timespan
end
def statistics_response
return if skip_statistics_response?
minutes = calculate_minutes(ticket.last_contact_customer_at, ticket.last_contact_agent_at)
return if !forced? && !statistics_response_applicable?(minutes)
{
update_in_min: minutes,
update_diff_in_min: minutes ? (sla.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?
ticket.last_contact_agent_at.blank?
end
# ATTENTION: Recalculation after SLA change won't happen
@ -201,10 +259,28 @@ class Escalation
ticket.update_in_min.blank? || minutes > ticket.update_in_min # keep longest timespan
end
def statistics_update_responses
ticket
.articles
.reverse
.lazy
.select { |article| article.sender&.name == 'Agent' && article.type&.communication }
.first(2)
end
def statistics_update_minutes
last_agent_responses = statistics_update_responses
from = last_agent_responses.second&.created_at || ticket.created_at
to = last_agent_responses.first&.created_at
calculate_minutes(from, to)
end
def statistics_update
return if skip_statistics_update?
minutes = calculate_minutes(ticket.last_contact_customer_at, ticket.last_contact_agent_at)
minutes = statistics_update_minutes
return if !forced? && !statistics_update_applicable?(minutes)

View file

@ -170,12 +170,15 @@ test('form checks', function() {
first_response_time: '150',
first_response_time_enabled: 'on',
first_response_time_in_text: '02:30',
response_time: '',
response_time_in_text: '',
solution_time: '',
solution_time_enabled: undefined,
solution_time_in_text: '',
update_time: '45',
update_time_enabled: 'on',
update_time_in_text: '00:45',
update_type: 'update',
working_hours: {
mon: {
active: true,
@ -318,15 +321,90 @@ test('form checks', function() {
first_response_time: '30',
first_response_time_enabled: 'on',
first_response_time_in_text: '00:30',
response_time: '',
response_time_in_text: '',
solution_time: '',
solution_time_enabled: undefined,
solution_time_in_text: '',
update_time: '',
update_time_enabled: undefined,
update_time_in_text: '',
update_type: undefined,
}
deepEqual(params, test_params, 'form param check')
// change sla times
el.find('#update_time').attr('checked', false)
el.find('[value=response]').click()
el.find('[name="response_time_in_text"]').val('4:30').trigger('blur')
var params = App.ControllerForm.params(el)
var test_params = {
priority1_id: '1',
priority2_id: ['1', '2'],
priority3_id: '2',
priority4_id: '2',
priority5_id: '1',
working_hours: {
mon: {
active: true,
timeframes: [
['09:00','17:00']
]
},
tue: {
active: true,
timeframes: [
['00:00','22:00']
]
},
wed: {
active: true,
timeframes: [
['09:00','17:00']
]
},
thu: {
active: true,
timeframes: [
['09:00','12:00'],
['13:00','17:00']
]
},
fri: {
active: true,
timeframes: [
['09:00','17:00']
]
},
sat: {
active: false,
timeframes: [
['10:00','14:00']
]
},
sun: {
active: false,
timeframes: [
['10:00','14:00']
]
},
},
first_response_time: '30',
first_response_time_enabled: 'on',
first_response_time_in_text: '00:30',
response_time: '270',
response_time_in_text: '04:30',
solution_time: '',
solution_time_enabled: undefined,
solution_time_in_text: '',
update_time: '',
update_time_enabled: 'on',
update_time_in_text: '',
update_type: 'response'
}
deepEqual(params, test_params, 'form param check post response')
/* empty params or defaults */
$('#forms').append('<hr><h1>form condition check</h1><form id="form2"></form>')
var el = $('#form2')

View file

@ -57,6 +57,30 @@ test("form SLA times highlights and shows settings accordingly", function(assert
equal(firstRow.find('input[data-name=first_response_time]').val(), '')
ok(secondRow.hasClass('is-active'))
equal(secondRow.find('input[data-name=update_time]').val(), '04:00')
equal(secondRow.find('input[name=update_type]:checked').val(), 'update')
$('#forms').append('<hr><h1>SLA with response time set</h1><form id="form3a"></form>')
var el = $('#form3a')
var item = new App.Sla()
item.id = '123'
item.response_time = 180
new App.ControllerForm({
el: el,
model: item.constructor,
params: item
});
var firstRow = el.find('.sla_times tbody > tr:first')
var secondRow = el.find('.sla_times tbody > tr:nth-child(2)')
notOk(firstRow.hasClass('is-active'))
equal(firstRow.find('input[data-name=first_response_time]').val(), '')
ok(secondRow.hasClass('is-active'))
equal(secondRow.find('input[data-name=response_time]').val(), '03:00')
equal(secondRow.find('input[name=update_type]:checked').val(), 'response')
})
test("form SLA times clears field instead of 00:00", function(assert) {

View file

@ -3,13 +3,18 @@
require 'rails_helper'
RSpec.describe ::Escalation do
let(:instance) { described_class.new ticket, force: force }
let(:instance) { described_class.new ticket, force: force }
let(:instance_with_history) { described_class.new ticket_with_history, force: force }
let(:instance_with_open) { described_class.new open_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(:calendar) { create(:calendar, :'24/7') }
let(:sla_247) { create(:sla, :condition_blank, solution_time: 75, calendar: calendar) }
let(:sla_247_response) { create(:sla, :condition_blank, first_response_time: 30, response_time: 45, solution_time: 75, calendar: calendar) }
let(:sla_247_update) { create(:sla, :condition_blank, first_response_time: 30, update_time: 60, solution_time: 75, calendar: calendar) }
let(:ticket_with_history) do
freeze_time
ticket = create(:ticket)
@ -252,7 +257,7 @@ RSpec.describe ::Escalation do
# https://github.com/zammad/zammad/issues/3140
it 'customer contact sets #update_escalation_at' do
sla_247
sla_247_response
ticket
create(:ticket_article, :inbound_email, ticket: ticket)
@ -261,7 +266,7 @@ RSpec.describe ::Escalation do
context 'with ticket with sla and customer enquiry' do
before do
sla_247
sla_247_response
ticket
travel 10.minutes
@ -289,34 +294,120 @@ RSpec.describe ::Escalation do
let(:force) { true } # initial calculation
it 'returns attribute' do
sla_247
sla_247_response
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
expect(result).to include first_response_escalation_at: 90.minutes.ago
end
it 'returns nil when no sla#first_response_time' do
sla_247.update! first_response_time: nil
sla_247_response.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
describe '#escalation_update_reset' do
it 'resets to nil when no sla#response_time and sla#update_time' 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
result = instance_with_history.send(:escalation_update_reset)
expect(result).to include(update_escalation_at: nil)
end
it 'returns nil when no sla#response_time' do
sla_247_update
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_update_reset)
expect(result).to be_nil
end
it 'returns nil when no sla#update_time' do
sla_247.update! update_time: nil
sla_247_response
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)
result = instance_with_history.send(:escalation_update_reset)
expect(result).to be_nil
end
end
describe '#escalation_response' do
it 'returns attribute' do
sla_247_response
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_response)
expect(result).to include update_escalation_at: 75.minutes.ago
end
it 'returns nil when no sla#response_time' do
sla_247
allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
result = instance_with_history.send(:escalation_response)
expect(result).to be_nil
end
it 'response time is calculated when waiting for the first response with update-only SLA' do
sla_247_response.update! first_response_time: nil
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_response)
expect(result).to include update_escalation_at: 75.minutes.ago
end
end
describe '#escalation_update' do
context 'when has open ticket with history' do
before do
sla_247_update
open_ticket_with_history
allow(instance_with_open).to receive(:escalation_disabled?).and_return(false)
end
it 'update time is calculated before first agent response' do
result = instance_with_open.send(:escalation_update)
expect(result).to include update_escalation_at: 50.minutes.from_now
end
it 'update time is calculated after agent response' do
create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
result = instance_with_open.send(:escalation_update)
expect(result).to include update_escalation_at: 60.minutes.from_now
end
context 'when agent responds' do
before do
create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
travel 30.minutes
end
it 'update time is calculated after 2nd customer enquiry' do
create(:ticket_article, :inbound_email, ticket: open_ticket_with_history)
result = instance_with_open.send(:escalation_update)
expect(result).to include update_escalation_at: 30.minutes.from_now
end
it 'update time is calculated after 2nd agent response interrupted by customer' do
create(:ticket_article, :inbound_email, ticket: open_ticket_with_history)
travel 30.minutes
create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
result = instance_with_open.send(:escalation_update)
expect(result).to include update_escalation_at: 60.minutes.from_now
end
it 'update time is calculated after 2nd agent response in a row' do
create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
result = instance_with_open.send(:escalation_update)
expect(result).to include update_escalation_at: 60.minutes.from_now
end
end
end
it 'returns nil when no sla#update_time' do
sla_247
allow(instance_with_open).to receive(:escalation_disabled?).and_return(false)
result = instance_with_open.send(:escalation_update)
expect(result).to be_nil
end
end
@ -387,16 +478,16 @@ RSpec.describe ::Escalation do
describe '#statistics_first_response' do
it 'calculates statistics' do
sla_247
sla_247_response
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)
expect(result).to include(first_response_in_min: 75, first_response_diff_in_min: -45)
end
it 'does not touch statistics when sla time is nil' do
sla_247.update! first_response_time: nil
sla_247_response.update! first_response_time: nil
ticket_with_history.first_response_at = 45.minutes.ago
instance_with_history.force!
@ -405,9 +496,9 @@ RSpec.describe ::Escalation do
end
end
describe '#statistics_update' do
describe '#statistics_response' do
before do
sla_247
sla_247_response
freeze_time
end
@ -415,22 +506,22 @@ RSpec.describe ::Escalation 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)
result = instance_with_history.send(:statistics_response)
expect(result).to include(update_in_min: 1, update_diff_in_min: 44)
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)
result = instance_with_history.send(:statistics_response)
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)
result = instance.send(:statistics_response)
expect(result).to be_nil
end
@ -440,7 +531,7 @@ RSpec.describe ::Escalation do
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)
expect(instance.send(:statistics_response)).to include(update_in_min: 10, update_diff_in_min: 35)
end
context 'with multiple exchanges and later one being quicker' do
@ -455,7 +546,95 @@ RSpec.describe ::Escalation do
end
it 'keeps statistics of longest exchange' do
expect(ticket.reload).to have_attributes(update_in_min: 10, update_diff_in_min: 50)
expect(ticket.reload).to have_attributes(update_in_min: 10, update_diff_in_min: 35)
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_update' do
before do
sla_247_update
freeze_time
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
context 'when agent responds after 20 minutes' do
before do
ticket
travel 20.minutes
create(:ticket_article, :outbound_email, ticket: ticket)
end
it 'does not touch statistics when customer response is most recent' do
travel 30.minutes
create(:ticket_article, :inbound_email, ticket: ticket)
result = instance.send(:statistics_update)
expect(result).to include(update_diff_in_min: 40, update_in_min: 20)
end
it 'calculates statistics when only agent update present' do
result = instance.send(:statistics_update)
expect(result).to include(update_diff_in_min: 40, update_in_min: 20)
end
it 'calculates statistics when multiple agent updates present' do
travel 30.minutes
create(:ticket_article, :outbound_email, ticket: ticket)
result = instance.send(:statistics_update)
expect(result).to include(update_diff_in_min: 30, update_in_min: 30)
end
context 'when customer responds' do
before do
travel 10.minutes
create(:ticket_article, :inbound_email, ticket: ticket)
end
it 'calculates statistics when multiple agent updates intercepted by customer' do
travel 35.minutes
create(:ticket_article, :outbound_email, ticket: ticket)
result = instance.send(:statistics_update)
expect(result).to include(update_diff_in_min: 15, update_in_min: 45)
end
end
end
context 'with multiple exchanges and later one being quicker' do
before do
travel 10.minutes
create(:ticket_article, :outbound_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: 5, update_diff_in_min: 55)
end
end
@ -512,7 +691,7 @@ RSpec.describe ::Escalation do
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
expect(open_ticket_with_history.escalation_at).to eq open_ticket_with_history.created_at + 85.minutes
end
def without_update_escalation_information_callback(&block)

View file

@ -303,7 +303,7 @@ RSpec.describe Calendar, type: :model do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, response_time: 120, solution_time: nil) }
before do
queue_adapter.perform_enqueued_jobs = true
@ -400,7 +400,7 @@ RSpec.describe Calendar, type: :model do
calendar: calendar,
condition: {},
first_response_time: 120,
update_time: 180,
response_time: 180,
solution_time: 240)
end

View file

@ -377,7 +377,7 @@ RSpec.describe CoreWorkflow, type: :model do
base_payload.merge(
'screen' => 'edit',
'class_name' => 'Sla',
'params' => { 'update_time_enabled' => 'true' }
'params' => { 'update_time_enabled' => 'true', 'update_type' => 'update' }
)
end

View file

@ -11,7 +11,7 @@ RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do
context 'when affected Ticket existed' do
subject(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 180, solution_time: 240) }
subject(:sla) { create(:sla, calendar: calendar, first_response_time: 60, response_time: 180, solution_time: 240) }
let(:calendar) { create(:calendar, :business_hours_9_17) }
let!(:ticket) { create(:ticket) }
@ -78,7 +78,7 @@ RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do
},
},
first_response_time: 10,
update_time: 20,
response_time: 20,
solution_time: 300)
end
@ -92,7 +92,7 @@ RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do
},
},
first_response_time: 120,
update_time: 180,
response_time: 180,
solution_time: 240)
end
@ -130,7 +130,7 @@ RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do
},
calendar: calendar,
first_response_time: 60,
update_time: 120,
response_time: 120,
solution_time: 180)
end
@ -172,7 +172,7 @@ RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do
},
calendar: calendar,
first_response_time: 60,
update_time: 120,
response_time: 120,
solution_time: 180)
end
@ -214,7 +214,7 @@ RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do
},
calendar: calendar,
first_response_time: 60,
update_time: 120,
response_time: 120,
solution_time: 180)
end

View file

@ -35,6 +35,28 @@ RSpec.describe Sla, type: :model do
end
end
describe '#cannot_have_response_and_update' do
it 'allows neither #response_time nor #update_time' do
instance = build(:sla, response_time: nil, update_time: nil)
expect(instance).to be_valid
end
it 'allows #response_time' do
instance = build(:sla, response_time: 180, update_time: nil)
expect(instance).to be_valid
end
it 'allows #update_time' do
instance = build(:sla, response_time: nil, update_time: 180)
expect(instance).to be_valid
end
it 'denies both #response_time and #update_time' do
instance = build(:sla, response_time: 180, update_time: 180)
expect(instance).not_to be_valid
end
end
describe '.for_ticket' do
it 'returns matching SLA for the ticket' do
sla

View file

@ -39,7 +39,7 @@ RSpec.shared_examples 'Ticket::Article::HasTicketContactAttributesImpact' do
},
})
end
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: 180) }
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, response_time: 120, solution_time: 180) }
before do
sla
@ -174,7 +174,7 @@ RSpec.shared_examples 'Ticket::Article::HasTicketContactAttributesImpact' do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, response_time: 120, solution_time: nil) }
before do
sla
@ -252,7 +252,7 @@ RSpec.shared_examples 'Ticket::Article::HasTicketContactAttributesImpact' do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, response_time: 120, solution_time: nil) }
before do
Setting.set('ticket_last_contact_behaviour', 'based_on_customer_reaction')

View file

@ -41,7 +41,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
},
})
end
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: 180) }
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, response_time: 120, solution_time: 180) }
let(:article) { create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2013-03-21 09:30:00 UTC', updated_at: '2013-03-21 09:30:00 UTC') }
before do
@ -434,7 +434,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
},
},
first_response_time: 60,
update_time: 180,
response_time: 180,
solution_time: 240)
end
@ -512,7 +512,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
})
end
let(:sla) { create(:sla, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 250) }
let(:sla) { create(:sla, calendar: calendar, first_response_time: 120, response_time: 180, solution_time: 250) }
context 'when Ticket is reopened' do
@ -663,7 +663,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 250) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, response_time: 180, solution_time: 250) }
before do
sla
@ -774,7 +774,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 240) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, response_time: 180, solution_time: 240) }
before do
sla
@ -851,7 +851,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 240) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, response_time: 180, solution_time: 240) }
before do
sla
@ -950,7 +950,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 240) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, response_time: 180, solution_time: 240) }
before do
sla
@ -1068,7 +1068,7 @@ RSpec.shared_examples 'Ticket::Escalation' do
})
end
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 1200, solution_time: nil) }
let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, response_time: 1200, solution_time: nil) }
before do
sla

View file

@ -1167,7 +1167,7 @@ RSpec.describe Ticket, type: :model do
describe '#escalation_at' do
before { travel_to(Time.current) } # freeze time
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 180, solution_time: 240) }
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, response_time: 180, solution_time: 240) }
let(:calendar) { create(:calendar, :'24/7') }
context 'with no SLAs in the system' do
@ -1378,7 +1378,7 @@ RSpec.describe Ticket, type: :model do
describe '#first_response_escalation_at' do
before { travel_to(Time.current) } # freeze time
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 180, solution_time: 240) }
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, response_time: 180, solution_time: 240) }
let(:calendar) { create(:calendar, :'24/7') }
context 'with no SLAs in the system' do
@ -1410,7 +1410,7 @@ RSpec.describe Ticket, type: :model do
describe '#update_escalation_at' do
before { travel_to(Time.current) } # freeze time
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 180, solution_time: 240) }
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, response_time: 180, solution_time: 240) }
let(:calendar) { create(:calendar, :'24/7') }
context 'with no SLAs in the system' do
@ -1450,7 +1450,7 @@ RSpec.describe Ticket, type: :model do
describe '#close_escalation_at' do
before { travel_to(Time.current) } # freeze time
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 180, solution_time: 240) }
let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, response_time: 180, solution_time: 240) }
let(:calendar) { create(:calendar, :'24/7') }
context 'with no SLAs in the system' do

View file

@ -4,8 +4,8 @@ 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(:sla_response) { 3.hours }
let(:sla_close) { 4.hours }
let!(:mail_group) { create(:group, email_address: create(:email_address)) }
@ -14,7 +14,7 @@ RSpec.describe 'Ticket Escalation', type: :request do
create(:sla,
calendar: calendar,
first_response_time: sla_first_response / 1.minute,
update_time: sla_update / 1.minute,
response_time: sla_response / 1.minute,
solution_time: sla_close / 1.minute)
end

View file

@ -14,6 +14,7 @@ RSpec.describe 'Manage > Sla', type: :system do
# enable all checkboxes
page.find('input#update_time', visible: false).find(:xpath, './/..').click
page.first('.js-updateTypeSelector', visible: false).click
page.find('input#solution_time', visible: false).find(:xpath, './/..').click
# check if required