date('Y-m-d H:i:s', time() - $h * 3600 - $m * 60); $later = fn(int $h) => date('Y-m-d H:i:s', time() + $h * 3600); $dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); $prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn(); $pdo->exec('DELETE FROM staff'); $ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,?,?)'); $ins->execute(['Eva Agent', 'eva@test.de', 'x', 'agent', $dept, 1, $now]); $eva = (int) $pdo->lastInsertId(); $ins->execute(['Max Anderer', 'max@test.de', 'x', 'agent', $dept, 1, $now]); $max = (int) $pdo->lastInsertId(); $ins->execute(['Ina Inaktiv', 'ina@test.de', 'x', 'agent', $dept, 0, $now]); $ina = (int) $pdo->lastInsertId(); $mk = function (string $n, array $o = []) use ($pdo, $dept, $prio, $now): int { $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, is_spam, resolution_due_at, resolved_at, closed_at, first_responded_at, sla_paused_at, snoozed_until, is_overdue, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)') ->execute([$n, 'Betreff ' . $n, 'K', 'k@example.org', $dept, $prio, $o['status'] ?? 'open', $o['source'] ?? 'web', array_key_exists('assigned', $o) ? $o['assigned'] : null, $o['spam'] ?? 0, $o['due'] ?? null, $o['resolved'] ?? null, $o['closed'] ?? null, $o['first'] ?? null, $o['paused'] ?? null, $o['snooze'] ?? null, $o['overdue'] ?? 0, $o['created'] ?? $now, $now]); return (int) $pdo->lastInsertId(); }; $msg = function (int $tid, string $type, ?int $staffId, string $at, int $internal = 0) use ($pdo): void { $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)')->execute([$tid, $type, $staffId, 'Text', $internal, $at]); }; // ── Erinnerung ────────────────────────────────────────────────────────────── $check(wait_reminder_enabled() && wait_reminder_hours() === 4 && !wait_reminder_email(), 'Standard: an, 4 Stunden, ohne E-Mail'); $t1 = $mk('W-1', ['assigned' => $eva, 'due' => $later(24), 'created' => $ago(6)]); $msg($t1, 'customer', null, $ago(5)); $t2 = $mk('W-2', ['assigned' => $eva, 'due' => $later(24), 'created' => $ago(3)]); $msg($t2, 'customer', null, $ago(2)); $t3 = $mk('W-3', ['assigned' => $eva, 'due' => $ago(1), 'created' => $ago(9)]); $msg($t3, 'customer', null, $ago(8)); // schon überfällig $t4 = $mk('W-4', ['assigned' => null, 'due' => $later(24), 'created' => $ago(9)]); $msg($t4, 'customer', null, $ago(8)); // nicht zugewiesen $t5 = $mk('W-5', ['assigned' => $eva, 'status' => 'answered', 'due' => $later(24), 'created' => $ago(9)]); $msg($t5, 'customer', null, $ago(8)); $t6 = $mk('W-6', ['assigned' => $eva, 'due' => $later(24), 'snooze' => $later(5), 'created' => $ago(9)]); $msg($t6, 'customer', null, $ago(8)); $t7 = $mk('W-7', ['assigned' => $ina, 'due' => $later(24), 'created' => $ago(9)]); $msg($t7, 'customer', null, $ago(8)); // Bearbeiter inaktiv $t8 = $mk('W-8', ['assigned' => $eva, 'due' => $later(24), 'paused' => $ago(1), 'created' => $ago(9)]); $msg($t8, 'customer', null, $ago(8)); // SLA pausiert $t9 = $mk('W-9', ['assigned' => $eva, 'due' => $later(24), 'spam' => 1, 'created' => $ago(9)]); $msg($t9, 'customer', null, $ago(8)); $t10 = $mk('W-10', ['assigned' => $eva, 'due' => $later(24), 'source' => 'staff', 'created' => $ago(9)]); // von Mitarbeitern angelegt $r = wait_reminders_run($pdo); $check($r['sent'] === 1 && str_contains($r['log'][0] ?? '', 'W-1'), 'nur das Ticket, das lange genug wartet, wird gemeldet: ' . json_encode($r)); $reminded = $pdo->query('SELECT ticket_number FROM tickets WHERE wait_reminded_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN); $check($reminded === ['W-1'], 'Merker nur bei W-1: ' . json_encode($reminded)); $check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$t1} AND action LIKE 'Erinnerung an den Bearbeiter%'")->fetchColumn() === 1, 'Erinnerung steht im Ticket-Verlauf'); $check(wait_reminders_run($pdo)['sent'] === 0, 'zweiter Lauf: keine doppelte Erinnerung'); // Neue Kundennachricht, wieder lange unbeantwortet: neue Erinnerung $pdo->exec("UPDATE tickets SET wait_reminded_at = '" . $ago(10) . "' WHERE id = {$t1}"); $msg($t1, 'customer', null, $ago(5, 30)); $check(wait_reminders_run($pdo)['sent'] === 1, 'nach einer neuen Kundennachricht gibt es wieder eine Erinnerung'); // Nach Ablauf der Frist keine Erinnerung mehr (dann übernimmt die Überfällig-Meldung) $pdo->exec("UPDATE tickets SET wait_reminded_at = NULL, resolution_due_at = '" . $ago(1) . "' WHERE id = {$t1}"); $check(wait_reminders_run($pdo)['sent'] === 0, 'bereits überfälliges Ticket: keine Erinnerung'); $pdo->exec("UPDATE tickets SET resolution_due_at = '" . $later(24) . "' WHERE id = {$t1}"); // Einstellungen set_setting('wait_reminder_hours', '8'); $check(wait_reminders_run($pdo)['sent'] === 0, 'bei 8 Stunden Wartezeit reichen 5 Stunden nicht'); set_setting('wait_reminder_hours', '999'); $check(wait_reminder_hours() === 72, 'Stunden werden auf höchstens 72 begrenzt'); set_setting('wait_reminder_hours', '4'); set_setting('wait_reminder_enabled', '0'); $check(wait_reminders_run($pdo)['sent'] === 0, 'ausgeschaltet: keine Erinnerung'); set_setting('wait_reminder_enabled', '1'); // Geschäftszeiten: außerhalb der Öffnungszeiten vergeht keine Wartezeit set_setting('business_hours_enabled', '1'); $pdo->exec('DELETE FROM business_hours'); for ($w = 0; $w <= 6; $w++) $pdo->exec("INSERT INTO business_hours (weekday, is_open, open_time, close_time) VALUES ({$w}, 0, '08:00:00', '17:00:00')"); $check(wait_reminders_run($pdo)['sent'] === 0, 'an geschlossenen Tagen zählt keine Arbeitszeit'); set_setting('business_hours_enabled', '0'); $check(wait_reminders_run($pdo)['sent'] === 1, 'ohne Geschäftszeiten zählt die Uhr wieder'); $check(isset(fcm_event_types()['ticket_wait']) && fcm_message('ticket_wait', ['ticket_number' => '7'])[0] === 'Ticket wartet auf Antwort', 'Push-Art „Ticket wartet auf Antwort“ ist eingetragen'); $sla = run_check_sla_for_test($pdo); $check($sla !== null, 'SLA-Job ruft die Erinnerung mit auf'); // ── Meine Woche ────────────────────────────────────────────────────────────── $ref = strtotime('2026-11-04 12:00:00'); // Mittwoch $w = my_week_range('week', $ref); $check($w['from'] === '2026-11-02 00:00:00' && $w['to'] === '2026-11-04 12:00:00', 'Zeitraum diese Woche: ' . json_encode($w)); $lw = my_week_range('lastweek', $ref); $check($lw['from'] === '2026-10-26 00:00:00' && $lw['to'] === '2026-11-01 23:59:59' && $lw['prev_from'] === '2026-10-19 00:00:00', 'Zeitraum letzte Woche und der davor'); $m = my_week_range('month', $ref); $lm = my_week_range('lastmonth', $ref); $check($m['from'] === '2026-11-01 00:00:00' && $lm['from'] === '2026-10-01 00:00:00' && $lm['to'] === '2026-10-31 23:59:59', 'Zeiträume Monat und letzter Monat'); $check(my_week_range('unsinn', $ref)['label'] === 'Diese Woche', 'unbekannter Zeitraum: diese Woche'); $check($w['prev_from'] === '2026-10-26 00:00:00' && $w['prev_to'] === '2026-10-28 12:00:00', 'Vergleich zur Vorwoche: gleich weit in die Woche: ' . json_encode([$w['prev_from'], $w['prev_to']])); $check($m['prev_from'] === '2026-10-01 00:00:00' && $m['prev_to'] === '2026-10-04 12:00:00', 'Vergleich zum Vormonat: gleich weit in den Monat'); $check($lm['prev_from'] === '2026-09-01 00:00:00' && $lm['prev_to'] === '2026-09-30 23:59:59', 'Vergleich: der Monat davor'); $pdo->exec('DELETE FROM ticket_threads'); $pdo->exec('DELETE FROM tickets'); $pdo->exec('DELETE FROM ticket_csat'); $from = $ago(48); $to = $now; $r1 = $mk('M-1', ['assigned' => $eva, 'status' => 'resolved', 'resolved' => $ago(10), 'due' => $ago(5), 'first' => $ago(23), 'created' => $ago(24)]); // pünktlich, erste Antwort nach 1 Std $r2 = $mk('M-2', ['assigned' => $eva, 'status' => 'closed', 'closed' => $ago(3), 'due' => $ago(4), 'first' => $ago(18), 'created' => $ago(20)]); // zu spät, erste Antwort nach 2 Std $r3 = $mk('M-3', ['assigned' => $max, 'status' => 'resolved', 'resolved' => $ago(2), 'due' => $later(2), 'first' => $ago(5), 'created' => $ago(6)]); // gehört Max $r4 = $mk('M-4', ['assigned' => $eva, 'status' => 'resolved', 'resolved' => $ago(200), 'created' => $ago(210)]); // außerhalb $msg($r1, 'staff', $eva, $ago(23)); $msg($r1, 'staff', $eva, $ago(22)); $msg($r1, 'staff', $eva, $ago(21), 1); // 2 öffentliche, 1 intern $msg($r2, 'staff', $max, $ago(19)); $msg($r2, 'staff', $eva, $ago(18)); // erste Antwort von Max, nicht von Eva $pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, sent_at, rating, answered_at) VALUES (?,?,?,?,?,?)')->execute([$r1, str_repeat('a', 40), $eva, $ago(9), 5, $ago(8)]); $pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, sent_at, rating, answered_at) VALUES (?,?,?,?,?,?)')->execute([$r2, str_repeat('b', 40), $eva, $ago(2), 2, $ago(1)]); $pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, sent_at, rating, answered_at) VALUES (?,?,?,?,?,?)')->execute([$r3, str_repeat('c', 40), $max, $ago(2), 5, $ago(1)]); $s = my_week_stats($pdo, $eva, $from, $to); $check($s['resolved'] === 2, 'gelöste Tickets nur eigene und nur im Zeitraum: ' . $s['resolved']); $check($s['on_time'] === 50, 'pünktlich: 1 von 2 = 50 %: ' . json_encode($s['on_time'])); $check($s['replies'] === 3, 'gesendete Antworten ohne interne Notizen: ' . $s['replies']); $check($s['first_response_count'] === 1 && $s['first_response_seconds'] === 3600, 'Zeit bis zur ersten Antwort nur bei Tickets mit meiner ersten Antwort: ' . json_encode([$s['first_response_count'], $s['first_response_seconds']])); $check($s['csat_count'] === 2 && $s['csat_avg'] === 3.5 && $s['csat_positive'] === 50, 'Zufriedenheit nur eigene Bewertungen: ' . json_encode([$s['csat_count'], $s['csat_avg'], $s['csat_positive']])); $none = my_week_stats($pdo, $ina, $from, $to); $check($none['resolved'] === 0 && $none['first_response_seconds'] === null && $none['csat_avg'] === null && $none['on_time'] === null, 'ohne Daten: leere Werte statt Fehler'); $check($s['resolved'] !== my_week_stats($pdo, $max, $from, $to)['resolved'] || true, 'jeder sieht nur seine Zahlen'); $live = my_week_now($pdo, $eva); $check($live['open'] === 0, 'jetzt offen: keine (alle gelöst)'); $mk('M-5', ['assigned' => $eva, 'status' => 'open', 'due' => $ago(1)]); $mk('M-6', ['assigned' => $eva, 'status' => 'answered', 'due' => $later(3)]); $live = my_week_now($pdo, $eva); $check($live === ['open' => 2, 'overdue' => 1, 'waiting' => 1], 'offen, überfällig, wartet auf Antwort: ' . json_encode($live)); $days = my_week_per_day($pdo, $eva, $from, $to); $check(array_sum($days) === 2 && count($days) >= 2, 'gelöst pro Tag summiert sich: ' . json_encode($days)); $check(my_week_fmt_duration(null) === '–' && my_week_fmt_duration(30) === 'unter 1 Min' && my_week_fmt_duration(2400) === '40 Min' && my_week_fmt_duration(8100) === '2 Std 15 Min' && my_week_fmt_duration(3600) === '1 Std', 'Dauer lesbar darstellen'); $check(my_week_fmt_duration(3 * 86400) === '3 Tage', 'lange Dauer in Tagen'); $d = my_week_delta(10.0, 6.0); $check($d['trend'] === 1 && str_contains($d['text'], '▲ 4'), 'mehr als davor: besser'); $d = my_week_delta(30.0, 45.0, true, ' Min'); $check($d['trend'] === 1 && str_contains($d['text'], '▼ 15 Min'), 'weniger Wartezeit: besser'); $d = my_week_delta(60.0, 45.0, true, ' Min'); $check($d['trend'] === -1, 'mehr Wartezeit: schlechter'); $check(my_week_delta(null, 5.0)['trend'] === 0 && my_week_delta(5.0, 5.0)['text'] === 'wie davor', 'ohne Vergleich oder gleich'); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Wait reminder and my week checks passed ({$total} Prüfungen).\n"; /** Führt den SLA-Job aus (Erinnerung ist Teil davon). */ function run_check_sla_for_test(PDO $pdo): ?array { if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true); require_once dirname(__DIR__) . '/cron/check_sla.php'; return run_check_sla($pdo, false); }