Files
deskhub/DeskHub-Webseite/includes/csat.php
T
2026-10-24 20:14:54 +00:00

167 lines
8.7 KiB
PHP

<?php
/**
* includes/csat.php
*
* Zufriedenheitsabfrage (CSAT): Nach dem Schließen oder Lösen eines Tickets bekommt der Kunde eine Mail mit der Bitte,
* die Bearbeitung mit 1 bis 5 Sternen zu bewerten (und optional einen Kommentar zu schreiben). Pro Ticket gibt es höchstens
* eine Abfrage. Der Versand läuft über den Hintergrunddienst (cron/send_csat.php), damit er nicht an jedem Weg hängt,
* auf dem ein Ticket geschlossen werden kann (Web, App, Regeln, automatisches Schließen).
*/
function csat_enabled(): bool
{
return get_setting('csat_enabled', '0') === '1';
}
/** Wartezeit nach dem Schließen, bevor die Mail rausgeht (0 bis 1440 Minuten). */
function csat_delay_minutes(): int
{
return max(0, min(1440, (int) get_setting('csat_delay_minutes', '30')));
}
function csat_url(string $token, ?int $rating = null): string
{
return base_url() . '/csat.php?t=' . rawurlencode($token) . ($rating ? '&r=' . $rating : '');
}
/** Schaltet die Abfrage ein oder aus. Beim Einschalten gelten nur Tickets, die ab jetzt geschlossen werden (keine Mails für alte Tickets). */
function csat_set_enabled(bool $on, int $delayMinutes): void
{
if ($on && !csat_enabled()) set_setting('csat_since', db_now());
set_setting('csat_enabled', $on ? '1' : '0');
set_setting('csat_delay_minutes', (string) max(0, min(1440, $delayMinutes)));
}
/** Eintrag zu einem Ticket (oder null). */
function csat_for_ticket(PDO $pdo, int $ticketId): ?array
{
$s = $pdo->prepare('SELECT * FROM ticket_csat WHERE ticket_id = ?');
$s->execute([$ticketId]);
return $s->fetch() ?: null;
}
function csat_find_by_token(PDO $pdo, string $token): ?array
{
if (!preg_match('/^[a-f0-9]{40}$/', $token)) return null;
$s = $pdo->prepare('SELECT c.*, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE c.token = ?');
$s->execute([$token]);
return $s->fetch() ?: null;
}
/**
* Speichert die Bewertung. Jede Abfrage lässt sich nur einmal beantworten.
* @return array{ok:bool,error?:string}
*/
function csat_record(PDO $pdo, string $token, int $rating, string $comment): array
{
$row = csat_find_by_token($pdo, $token);
if (!$row) return ['ok' => false, 'error' => 'Dieser Link ist ungültig oder abgelaufen.'];
if ($row['answered_at'] !== null) return ['ok' => false, 'error' => 'Vielen Dank, Sie haben dieses Ticket bereits bewertet.'];
if ($rating < 1 || $rating > 5) return ['ok' => false, 'error' => 'Bitte wählen Sie 1 bis 5 Sterne.'];
$comment = trim(mb_substr($comment, 0, 2000));
$pdo->prepare('UPDATE ticket_csat SET rating = ?, comment = ?, answered_at = ? WHERE id = ? AND answered_at IS NULL')
->execute([$rating, $comment !== '' ? $comment : null, db_now(), $row['id']]);
try {
log_activity($pdo, (int) $row['ticket_id'], null, 'Zufriedenheit bewertet: ' . $rating . ' von 5 Sternen');
} catch (Throwable $e) {
}
return ['ok' => true];
}
/** Text der Mail an den Kunden. */
function csat_mail_html(array $ticket, string $token): string
{
$name = trim((string) ($ticket['requester_name'] ?? ''));
$stars = '';
for ($i = 1; $i <= 5; $i++) {
$stars .= '<a href="' . e(csat_url($token, $i)) . '" style="display:inline-block;text-decoration:none;font-size:34px;line-height:1;color:#f5b301;padding:0 3px" title="' . $i . ' von 5">★</a>';
}
return '<h2 style="margin:0 0 12px;font-size:20px;color:#16202a">Wie zufrieden waren Sie?</h2>'
. '<p style="margin:0 0 16px;color:#475569;font-size:15px;line-height:1.55">' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '')
. 'Ihr Ticket <strong>[#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . '</strong> ist abgeschlossen. '
. 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.</p>'
. '<p style="margin:0 0 6px;text-align:center">' . $stars . '</p>'
. '<p style="margin:0 0 16px;text-align:center;color:#64748b;font-size:12px">1 Stern = unzufrieden · 5 Sterne = sehr zufrieden</p>';
}
/**
* Verschickt die fälligen Abfragen. Fällig: Ticket geschlossen oder gelöst (seit mindestens der eingestellten Wartezeit, höchstens 14 Tage),
* kein Spam, kein Live-Chat, gültige Kundenadresse, noch keine Abfrage, und geschlossen nach dem Einschalten der Funktion.
*/
function csat_send_due(PDO $pdo, int $limit = 50): int
{
if (!csat_enabled()) return 0;
$now = time();
$until = date('Y-m-d H:i:s', $now - csat_delay_minutes() * 60);
$oldest = date('Y-m-d H:i:s', $now - 14 * 86400);
$since = (string) get_setting('csat_since', '');
if ($since !== '' && $since > $oldest) $oldest = $since;
$stmt = $pdo->prepare(
"SELECT t.* FROM tickets t
WHERE t.status IN ('closed','resolved') AND t.is_spam = 0 AND t.source <> 'chat'
AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) <= ? AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) >= ?
AND t.requester_email IS NOT NULL AND t.requester_email <> ''
AND NOT EXISTS (SELECT 1 FROM ticket_csat c WHERE c.ticket_id = t.id)
ORDER BY t.id LIMIT " . max(1, $limit)
);
$stmt->execute([$until, $oldest]);
$sent = 0;
foreach ($stmt->fetchAll() as $t) {
if (!filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue;
$token = bin2hex(random_bytes(20));
try {
$pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, department_id, sent_at) VALUES (?,?,?,?,?)')
->execute([$t['id'], $token, $t['assigned_staff_id'] ?: null, $t['department_id'] ?: null, db_now()]);
} catch (Throwable $e) {
continue; // parallel schon angelegt
}
$from = resolve_from_address($t['department_id'] ? (int) $t['department_id'] : null, $t['priority_id'] ? (int) $t['priority_id'] : null);
email_send_and_log((string) $t['requester_email'], 'Wie zufrieden waren Sie? [#' . $t['ticket_number'] . ']', email_wrap(csat_mail_html($t, $token)), $from, 'Zufriedenheitsabfrage', (string) $t['ticket_number']);
$sent++;
}
return $sent;
}
/**
* Auswertung für einen Zeitraum.
* @param array|null $deptIds null = alle Abteilungen
* @return array{sent:int,answered:int,avg:?float,rate:?float,dist:array<int,int>,byStaff:list<array>,recent:list<array>}
*/
function csat_stats(PDO $pdo, string $from, string $to, ?array $deptIds = null): array
{
$where = 'c.sent_at >= ? AND c.sent_at <= ?';
$args = [$from, $to];
if ($deptIds !== null) {
if (!$deptIds) return ['sent' => 0, 'answered' => 0, 'avg' => null, 'rate' => null, 'dist' => [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0], 'byStaff' => [], 'recent' => []];
$where .= ' AND c.department_id IN (' . implode(',', array_map('intval', $deptIds)) . ')';
}
$q = $pdo->prepare("SELECT COUNT(*) AS sent, SUM(CASE WHEN c.rating IS NOT NULL THEN 1 ELSE 0 END) AS answered, AVG(c.rating) AS avg FROM ticket_csat c WHERE $where");
$q->execute($args);
$r = $q->fetch() ?: [];
$sent = (int) ($r['sent'] ?? 0);
$answered = (int) ($r['answered'] ?? 0);
$dist = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0];
$d = $pdo->prepare("SELECT c.rating AS r, COUNT(*) AS n FROM ticket_csat c WHERE $where AND c.rating IS NOT NULL GROUP BY c.rating");
$d->execute($args);
foreach ($d->fetchAll() as $row) $dist[(int) $row['r']] = (int) $row['n'];
$b = $pdo->prepare("SELECT s.id, s.name, COUNT(*) AS n, AVG(c.rating) AS avg FROM ticket_csat c JOIN staff s ON s.id = c.staff_id WHERE $where AND c.rating IS NOT NULL GROUP BY s.id, s.name ORDER BY AVG(c.rating) DESC, COUNT(*) DESC");
$b->execute($args);
$byStaff = array_map(fn($x) => ['id' => (int) $x['id'], 'name' => (string) $x['name'], 'count' => (int) $x['n'], 'avg' => round((float) $x['avg'], 2)], $b->fetchAll());
$rc = $pdo->prepare("SELECT c.rating, c.comment, c.answered_at, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE $where AND c.rating IS NOT NULL ORDER BY c.answered_at DESC LIMIT 10");
$rc->execute($args);
return [
'sent' => $sent, 'answered' => $answered,
'avg' => $answered ? round((float) $r['avg'], 2) : null,
'rate' => $sent ? round($answered * 100 / $sent, 1) : null,
'dist' => $dist, 'byStaff' => $byStaff,
'recent' => array_map(fn($x) => ['rating' => (int) $x['rating'], 'comment' => (string) ($x['comment'] ?? ''), 'answeredAt' => (string) $x['answered_at'], 'number' => (string) $x['ticket_number'], 'subject' => (string) $x['subject']], $rc->fetchAll()),
];
}
/** „★★★★☆“ für Anzeigen. */
function csat_stars(?int $rating): string
{
$rating = max(0, min(5, (int) $rating));
return str_repeat('★', $rating) . str_repeat('☆', 5 - $rating);
}