167 lines
8.8 KiB
PHP
167 lines
8.8 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 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">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);
|
|
}
|