Fix SLA resolve-time reporting, stale settings cache on save, and report N+1

This commit is contained in:
johnnyq
2026-07-29 13:34:34 -04:00
parent 750c2e58cc
commit 566d74c15d
4 changed files with 74 additions and 38 deletions

View File

@@ -109,6 +109,9 @@ if (isset($_POST['edit_sla_settings'])) {
mysqli_query($mysqli, "UPDATE settings SET config_business_days = '$business_days', config_business_hours_start = '$business_hours_start', config_business_hours_end = '$business_hours_end', config_sla_warning_percent = $warning_percent, config_sla_notification_email = '$notification_email' WHERE company_id = 1"); mysqli_query($mysqli, "UPDATE settings SET config_business_days = '$business_days', config_business_hours_start = '$business_hours_start', config_business_hours_end = '$business_hours_end', config_sla_warning_percent = $warning_percent, config_sla_notification_email = '$notification_email' WHERE company_id = 1");
// Drop the cached copy so the restamp below uses the hours just saved
getSlaSettings(true);
// Business hours feed the due date math - re-stamp open SLA tickets // Business hours feed the due date math - re-stamp open SLA tickets
$restamped = 0; $restamped = 0;
$sql_tickets = mysqli_query($mysqli, "SELECT ticket_id, ticket_sla_id FROM tickets WHERE ticket_sla_id > 0 AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL"); $sql_tickets = mysqli_query($mysqli, "SELECT ticket_id, ticket_sla_id FROM tickets WHERE ticket_sla_id > 0 AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL");

View File

@@ -27,18 +27,31 @@ $sql_ticket_years = mysqli_query($mysqli, "SELECT DISTINCT YEAR(ticket_created_a
$sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL ORDER BY client_name ASC"); $sql_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL ORDER BY client_name ASC");
function slaPercentDisplay($percent)
{ // Clock time spent on resolved tickets, gathered for every client at once
if (is_null($percent)) { // rather than per ticket. Tickets with no interval rows - response-only plans,
return "<span class='text-secondary'>-</span>"; // or anything resolved before SLA pausing existed - fall back to the business
// time that elapsed between being raised and resolved.
$resolve_totals = [];
$sql_resolve_times = mysqli_query($mysqli, "SELECT ticket_client_id, ticket_id, ticket_created_at, ticket_resolved_at, SUM(sla_history_minutes) AS logged_minutes
FROM tickets
LEFT JOIN sla_history ON sla_history_ticket_id = ticket_id
WHERE ticket_sla_id > 0
AND ticket_resolved_at IS NOT NULL
AND $period_query
GROUP BY ticket_id, ticket_client_id, ticket_created_at, ticket_resolved_at"
);
while ($resolve_row = mysqli_fetch_assoc($sql_resolve_times)) {
$resolve_client_id = intval($resolve_row['ticket_client_id']);
$resolve_minutes = is_null($resolve_row['logged_minutes'])
? businessMinutesBetween($resolve_row['ticket_created_at'], $resolve_row['ticket_resolved_at'])
: intval($resolve_row['logged_minutes']);
if (!isset($resolve_totals[$resolve_client_id])) {
$resolve_totals[$resolve_client_id] = ['minutes' => 0, 'count' => 0];
} }
if ($percent >= 95) { $resolve_totals[$resolve_client_id]['minutes'] += $resolve_minutes;
return "<span class='text-success text-bold'>$percent%</span>"; $resolve_totals[$resolve_client_id]['count']++;
}
if ($percent >= 80) {
return "<span class='text-warning text-bold'>$percent%</span>";
}
return "<span class='text-danger text-bold'>$percent%</span>";
} }
?> ?>
@@ -125,18 +138,11 @@ function slaPercentDisplay($percent)
$avg_time_to_respond = is_null($stats['avg_response_seconds']) ? '-' : secondsToTime($stats['avg_response_seconds']); $avg_time_to_respond = is_null($stats['avg_response_seconds']) ? '-' : secondsToTime($stats['avg_response_seconds']);
// Resolution time is measured in clock time actually spent - // Clock time actually spent - paused spells excluded, which is
// paused spells are excluded, which is what the SLA judged on // what the SLA itself judged on
$avg_time_to_resolve = '-'; $avg_time_to_resolve = '-';
$resolved_minutes_total = 0; if (!empty($resolve_totals[$client_id]['count'])) {
$resolved_count = 0; $avg_time_to_resolve = secondsToTime(($resolve_totals[$client_id]['minutes'] / $resolve_totals[$client_id]['count']) * 60);
$sql_resolved = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_sla_id > 0 AND ticket_client_id = $client_id AND ticket_resolved_at IS NOT NULL AND $period_query");
while ($resolved_row = mysqli_fetch_assoc($sql_resolved)) {
$resolved_minutes_total += getTicketSlaConsumedMinutes($resolved_row['ticket_id']);
$resolved_count++;
}
if ($resolved_count > 0) {
$avg_time_to_resolve = secondsToTime(($resolved_minutes_total / $resolved_count) * 60);
} }
?> ?>
<tr> <tr>

View File

@@ -50,20 +50,6 @@ function getSlaCompliance($where)
return $compliance; return $compliance;
} }
// Colour the headline figures the way the ticket list colours rows
function slaPercentDisplay($percent)
{
if (is_null($percent)) {
return "<span class='text-secondary'>-</span>";
}
if ($percent >= 95) {
return "<span class='text-success text-bold'>$percent%</span>";
}
if ($percent >= 80) {
return "<span class='text-warning text-bold'>$percent%</span>";
}
return "<span class='text-danger text-bold'>$percent%</span>";
}
$overall = getSlaCompliance("AND YEAR(ticket_created_at) = $year"); $overall = getSlaCompliance("AND YEAR(ticket_created_at) = $year");

