Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473dc978e0 |
+9
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -40,9 +41,17 @@ const slaStatusText = computed(() => {
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
const businessHoursConfig = getBusinessHoursConfig(
|
||||
appliedSLA.value?.slaPolicy,
|
||||
props.conversation?.inbox
|
||||
);
|
||||
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
chat: props.conversation,
|
||||
options: businessHoursConfig
|
||||
? { businessHours: businessHoursConfig }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -58,9 +59,17 @@ const groupClass = computed(() => {
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
const businessHoursConfig = getBusinessHoursConfig(
|
||||
appliedSLA.value?.sla_policy,
|
||||
props.chat?.inbox
|
||||
);
|
||||
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
options: businessHoursConfig
|
||||
? { businessHours: businessHoursConfig }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Helper functions for SLA business hours configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts business hours configuration from SLA policy and inbox data
|
||||
* Supports both camelCase and snake_case property naming conventions
|
||||
*
|
||||
* @param {Object} slaPolicy - The SLA policy object
|
||||
* @param {Object} inbox - The inbox object with working hours configuration
|
||||
* @returns {Object|null} Business hours configuration for utils package, or null if not applicable
|
||||
*/
|
||||
export const getBusinessHoursConfig = (slaPolicy, inbox) => {
|
||||
// Handle both camelCase and snake_case property names
|
||||
const onlyDuringBusinessHours =
|
||||
slaPolicy?.only_during_business_hours ?? slaPolicy?.onlyDuringBusinessHours;
|
||||
const workingHoursEnabled =
|
||||
inbox?.working_hours_enabled ?? inbox?.workingHoursEnabled;
|
||||
|
||||
if (!onlyDuringBusinessHours || !workingHoursEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert working hours format to match utils expectation
|
||||
const workingHours = {};
|
||||
const dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
|
||||
// Handle both camelCase and snake_case working hours arrays
|
||||
const inboxWorkingHours = inbox.working_hours ?? inbox.workingHours;
|
||||
|
||||
if (inboxWorkingHours) {
|
||||
inboxWorkingHours.forEach(dayConfig => {
|
||||
// Handle both property naming conventions
|
||||
const dayOfWeek = dayConfig.day_of_week ?? dayConfig.dayOfWeek;
|
||||
const closedAllDay = dayConfig.closed_all_day ?? dayConfig.closedAllDay;
|
||||
const openHour = dayConfig.open_hour ?? dayConfig.openHour;
|
||||
const openMinutes = dayConfig.open_minutes ?? dayConfig.openMinutes;
|
||||
const closeHour = dayConfig.close_hour ?? dayConfig.closeHour;
|
||||
const closeMinutes = dayConfig.close_minutes ?? dayConfig.closeMinutes;
|
||||
|
||||
const dayName = dayMap[dayOfWeek];
|
||||
|
||||
if (closedAllDay) {
|
||||
workingHours[dayName] = null;
|
||||
} else {
|
||||
// Convert to HH:MM format
|
||||
const startHour = String(openHour || 0).padStart(2, '0');
|
||||
const startMin = String(openMinutes || 0).padStart(2, '0');
|
||||
const endHour = String(closeHour || 0).padStart(2, '0');
|
||||
const endMin = String(closeMinutes || 0).padStart(2, '0');
|
||||
|
||||
workingHours[dayName] = {
|
||||
start: `${startHour}:${startMin}`,
|
||||
finish: `${endHour}:${endMin}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
working_hours_enabled: workingHoursEnabled,
|
||||
timezone: inbox.timezone || 'UTC',
|
||||
working_hours: workingHours,
|
||||
only_during_business_hours: onlyDuringBusinessHours,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
import { getBusinessHoursConfig } from '../slaHelper';
|
||||
|
||||
describe('slaHelper', () => {
|
||||
describe('getBusinessHoursConfig', () => {
|
||||
const mockWorkingHours = [
|
||||
{
|
||||
day_of_week: 1, // Monday
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 30,
|
||||
closed_all_day: false,
|
||||
},
|
||||
{
|
||||
day_of_week: 2, // Tuesday
|
||||
open_hour: 8,
|
||||
open_minutes: 30,
|
||||
close_hour: 18,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
},
|
||||
{
|
||||
day_of_week: 0, // Sunday
|
||||
closed_all_day: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockWorkingHoursCamelCase = [
|
||||
{
|
||||
dayOfWeek: 1, // Monday
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 30,
|
||||
closedAllDay: false,
|
||||
},
|
||||
{
|
||||
dayOfWeek: 2, // Tuesday
|
||||
openHour: 8,
|
||||
openMinutes: 30,
|
||||
closeHour: 18,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
},
|
||||
{
|
||||
dayOfWeek: 0, // Sunday
|
||||
closedAllDay: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('when business hours are not required', () => {
|
||||
it('returns null when SLA policy does not require business hours', () => {
|
||||
const slaPolicy = { only_during_business_hours: false };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when inbox does not have working hours enabled', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: false,
|
||||
working_hours: mockWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when both conditions are false', () => {
|
||||
const slaPolicy = { only_during_business_hours: false };
|
||||
const inbox = { working_hours_enabled: false };
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when slaPolicy is null', () => {
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(null, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when inbox is null', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when business hours are required - snake_case properties', () => {
|
||||
it('converts working hours correctly with snake_case properties', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours,
|
||||
timezone: 'America/New_York',
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toEqual({
|
||||
working_hours_enabled: true,
|
||||
timezone: 'America/New_York',
|
||||
working_hours: {
|
||||
sun: null, // Closed all day
|
||||
mon: {
|
||||
start: '09:00',
|
||||
finish: '17:30',
|
||||
},
|
||||
tue: {
|
||||
start: '08:30',
|
||||
finish: '18:00',
|
||||
},
|
||||
},
|
||||
only_during_business_hours: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing timezone by defaulting to UTC', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours.slice(0, 1), // Just Monday
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
it('handles zero hours and minutes correctly', () => {
|
||||
const workingHoursWithZeros = [
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: 0,
|
||||
open_minutes: 0,
|
||||
close_hour: 0,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
},
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: workingHoursWithZeros,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours.mon).toEqual({
|
||||
start: '00:00',
|
||||
finish: '00:00',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when business hours are required - camelCase properties', () => {
|
||||
it('converts working hours correctly with camelCase properties', () => {
|
||||
const slaPolicy = { onlyDuringBusinessHours: true };
|
||||
const inbox = {
|
||||
workingHoursEnabled: true,
|
||||
workingHours: mockWorkingHoursCamelCase,
|
||||
timezone: 'America/Los_Angeles',
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toEqual({
|
||||
working_hours_enabled: true,
|
||||
timezone: 'America/Los_Angeles',
|
||||
working_hours: {
|
||||
sun: null, // Closed all day
|
||||
mon: {
|
||||
start: '09:00',
|
||||
finish: '17:30',
|
||||
},
|
||||
tue: {
|
||||
start: '08:30',
|
||||
finish: '18:00',
|
||||
},
|
||||
},
|
||||
only_during_business_hours: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed property naming conventions', () => {
|
||||
it('handles mixed snake_case and camelCase properties', () => {
|
||||
const slaPolicy = {
|
||||
only_during_business_hours: true, // snake_case
|
||||
onlyDuringBusinessHours: false, // This should be ignored due to nullish coalescing
|
||||
};
|
||||
const inbox = {
|
||||
working_hours_enabled: true, // snake_case
|
||||
workingHours: mockWorkingHoursCamelCase, // camelCase
|
||||
timezone: 'UTC',
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.only_during_business_hours).toBe(true);
|
||||
expect(result.working_hours_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('prioritizes snake_case over camelCase when both exist', () => {
|
||||
const slaPolicy = {
|
||||
only_during_business_hours: true,
|
||||
onlyDuringBusinessHours: false,
|
||||
};
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
workingHoursEnabled: false,
|
||||
working_hours: mockWorkingHours,
|
||||
workingHours: mockWorkingHoursCamelCase,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.only_during_business_hours).toBe(true);
|
||||
expect(result.working_hours_enabled).toBe(true);
|
||||
// Should use snake_case working_hours
|
||||
expect(result.working_hours.mon.start).toBe('09:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty working hours array', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: [],
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours).toEqual({});
|
||||
});
|
||||
|
||||
it('handles missing working hours property', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
// No working_hours property
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours).toEqual({});
|
||||
});
|
||||
|
||||
it('handles undefined values in working hours', () => {
|
||||
const workingHoursWithUndefined = [
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: undefined,
|
||||
open_minutes: undefined,
|
||||
close_hour: undefined,
|
||||
close_minutes: undefined,
|
||||
closed_all_day: false,
|
||||
},
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: workingHoursWithUndefined,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours.mon).toEqual({
|
||||
start: '00:00',
|
||||
finish: '00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('pads single digit hours and minutes correctly', () => {
|
||||
const workingHoursSingleDigit = [
|
||||
{
|
||||
day_of_week: 3, // Wednesday
|
||||
open_hour: 9,
|
||||
open_minutes: 5,
|
||||
close_hour: 5,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
},
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: workingHoursSingleDigit,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours.wed).toEqual({
|
||||
start: '09:05',
|
||||
finish: '05:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles all days of the week correctly', () => {
|
||||
const fullWeekWorkingHours = [
|
||||
{ day_of_week: 0, closed_all_day: true }, // Sunday
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Monday
|
||||
{
|
||||
day_of_week: 2,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Tuesday
|
||||
{
|
||||
day_of_week: 3,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Wednesday
|
||||
{
|
||||
day_of_week: 4,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Thursday
|
||||
{
|
||||
day_of_week: 5,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Friday
|
||||
{ day_of_week: 6, closed_all_day: true }, // Saturday
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: fullWeekWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours).toEqual({
|
||||
sun: null,
|
||||
mon: { start: '09:00', finish: '17:00' },
|
||||
tue: { start: '09:00', finish: '17:00' },
|
||||
wed: { start: '09:00', finish: '17:00' },
|
||||
thu: { start: '09:00', finish: '17:00' },
|
||||
fri: { start: '09:00', finish: '17:00' },
|
||||
sat: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
class Sla::EvaluateAppliedSlaService
|
||||
pattr_initialize [:applied_sla!]
|
||||
include ReportingEventHelper
|
||||
|
||||
def perform
|
||||
check_sla_thresholds
|
||||
@@ -21,12 +22,58 @@ class Sla::EvaluateAppliedSlaService
|
||||
end
|
||||
end
|
||||
|
||||
def should_use_business_hours?(sla_policy, inbox)
|
||||
sla_policy.only_during_business_hours? && inbox.working_hours_enabled?
|
||||
end
|
||||
|
||||
# Calculates the SLA threshold deadline considering business hours if enabled.
|
||||
#
|
||||
# This method determines when an SLA will be breached by adding the threshold duration
|
||||
# to the start time. If business hours are enabled, it only counts time during working hours,
|
||||
# automatically skipping weekends and after-hours periods.
|
||||
def calculate_threshold_deadline(start_time, threshold_seconds, inbox, sla_policy)
|
||||
# Fall back to simple calendar time calculation if business hours not enabled
|
||||
return start_time.to_i + threshold_seconds unless should_use_business_hours?(sla_policy, inbox)
|
||||
|
||||
# Configure the working_hours gem with inbox-specific schedule and timezone
|
||||
configure_working_hours_for_calculation(inbox)
|
||||
|
||||
# Convert start time to inbox timezone for accurate business hours calculation
|
||||
start_time_in_timezone = start_time.in_time_zone(inbox.timezone).to_time
|
||||
|
||||
# Determine effective start time: if outside business hours, advance to next working time
|
||||
# Example: Saturday 6 PM conversation would start counting Monday 9 AM
|
||||
effective_start_time = if start_time_in_timezone.in_working_hours?
|
||||
start_time_in_timezone
|
||||
else
|
||||
WorkingHours.next_working_time(start_time_in_timezone)
|
||||
end
|
||||
|
||||
# Add working time duration
|
||||
# This automatically skips non-working hours, weekends, and holidays
|
||||
deadline = effective_start_time + threshold_seconds.working.seconds
|
||||
deadline.to_i
|
||||
end
|
||||
|
||||
def configure_working_hours_for_calculation(inbox)
|
||||
inbox_working_hours = configure_working_hours(inbox.working_hours)
|
||||
return if inbox_working_hours.blank?
|
||||
|
||||
WorkingHours::Config.working_hours = inbox_working_hours
|
||||
WorkingHours::Config.time_zone = inbox.timezone
|
||||
end
|
||||
|
||||
def still_within_threshold?(threshold)
|
||||
Time.zone.now.to_i < threshold
|
||||
end
|
||||
|
||||
def check_first_response_time_threshold(applied_sla, conversation, sla_policy)
|
||||
threshold = conversation.created_at.to_i + sla_policy.first_response_time_threshold.to_i
|
||||
threshold = calculate_threshold_deadline(
|
||||
conversation.created_at,
|
||||
sla_policy.first_response_time_threshold.to_i,
|
||||
conversation.inbox,
|
||||
sla_policy
|
||||
)
|
||||
return if first_reply_was_within_threshold?(conversation, threshold)
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
@@ -43,7 +90,12 @@ class Sla::EvaluateAppliedSlaService
|
||||
# Waiting on customer response, no need to check next response time threshold
|
||||
return if conversation.waiting_since.blank?
|
||||
|
||||
threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i
|
||||
threshold = calculate_threshold_deadline(
|
||||
conversation.waiting_since,
|
||||
sla_policy.next_response_time_threshold.to_i,
|
||||
conversation.inbox,
|
||||
sla_policy
|
||||
)
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla, 'nrt')
|
||||
@@ -61,7 +113,12 @@ class Sla::EvaluateAppliedSlaService
|
||||
def check_resolution_time_threshold(applied_sla, conversation, sla_policy)
|
||||
return if conversation.resolved?
|
||||
|
||||
threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i
|
||||
threshold = calculate_threshold_deadline(
|
||||
conversation.created_at,
|
||||
sla_policy.resolution_time_threshold.to_i,
|
||||
conversation.inbox,
|
||||
sla_policy
|
||||
)
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla, 'rt')
|
||||
|
||||
@@ -222,4 +222,278 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Business Hours SLA evaluation' do
|
||||
let!(:inbox) { create(:inbox, account: account, working_hours_enabled: true, timezone: 'UTC') }
|
||||
let!(:conversation) do
|
||||
create(:conversation,
|
||||
created_at: '15.05.2024 09:00'.to_datetime, assignee: user_1,
|
||||
account: sla_policy.account,
|
||||
sla_policy: sla_policy,
|
||||
inbox: inbox)
|
||||
end
|
||||
let!(:applied_sla) { conversation.applied_sla }
|
||||
|
||||
before do
|
||||
# Configure business hours: Mon-Fri 9 AM - 5 PM UTC
|
||||
(1..5).each do |day|
|
||||
create(:working_hour,
|
||||
inbox: inbox,
|
||||
day_of_week: day,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0)
|
||||
end
|
||||
# Weekend closed
|
||||
[0, 6].each do |day|
|
||||
create(:working_hour,
|
||||
inbox: inbox,
|
||||
day_of_week: day,
|
||||
closed_all_day: true)
|
||||
end
|
||||
|
||||
Time.zone = 'UTC'
|
||||
travel_to '15.05.2024 10:00'.to_datetime
|
||||
applied_sla.sla_policy.update(only_during_business_hours: true)
|
||||
end
|
||||
|
||||
context 'when business hours are enabled and SLA policy requires it' do
|
||||
context 'when evaluating first response time with business hours' do
|
||||
before { applied_sla.sla_policy.update(first_response_time_threshold: 2.hours) }
|
||||
|
||||
it 'does not miss FRT when within business hours deadline' do
|
||||
# Conversation created at 9 AM
|
||||
# 2 hour FRT would be due at 1 PM (within business hours)
|
||||
# Current time is 10 AM
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
|
||||
it 'misses FRT when business hours deadline is exceeded' do
|
||||
travel_to '15.05.2024 13:01'.to_datetime
|
||||
# Conversation created at 9 AM
|
||||
# 2 hour business hours FRT would be due at 1 PM
|
||||
# Current time is 1:01 PM - should miss
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).to have_received(:warn).with("SLA frt missed for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
|
||||
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
|
||||
end
|
||||
|
||||
it 'extends deadline over weekend correctly' do
|
||||
# Create conversation on Friday at 4 PM
|
||||
conversation.update(created_at: '10.05.2024 16:00'.to_datetime)
|
||||
travel_to '13.05.2024 09:59'.to_datetime # Monday 9:59 AM
|
||||
|
||||
# 2 hour FRT from Friday 4 PM should be due Monday 10 AM
|
||||
# (1 hour Friday + 1 hour Monday)
|
||||
# Current time Monday 9:59 AM - should not miss yet
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when evaluating next response time with business hours' do
|
||||
before do
|
||||
applied_sla.sla_policy.update(next_response_time_threshold: 1.hour)
|
||||
conversation.update(
|
||||
first_reply_created_at: '15.05.2024 09:30'.to_datetime,
|
||||
waiting_since: '15.05.2024 10:00'.to_datetime
|
||||
)
|
||||
end
|
||||
|
||||
it 'calculates NRT deadline correctly in business hours' do
|
||||
travel_to '15.05.2024 10:30'.to_datetime
|
||||
# Customer replied at 10 AM, 1 hour NRT should be due at 11 AM
|
||||
# Current time 10:30 AM - should not miss yet
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
|
||||
it 'misses NRT when business hours deadline is exceeded' do
|
||||
travel_to '15.05.2024 11:01'.to_datetime
|
||||
# Customer replied at 10 AM, 1 hour NRT should be due at 11 AM
|
||||
# Current time 11:01 AM - should miss
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
|
||||
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when evaluating resolution time with business hours' do
|
||||
before { applied_sla.sla_policy.update(resolution_time_threshold: 4.hours) }
|
||||
|
||||
it 'calculates RT deadline correctly in business hours' do
|
||||
travel_to '15.05.2024 12:00'.to_datetime
|
||||
# Conversation created at 9 AM, 4 hour RT should be due at 3 PM
|
||||
# Current time 12 PM - should not miss yet
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
|
||||
it 'misses RT when business hours deadline is exceeded' do
|
||||
travel_to '15.05.2024 15:01'.to_datetime
|
||||
# Conversation created at 9 AM, 4 hour RT should be due at 3 PM
|
||||
# Current time 3:01 PM - should miss
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
|
||||
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SLA spans across multiple business days' do
|
||||
before { applied_sla.sla_policy.update(resolution_time_threshold: 10.hours) }
|
||||
|
||||
it 'correctly calculates deadline across multiple business days' do
|
||||
# Conversation created Wednesday 4 PM
|
||||
conversation.update(created_at: '15.05.2024 16:00'.to_datetime)
|
||||
travel_to '16.05.2024 11:00'.to_datetime # Thursday 11 AM
|
||||
|
||||
# 10 hour RT: Wed 4-5 PM (1h) + Thu 9-11 AM (2h) = 3h elapsed, 7h remaining
|
||||
# Deadline should be Friday at 2 PM (Thu 9-5 = 8h total = 7h remaining)
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SLA starts near end of business day' do
|
||||
before do
|
||||
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
|
||||
conversation.update(created_at: '15.05.2024 16:30'.to_datetime) # 4:30 PM
|
||||
end
|
||||
|
||||
it 'extends deadline to next business day' do
|
||||
travel_to '16.05.2024 10:29'.to_datetime # Next day 10:29 AM
|
||||
|
||||
# Started 4:30 PM (30 min before close) + 2 hours = 1.5 hours into next day
|
||||
# Deadline should be 10:30 AM next day
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business hours are disabled on inbox' do
|
||||
before do
|
||||
inbox.update(working_hours_enabled: false)
|
||||
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
|
||||
end
|
||||
|
||||
it 'falls back to calendar time calculation' do
|
||||
travel_to '15.05.2024 11:01'.to_datetime
|
||||
# Conversation created at 9 AM, 2 hour FRT would be due at 11 AM (calendar time)
|
||||
# Current time 11:01 AM - should miss with calendar time
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SLA policy has business hours disabled' do
|
||||
before do
|
||||
applied_sla.sla_policy.update(
|
||||
only_during_business_hours: false,
|
||||
first_response_time_threshold: 2.hours
|
||||
)
|
||||
end
|
||||
|
||||
it 'falls back to calendar time calculation' do
|
||||
travel_to '15.05.2024 11:01'.to_datetime
|
||||
# Conversation created at 9 AM, 2 hour FRT would be due at 11 AM (calendar time)
|
||||
# Current time 11:01 AM - should miss with calendar time
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation starts outside business hours' do
|
||||
before do
|
||||
conversation.update(created_at: '18.05.2024 18:00'.to_datetime)
|
||||
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
|
||||
travel_to '20.05.2024 10:00'.to_datetime # Monday 10 AM
|
||||
# Saturday 6 PM
|
||||
end
|
||||
|
||||
it 'starts counting from next business hours' do
|
||||
# Conversation created Saturday 6 PM (outside business hours)
|
||||
# 2 hour FRT should start counting from Monday 9 AM
|
||||
# Deadline would be Monday 11 AM
|
||||
# Current time Monday 10 AM - should not miss yet
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling different timezones' do
|
||||
before do
|
||||
inbox.update(timezone: 'America/New_York')
|
||||
# Update working hours for EST timezone
|
||||
inbox.working_hours.destroy_all
|
||||
(1..5).each do |day|
|
||||
create(:working_hour,
|
||||
inbox: inbox,
|
||||
day_of_week: day,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0)
|
||||
end
|
||||
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
|
||||
end
|
||||
|
||||
it 'correctly handles timezone conversions' do
|
||||
# Conversation created at 9 AM EST (2 PM UTC)
|
||||
conversation.update(created_at: Time.zone.parse('15.05.2024 09:00 EST'))
|
||||
# Test at 10:59 AM EST (3:59 PM UTC) - should not miss 2 hour SLA yet
|
||||
travel_to Time.zone.parse('15.05.2024 10:59 EST')
|
||||
|
||||
# Should calculate business hours in the inbox timezone
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).not_to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no working hours are configured' do
|
||||
before do
|
||||
inbox.working_hours.destroy_all
|
||||
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
|
||||
end
|
||||
|
||||
it 'falls back to calendar time calculation' do
|
||||
travel_to '15.05.2024 11:01'.to_datetime
|
||||
# No working hours configured, should fall back to calendar time
|
||||
# Conversation at 9 AM + 2 hours = 11 AM deadline
|
||||
# Current time 11:01 AM - should miss
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
described_class.new(applied_sla: applied_sla).perform
|
||||
expect(Rails.logger).to have_received(:warn)
|
||||
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user