Fix avg resolution time using dedicated closed_at column
The dashboard's "Avg Resolution" stat was using updated_at, which gets overwritten on any post-close edit (title change, comment, etc.), inflating the metric. Also fixes "Closed Today" count for the same reason. - Add closed_at TIMESTAMP column to tickets table - Set closed_at on close, preserve on re-edit, clear on reopen - Update StatsModel queries to use closed_at instead of updated_at - Add migration script with audit log backfill for existing tickets Run: php scripts/add_closed_at_column.php Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,12 +84,12 @@ class StatsModel {
|
|||||||
* Get average resolution time in hours
|
* Get average resolution time in hours
|
||||||
*/
|
*/
|
||||||
public function getAverageResolutionTime(): float {
|
public function getAverageResolutionTime(): float {
|
||||||
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours
|
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours
|
||||||
FROM tickets
|
FROM tickets
|
||||||
WHERE status = 'Closed'
|
WHERE status = 'Closed'
|
||||||
AND created_at IS NOT NULL
|
AND created_at IS NOT NULL
|
||||||
AND updated_at IS NOT NULL
|
AND closed_at IS NOT NULL
|
||||||
AND updated_at > created_at";
|
AND closed_at > created_at";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
||||||
@@ -119,7 +119,7 @@ class StatsModel {
|
|||||||
* Get count of tickets closed today
|
* Get count of tickets closed today
|
||||||
*/
|
*/
|
||||||
public function getTicketsClosedToday(): int {
|
public function getTicketsClosedToday(): int {
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()";
|
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
return (int)$row['count'];
|
return (int)$row['count'];
|
||||||
@@ -211,11 +211,11 @@ class StatsModel {
|
|||||||
SUM(CASE WHEN status = 'Closed' THEN 1 ELSE 0 END) as closed_tickets,
|
SUM(CASE WHEN status = 'Closed' THEN 1 ELSE 0 END) as closed_tickets,
|
||||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as created_today,
|
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as created_today,
|
||||||
SUM(CASE WHEN YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1) THEN 1 ELSE 0 END) as created_this_week,
|
SUM(CASE WHEN YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1) THEN 1 ELSE 0 END) as created_this_week,
|
||||||
SUM(CASE WHEN status = 'Closed' AND DATE(updated_at) = CURDATE() THEN 1 ELSE 0 END) as closed_today,
|
SUM(CASE WHEN status = 'Closed' AND DATE(closed_at) = CURDATE() THEN 1 ELSE 0 END) as closed_today,
|
||||||
SUM(CASE WHEN assigned_to IS NULL AND status != 'Closed' THEN 1 ELSE 0 END) as unassigned,
|
SUM(CASE WHEN assigned_to IS NULL AND status != 'Closed' THEN 1 ELSE 0 END) as unassigned,
|
||||||
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
||||||
AVG(CASE WHEN status = 'Closed' AND updated_at > created_at
|
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
||||||
THEN TIMESTAMPDIFF(HOUR, created_at, updated_at) ELSE NULL END) as avg_resolution
|
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
||||||
FROM tickets";
|
FROM tickets";
|
||||||
|
|
||||||
$countsResult = $this->conn->query($countsSql);
|
$countsResult = $this->conn->query($countsSql);
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ class TicketModel {
|
|||||||
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
||||||
*/
|
*/
|
||||||
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
|
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
|
||||||
|
// closed_at: set on close (preserve if already set), clear on reopen
|
||||||
|
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
|
||||||
|
|
||||||
// Build query with optional optimistic locking
|
// Build query with optional optimistic locking
|
||||||
if ($expectedUpdatedAt !== null) {
|
if ($expectedUpdatedAt !== null) {
|
||||||
// Optimistic locking enabled - check that updated_at hasn't changed
|
// Optimistic locking enabled - check that updated_at hasn't changed
|
||||||
@@ -227,7 +230,8 @@ class TicketModel {
|
|||||||
category = ?,
|
category = ?,
|
||||||
type = ?,
|
type = ?,
|
||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
$closedAtClause
|
||||||
WHERE ticket_id = ? AND updated_at = ?";
|
WHERE ticket_id = ? AND updated_at = ?";
|
||||||
} else {
|
} else {
|
||||||
// No optimistic locking
|
// No optimistic locking
|
||||||
@@ -239,7 +243,8 @@ class TicketModel {
|
|||||||
category = ?,
|
category = ?,
|
||||||
type = ?,
|
type = ?,
|
||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = NOW()
|
updated_at = NOW(),
|
||||||
|
$closedAtClause
|
||||||
WHERE ticket_id = ?";
|
WHERE ticket_id = ?";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +255,7 @@ class TicketModel {
|
|||||||
|
|
||||||
if ($expectedUpdatedAt !== null) {
|
if ($expectedUpdatedAt !== null) {
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissssiis",
|
"sissssisis",
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['priority'],
|
$ticketData['priority'],
|
||||||
$ticketData['status'],
|
$ticketData['status'],
|
||||||
@@ -258,12 +263,13 @@ class TicketModel {
|
|||||||
$ticketData['category'],
|
$ticketData['category'],
|
||||||
$ticketData['type'],
|
$ticketData['type'],
|
||||||
$updatedBy,
|
$updatedBy,
|
||||||
|
$ticketData['status'],
|
||||||
$ticketData['ticket_id'],
|
$ticketData['ticket_id'],
|
||||||
$expectedUpdatedAt
|
$expectedUpdatedAt
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissssii",
|
"sissssisi",
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['priority'],
|
$ticketData['priority'],
|
||||||
$ticketData['status'],
|
$ticketData['status'],
|
||||||
@@ -271,6 +277,7 @@ class TicketModel {
|
|||||||
$ticketData['category'],
|
$ticketData['category'],
|
||||||
$ticketData['type'],
|
$ticketData['type'],
|
||||||
$updatedBy,
|
$updatedBy,
|
||||||
|
$ticketData['status'],
|
||||||
$ticketData['ticket_id']
|
$ticketData['ticket_id']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
72
scripts/add_closed_at_column.php
Normal file
72
scripts/add_closed_at_column.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Migration: Add closed_at column to tickets table
|
||||||
|
*
|
||||||
|
* Adds a dedicated timestamp for when tickets are closed,
|
||||||
|
* so avg resolution time isn't inflated by post-close edits.
|
||||||
|
*
|
||||||
|
* Usage: php scripts/add_closed_at_column.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
die("Database connection failed: " . $conn->connect_error . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Adding closed_at column to tickets table...\n";
|
||||||
|
|
||||||
|
// Add the column if it doesn't exist
|
||||||
|
$result = $conn->query("SHOW COLUMNS FROM tickets LIKE 'closed_at'");
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
echo "Column 'closed_at' already exists, skipping ALTER TABLE.\n";
|
||||||
|
} else {
|
||||||
|
$sql = "ALTER TABLE tickets ADD COLUMN closed_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at";
|
||||||
|
if ($conn->query($sql)) {
|
||||||
|
echo "Column added successfully.\n";
|
||||||
|
} else {
|
||||||
|
die("Failed to add column: " . $conn->error . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add index for stats queries
|
||||||
|
$conn->query("CREATE INDEX idx_tickets_closed_at ON tickets (closed_at)");
|
||||||
|
echo "Index created.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill: For existing closed tickets, use the audit log to find when they were closed
|
||||||
|
echo "\nBackfilling closed_at from audit log...\n";
|
||||||
|
|
||||||
|
$sql = "UPDATE tickets t
|
||||||
|
JOIN (
|
||||||
|
SELECT entity_id as ticket_id, MIN(created_at) as first_closed
|
||||||
|
FROM audit_log
|
||||||
|
WHERE entity_type = 'ticket'
|
||||||
|
AND action_type = 'update'
|
||||||
|
AND details LIKE '%\"status\":\"Closed\"%'
|
||||||
|
GROUP BY entity_id
|
||||||
|
) al ON t.ticket_id = al.ticket_id
|
||||||
|
SET t.closed_at = al.first_closed
|
||||||
|
WHERE t.status = 'Closed' AND t.closed_at IS NULL";
|
||||||
|
|
||||||
|
$result = $conn->query($sql);
|
||||||
|
$backfilled = $conn->affected_rows;
|
||||||
|
echo "Backfilled $backfilled tickets from audit log.\n";
|
||||||
|
|
||||||
|
// For any remaining closed tickets without audit log entries, use updated_at as fallback
|
||||||
|
$sql = "UPDATE tickets SET closed_at = updated_at WHERE status = 'Closed' AND closed_at IS NULL";
|
||||||
|
$conn->query($sql);
|
||||||
|
$fallback = $conn->affected_rows;
|
||||||
|
if ($fallback > 0) {
|
||||||
|
echo "Used updated_at as fallback for $fallback tickets without audit log entries.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nMigration complete!\n";
|
||||||
|
$conn->close();
|
||||||
Reference in New Issue
Block a user