View File

@@ -17,12 +17,19 @@
*/ */
// Business hours + SLA settings, fetched once per request // Business hours + SLA settings, fetched once per request
function getSlaSettings() function getSlaSettings($refresh = false)
{ {
global $mysqli; global $mysqli;
static $sla_settings = null; static $sla_settings = null;
// Callers that have just written to the settings row pass true - otherwise
// they would restamp tickets using the business hours this request started
// with rather than the ones just saved
if ($refresh) {
$sla_settings = null;
}
if (!is_null($sla_settings)) { if (!is_null($sla_settings)) {
return $sla_settings; return $sla_settings;
} }
@@ -166,16 +173,24 @@ function businessMinutesBetween($start_datetime, $end_datetime)
} }
// Business minutes already spent on a ticket's resolution clock, including the // Business minutes already spent on a ticket's resolution clock, including the
// interval currently running // interval currently running.
//
// Tickets with no clock history at all fall back to the business time elapsed
// since they were raised. Only tickets carrying a resolution target get
// intervals, so this covers response-only plans, and tickets that were already
// resolved when SLA pausing was introduced. Without the fallback both report
// zero time spent, which reads as instant resolution.
function getTicketSlaConsumedMinutes($ticket_id) function getTicketSlaConsumedMinutes($ticket_id)
{ {
global $mysqli; global $mysqli;
$ticket_id = intval($ticket_id); $ticket_id = intval($ticket_id);
$consumed = 0; $consumed = 0;
$has_history = false;
$sql = mysqli_query($mysqli, "SELECT sla_history_started_at, sla_history_ended_at, sla_history_minutes FROM sla_history WHERE sla_history_ticket_id = $ticket_id"); $sql = mysqli_query($mysqli, "SELECT sla_history_started_at, sla_history_ended_at, sla_history_minutes FROM sla_history WHERE sla_history_ticket_id = $ticket_id");
while ($row = mysqli_fetch_assoc($sql)) { while ($row = mysqli_fetch_assoc($sql)) {
$has_history = true;
if (!is_null($row['sla_history_ended_at'])) { if (!is_null($row['sla_history_ended_at'])) {
$consumed += intval($row['sla_history_minutes']); $consumed += intval($row['sla_history_minutes']);
} else { } else {
@@ -183,6 +198,16 @@ function getTicketSlaConsumedMinutes($ticket_id)
} }
} }
if (!$has_history) {
$ticket_sql = mysqli_query($mysqli, "SELECT ticket_created_at, ticket_resolved_at, ticket_closed_at FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
if (!$ticket_sql || !mysqli_num_rows($ticket_sql)) {
return 0;
}
$ticket = mysqli_fetch_assoc($ticket_sql);
$ended_at = $ticket['ticket_resolved_at'] ?: ($ticket['ticket_closed_at'] ?: date('Y-m-d H:i:s'));
return businessMinutesBetween($ticket['ticket_created_at'], $ended_at);
}
return $consumed; return $consumed;
} }
@@ -199,6 +224,22 @@ function getTicketSlaPausedCount($ticket_id)
return intval($row['paused_count']); return intval($row['paused_count']);
} }
// Colour an SLA compliance percentage for the reports. Null means nothing has
// been judged yet, which is not the same as zero.
function slaPercentDisplay($percent)
{
if (is_null($percent)) {
return "<span class='text-secondary'>-</span>";
}
if ($percent >= 95) {
return "<span class='text-success text-bold'>$percent%</span>";
}
if ($percent >= 80) {
return "<span class='text-warning text-bold'>$percent%</span>";
}
return "<span class='text-danger text-bold'>$percent%</span>";
}
// Reconcile a ticket's resolution clock with its current status. Safe to call // Reconcile a ticket's resolution clock with its current status. Safe to call
// after any status change (and after applyTicketSla) - it opens an interval // after any status change (and after applyTicketSla) - it opens an interval
// when the clock should be running, closes it when it should not, and on // when the clock should be running, closes it when it should not, and on