mirror of
https://github.com/itflow-org/itflow
synced 2026-08-16 20:45:12 +00:00
Ticket SLA: Fix on hold to pause only after first reply, show resolved closed and onhold as fixed on paused sla in the list
This commit is contained in:
39
admin/database_updates/2.7.0.php
Normal file
39
admin/database_updates/2.7.0.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Database update to version 2.7.0 (from 2.6.9)
|
||||
* Included by admin/database_updates.php - do not access directly
|
||||
*/
|
||||
|
||||
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
|
||||
|
||||
// The five built-in ticket statuses now have fixed SLA clock behaviour, matching
|
||||
// getTicketStatusSlaLock() in functions/app.php: On Hold, Resolved and Closed
|
||||
// pause the resolution clock, New and Open run it. Admin > Ticket Statuses no
|
||||
// longer offers the choice on these five - only on custom statuses.
|
||||
//
|
||||
// On Hold shipped with the column defaulting to 0, so a stock install breached
|
||||
// the resolution SLA on tickets parked waiting for a client or a vendor. Every
|
||||
// install had to find the toggle themselves; this sets it for them.
|
||||
//
|
||||
// Resolved and Closed are set for consistency across the SLA surfaces, not to
|
||||
// change behaviour - both already stopped the clocks via ticket_resolved_at and
|
||||
// ticket_closed_at, which is what the cron and syncTicketSlaClock() actually read.
|
||||
mysqli_query($mysqli, "UPDATE ticket_statuses SET ticket_status_pauses_sla = 1 WHERE ticket_status_id IN (3, 4, 5)");
|
||||
mysqli_query($mysqli, "UPDATE ticket_statuses SET ticket_status_pauses_sla = 0 WHERE ticket_status_id IN (1, 2)");
|
||||
|
||||
/*
|
||||
* Tickets already parked in On Hold are still holding an open sla_history
|
||||
* interval, so their paused time would keep counting as consumed. Closing those
|
||||
* intervals needs business-hours maths in the app timezone, and this file also
|
||||
* runs from scripts/update_cli.php, which sets neither - so the first
|
||||
* cron/ticket_sla.php run after the upgrade reconciles them instead, where the
|
||||
* timezone is set. Nothing is lost by waiting a minute: the same cron no longer
|
||||
* warns or breaches a paused ticket the moment the flag above lands.
|
||||
*
|
||||
* Breach verdicts already recorded against on-hold tickets are deliberately left
|
||||
* alone. They were recorded by a clock that really was running under the old
|
||||
* rules, and rewriting a stored miss is exactly what the reopen-integrity rules
|
||||
* in functions/sla.php exist to prevent. Re-stamp an individual ticket (change
|
||||
* its priority or its SLA) if you want it re-judged.
|
||||
*/
|
||||
@@ -12,6 +12,8 @@ $ticket_status_color = escapeHtml($row['ticket_status_color']);
|
||||
$ticket_status_order = intval($row['ticket_status_order']);
|
||||
$ticket_status_active = intval($row['ticket_status_active']);
|
||||
$ticket_status_pauses_sla = intval($row['ticket_status_pauses_sla']);
|
||||
// Null for a custom status (the admin picks), 1 or 0 for the five built-in ones
|
||||
$ticket_status_sla_lock = getTicketStatusSlaLock($ticket_status_id);
|
||||
|
||||
// Generate the HTML form content using output buffering.
|
||||
ob_start();
|
||||
@@ -66,12 +68,22 @@ ob_start();
|
||||
<label>SLA</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-stopwatch"></i></span>
|
||||
<select class="form-select select2" name="pauses_sla">
|
||||
<option <?php if ($ticket_status_pauses_sla == 0) { echo "selected "; } ?>value="0">Resolution clock keeps running</option>
|
||||
<option <?php if ($ticket_status_pauses_sla == 1) { echo "selected "; } ?>value="1">Pause the resolution clock</option>
|
||||
</select>
|
||||
<?php if (is_null($ticket_status_sla_lock)) { ?>
|
||||
<select class="form-select select2" name="pauses_sla">
|
||||
<option <?php if ($ticket_status_pauses_sla == 0) { echo "selected "; } ?>value="0">Resolution clock keeps running</option>
|
||||
<option <?php if ($ticket_status_pauses_sla == 1) { echo "selected "; } ?>value="1">Pause the resolution clock</option>
|
||||
</select>
|
||||
<?php } else { ?>
|
||||
<input type="text" class="form-control" value="<?= $ticket_status_sla_lock ? 'Pause the resolution clock' : 'Resolution clock keeps running' ?>" readonly>
|
||||
<?php } ?>
|
||||
</div>
|
||||
<small class="text-muted">Tickets sitting in a paused status never warn or breach on resolution. Time already spent is kept and the deadline moves out when the ticket comes back.</small>
|
||||
<?php if (is_null($ticket_status_sla_lock)) { ?>
|
||||
<small class="text-muted">Tickets sitting in a paused status never warn or breach on resolution. Time already spent is kept and the deadline moves out when the ticket comes back.</small>
|
||||
<?php } elseif ($ticket_status_sla_lock) { ?>
|
||||
<small class="text-muted">Fixed for built-in statuses. Tickets here never warn or breach on resolution - time already spent is kept and the deadline moves out when the ticket comes back.</small>
|
||||
<?php } else { ?>
|
||||
<small class="text-muted">Fixed for built-in statuses. Tickets here are being worked, so their resolution clock runs.</small>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,13 @@ if (isset($_POST['edit_ticket_status'])) {
|
||||
$order = intval($_POST['order']);
|
||||
$status = intval($_POST['status']);
|
||||
|
||||
$pauses_sla = intval($_POST['pauses_sla'] ?? 0);
|
||||
// Built-in statuses have fixed SLA behaviour. The modal renders it read-only, and a
|
||||
// read-only field submits nothing - so the value is decided here rather than taken
|
||||
// from the POST, which also means a hand-made request cannot change it.
|
||||
$pauses_sla = getTicketStatusSlaLock($ticket_status_id);
|
||||
if (is_null($pauses_sla)) {
|
||||
$pauses_sla = intval($_POST['pauses_sla'] ?? 0);
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE ticket_statuses SET ticket_status_name = '$name', ticket_status_color = '$color', ticket_status_order = $order, ticket_status_active = $status, ticket_status_pauses_sla = $pauses_sla WHERE ticket_status_id = $ticket_status_id");
|
||||
|
||||
|
||||
@@ -99,6 +99,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
|
||||
<?php } else { ?>
|
||||
<span class="text-secondary">Running</span>
|
||||
<?php } ?>
|
||||
<?php if (!is_null(getTicketStatusSlaLock($ticket_status_id))) { ?>
|
||||
<small class="text-muted ms-1" title="Built-in statuses have fixed SLA behaviour">(fixed)</small>
|
||||
<?php } ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown dropstart text-center">
|
||||
|
||||
@@ -2865,7 +2865,8 @@ if (isExportRequest('export_tickets')) {
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND COALESCE(ticket_status_pauses_sla, 0) = 0 AND (ticket_response_sla_alert_stage = 1 OR ticket_resolution_sla_alert_stage = 1)';
|
||||
$filter_summary['SLA'] = 'SLA at risk';
|
||||
} elseif ($sla_filter == 'paused') {
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1';
|
||||
// Mirrors agent/tickets.php - finished tickets have a verdict, not a pause
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1 AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL';
|
||||
$filter_summary['SLA'] = 'SLA paused';
|
||||
} elseif ($sla_filter == 'met') {
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_response_sla_met = 1 AND (ticket_resolution_sla_met = 1 OR ticket_resolution_due_at IS NULL)';
|
||||
|
||||
@@ -22,6 +22,9 @@ while ($row = mysqli_fetch_assoc($status_sql)) {
|
||||
'name' => escapeHtml($row['ticket_status_name']),
|
||||
'color' => escapeHtml($row['ticket_status_color']),
|
||||
'pauses_sla' => intval($row['ticket_status_pauses_sla']),
|
||||
// Resolved pauses the clock by finishing the ticket rather than parking it, so
|
||||
// the header marker would be noise on that column (Closed is not a column here)
|
||||
'show_sla_pause' => intval($row['ticket_status_pauses_sla']) && $status_id != 4,
|
||||
'tickets' => array()
|
||||
);
|
||||
}
|
||||
@@ -105,7 +108,7 @@ $kanban = array_values($statuses);
|
||||
<div class="kanban-column-header" style="border-top-color: <?= $column['color'] ?>">
|
||||
<span class="kanban-column-name"><?= $column['name'] ?></span>
|
||||
<span class="badge rounded-pill bg-secondary ms-1"><?= count($column['tickets']) ?></span>
|
||||
<?php if ($column['pauses_sla']) { ?>
|
||||
<?php if ($column['show_sla_pause']) { ?>
|
||||
<i class="fas fa-fw fa-pause text-secondary ms-1" title="The resolution SLA clock is paused in this status"></i>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,9 @@ if ($tickets) {
|
||||
|
||||
// SLA alert stages are maintained by cron/ticket_sla.php (1 = warned, 2 = breached)
|
||||
$ticket_sla_alert_stage = max(intval($row['ticket_response_sla_alert_stage']), intval($row['ticket_resolution_sla_alert_stage']));
|
||||
$ticket_sla_paused = intval($row['ticket_status_pauses_sla']);
|
||||
// Only an open ticket can be paused - Resolved and Closed pause the
|
||||
// clock too, but what those tickets have is a verdict, not a pause
|
||||
$ticket_sla_paused = $ticket_is_open && intval($row['ticket_status_pauses_sla']);
|
||||
// A paused ticket isn't running down its clock, so drop the
|
||||
// at-risk warning - a breach already recorded still stands
|
||||
if ($ticket_sla_paused && $ticket_sla_alert_stage < 2) {
|
||||
|
||||
@@ -199,7 +199,8 @@ if (!empty($_GET['sla']) && isset($sla_filter_labels[$_GET['sla']])) {
|
||||
} elseif ($ticket_sla_filter == 'at_risk') {
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND COALESCE(ticket_status_pauses_sla, 0) = 0 AND (ticket_response_sla_alert_stage = 1 OR ticket_resolution_sla_alert_stage = 1)';
|
||||
} elseif ($ticket_sla_filter == 'paused') {
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1';
|
||||
// Resolved and Closed pause the clock as well, but they are finished, not parked
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1 AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL';
|
||||
} elseif ($ticket_sla_filter == 'met') {
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_response_sla_met = 1 AND (ticket_resolution_sla_met = 1 OR ticket_resolution_due_at IS NULL)';
|
||||
} elseif ($ticket_sla_filter == 'none') {
|
||||
|
||||
@@ -60,6 +60,35 @@ $from_name = $sla_settings['ticket_from_name'];
|
||||
|
||||
$now = time();
|
||||
|
||||
/*
|
||||
* Stop any clock that is running when it should not be.
|
||||
*
|
||||
* Every status change calls syncTicketSlaClock(), so this is normally a no-op. It
|
||||
* matters when the pause rules themselves change underneath tickets that are already
|
||||
* parked - the 2.7.0 update makes On Hold pause, and those tickets are still holding
|
||||
* an open interval that would otherwise keep counting as consumed budget. Doing it
|
||||
* here rather than in the migration keeps the business-hours maths in a process that
|
||||
* has the app timezone set, which scripts/update_cli.php does not.
|
||||
*
|
||||
* Only the stopping direction is reconciled. Starting a clock re-bases the resolution
|
||||
* deadline on the remaining budget, which is a real decision about a ticket and belongs
|
||||
* with the status change that caused it, not with a background sweep.
|
||||
*/
|
||||
$sql_running = mysqli_query($mysqli, "SELECT DISTINCT ticket_id
|
||||
FROM sla_history
|
||||
JOIN tickets ON sla_history_ticket_id = ticket_id
|
||||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
WHERE sla_history_ended_at IS NULL
|
||||
AND (COALESCE(ticket_status_pauses_sla, 0) = 1
|
||||
OR ticket_resolved_at IS NOT NULL
|
||||
OR ticket_closed_at IS NOT NULL
|
||||
OR ticket_archived_at IS NOT NULL)"
|
||||
);
|
||||
|
||||
while ($running = mysqli_fetch_assoc($sql_running)) {
|
||||
syncTicketSlaClock(intval($running['ticket_id']));
|
||||
}
|
||||
|
||||
// Queue in-app + email notifications for an SLA event
|
||||
function sendSlaAlert($ticket, $subject_line, $body_line)
|
||||
{
|
||||
|
||||
@@ -59,6 +59,35 @@ function getInvoiceBadgeColor($invoice_status) {
|
||||
* The display name for a ticket status id, RAW. Escaping is the caller's job -
|
||||
* same convention as getFieldById() above.
|
||||
*/
|
||||
/*
|
||||
* Fixed SLA clock behaviour for the five built-in ticket statuses.
|
||||
*
|
||||
* New and Open always run the resolution clock; On Hold, Resolved and Closed
|
||||
* always stop it, and none of the five can be configured otherwise. A ticket
|
||||
* nobody can work on should not burn resolution budget, and a finished ticket
|
||||
* has a verdict rather than a clock.
|
||||
*
|
||||
* Resolved and Closed additionally stop BOTH clocks through ticket_resolved_at
|
||||
* and ticket_closed_at, which cron/ticket_sla.php and syncTicketSlaClock() read
|
||||
* directly - their behaviour never depended on this flag and still does not.
|
||||
* The flag is set on them so every SLA surface reads one consistent answer.
|
||||
*
|
||||
* Returns 1 or 0 for a built-in status, or null for a custom one (id > 5),
|
||||
* which the admin configures freely.
|
||||
*/
|
||||
function getTicketStatusSlaLock($ticket_status_id)
|
||||
{
|
||||
$locked_statuses = [
|
||||
1 => 0, // New
|
||||
2 => 0, // Open
|
||||
3 => 1, // On Hold
|
||||
4 => 1, // Resolved
|
||||
5 => 1, // Closed
|
||||
];
|
||||
|
||||
return $locked_statuses[intval($ticket_status_id)] ?? null;
|
||||
}
|
||||
|
||||
function getTicketStatusName($ticket_status) {
|
||||
|
||||
global $mysqli;
|
||||
|
||||
@@ -77,11 +77,13 @@ function seedDefaultData($mysqli)
|
||||
mysqli_query($mysqli,"INSERT INTO calendars SET calendar_name = 'Default', calendar_color = 'blue'");
|
||||
|
||||
// Add default ticket statuses
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'New', ticket_status_color = '#dc3545'"); // Default ID for new tickets is 1
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Open', ticket_status_color = '#007bff'"); // 2
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'On Hold', ticket_status_color = '#28a745'"); // 3
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Resolved', ticket_status_color = '#343a40'"); // 4 (was auto-close)
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Closed', ticket_status_color = '#343a40'"); // 5
|
||||
// ticket_status_pauses_sla is fixed for these five and not editable in the admin UI -
|
||||
// see getTicketStatusSlaLock() in functions/app.php. Keep the two in step.
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'New', ticket_status_color = '#dc3545', ticket_status_pauses_sla = 0"); // Default ID for new tickets is 1
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Open', ticket_status_color = '#007bff', ticket_status_pauses_sla = 0"); // 2
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'On Hold', ticket_status_color = '#28a745', ticket_status_pauses_sla = 1"); // 3
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Resolved', ticket_status_color = '#343a40', ticket_status_pauses_sla = 1"); // 4 (was auto-close)
|
||||
mysqli_query($mysqli, "INSERT INTO ticket_statuses SET ticket_status_name = 'Closed', ticket_status_color = '#343a40', ticket_status_pauses_sla = 1"); // 5
|
||||
|
||||
// Add default modules
|
||||
mysqli_query($mysqli, "INSERT INTO modules SET module_name = 'module_client', module_description = 'General client & contact management'");
|
||||
|
||||
Reference in New Issue
Block a user