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
|
||||
*/
|
||||
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
|
||||
WHERE status = 'Closed'
|
||||
AND created_at IS NOT NULL
|
||||
AND updated_at IS NOT NULL
|
||||
AND updated_at > created_at";
|
||||
AND closed_at IS NOT NULL
|
||||
AND closed_at > created_at";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
||||
@@ -119,7 +119,7 @@ class StatsModel {
|
||||
* Get count of tickets closed today
|
||||
*/
|
||||
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);
|
||||
$row = $result->fetch_assoc();
|
||||
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 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 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 priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
||||
AVG(CASE WHEN status = 'Closed' AND updated_at > created_at
|
||||
THEN TIMESTAMPDIFF(HOUR, created_at, updated_at) ELSE NULL END) as avg_resolution
|
||||
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
||||
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
||||
FROM tickets";
|
||||
|
||||
$countsResult = $this->conn->query($countsSql);
|
||||
|
||||
@@ -216,6 +216,9 @@ class TicketModel {
|
||||
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
||||
*/
|
||||
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
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
// Optimistic locking enabled - check that updated_at hasn't changed
|
||||
@@ -227,7 +230,8 @@ class TicketModel {
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_by = ?,
|
||||
updated_at = NOW()
|
||||
updated_at = NOW(),
|
||||
$closedAtClause
|
||||
WHERE ticket_id = ? AND updated_at = ?";
|
||||
} else {
|
||||
// No optimistic locking
|
||||
@@ -239,7 +243,8 @@ class TicketModel {
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_by = ?,
|
||||
updated_at = NOW()
|
||||
updated_at = NOW(),
|
||||
$closedAtClause
|
||||
WHERE ticket_id = ?";
|
||||
}
|
||||
|
||||
@@ -250,7 +255,7 @@ class TicketModel {
|
||||
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
$stmt->bind_param(
|
||||
"sissssiis",
|
||||
"sissssisis",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
@@ -258,12 +263,13 @@ class TicketModel {
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['status'],
|
||||
$ticketData['ticket_id'],
|
||||
$expectedUpdatedAt
|
||||
);
|
||||
} else {
|
||||
$stmt->bind_param(
|
||||
"sissssii",
|
||||
"sissssisi",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
@@ -271,6 +277,7 @@ class TicketModel {
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['status'],
|
||||
$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