open($zipPath, ZipArchive::CREATE); foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) { $zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1))); } $zip->close(); $r = plugin_install_zip($pdo, $zipPath); $check($r['ok'] && $r['slug'] === 'vertretung', 'Plugin lässt sich installieren: ' . json_encode($r)); $act = plugin_set_active($pdo, 'vertretung', true, ['hooks.tickets', 'hooks.cron', 'ui.menu', 'db.migrations']); $check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act)); $state = &plugin_state(); $state['booted'] = false; plugins_boot(); $check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für jeden Mitarbeiter sichtbar'); $mkStaff = function (string $name, string $role = 'agent') use ($pdo): int { $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)') ->execute([$name, strtolower($name) . '@team.example', 'x', $role, db_now()]); return (int) $pdo->lastInsertId(); }; $anna = $mkStaff('Anna'); $ben = $mkStaff('Ben'); $cara = $mkStaff('Cara'); $adm = $mkStaff('Chef', 'admin'); $actor = fn(int $id, string $role = 'agent') => ['id' => $id, 'role' => $role]; $dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); $prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); $n = 0; $mk = function (int $assigned, string $status = 'open') use ($pdo, $dept, $prio, &$n): int { $n++; $now = db_now(); $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)') ->execute(['VT-' . $n, 'Anfrage ' . $n, 'Kunde', 'kunde@x.example', $dept, $prio, $status, 'web', $assigned ?: null, $now, $now]); return (int) $pdo->lastInsertId(); }; $owner = fn(int $t) => (int) $pdo->query("SELECT assigned_staff_id FROM tickets WHERE id = {$t}")->fetchColumn(); $flag = fn(int $s) => $pdo->query("SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = {$s}")->fetch(); $day = fn(int $offset) => date('Y-m-d', strtotime('2026-07-15 12:00:00') + $offset * 86400); $GLOBALS['vertretung_today'] = $day(0); // ── Eingabeprüfung und Rechte ────────────────────────────────────────────── $bad = fn($res) => !$res['ok']; $check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(3), $day(1), '')), 'Ende vor Beginn wird abgelehnt'); $check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(-5), $day(-2), '')), 'Zeitraum in der Vergangenheit wird abgelehnt'); $check($bad(vertretung_create($pdo, $actor($anna), $anna, $anna, $day(1), $day(2), '')), 'man kann sich nicht selbst vertreten'); $check($bad(vertretung_create($pdo, $actor($anna), $anna, 9999, $day(1), $day(2), '')), 'unbekannte Vertretung wird abgelehnt'); $check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, '2026-02-30', $day(2), '')), 'ungültiges Datum wird abgelehnt'); $check($bad(vertretung_create($pdo, $actor($anna), $ben, $cara, $day(1), $day(2), '')), 'Mitarbeiter tragen keine fremde Abwesenheit ein'); $pdo->prepare('UPDATE staff SET is_active = 0 WHERE id = ?')->execute([$cara]); $check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(1), $day(2), '')), 'inaktive Vertretung wird abgelehnt'); $pdo->prepare('UPDATE staff SET is_active = 1 WHERE id = ?')->execute([$cara]); // ── Geplant, dann Beginn ─────────────────────────────────────────────────── $t1 = $mk($anna); $t2 = $mk($anna, 'pending'); $tDone = $mk($anna, 'resolved'); $tBen = $mk($ben); $tNobody = $mk(0); $plan = vertretung_create($pdo, $actor($anna), $anna, $ben, $day(2), $day(6), 'Urlaub'); $check($plan['ok'], 'Anna trägt ihre eigene Abwesenheit ein'); $check($owner($t1) === $anna && (int) $flag($anna)['is_on_vacation'] === 0, 'vor dem Beginn ändert sich nichts'); $check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(4), $day(8), '')), 'überschneidende Abwesenheit wird abgelehnt'); vertretung_tick($pdo); $check($owner($t1) === $anna, 'Tick vor dem Beginn ändert nichts'); $GLOBALS['vertretung_today'] = $day(2); vertretung_tick($pdo); $check($owner($t1) === $ben && $owner($t2) === $ben, 'zum Beginn gehen die offenen Tickets an die Vertretung'); $check($owner($tDone) === $anna, 'gelöste Tickets bleiben bei Anna'); $check($owner($tBen) === $ben && $owner($tNobody) === 0, 'andere Tickets bleiben unberührt'); $f = $flag($anna); $check((int) $f['is_on_vacation'] === 1 && (int) $f['vacation_substitute_id'] === $ben, 'Urlaubsstatus und Vertretung im Profil sind gesetzt'); $check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$t1} AND action LIKE '%Vertretung Ben%'")->fetchColumn() === 1, 'Aktivität im Ticket vermerkt'); vertretung_tick($pdo); $check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves')->fetchColumn() === 2, 'zweiter Tick verteilt nicht noch einmal'); // neue Tickets für Anna $tNew = $mk($anna); webhook_dispatch($pdo, 'ticket.created', $tNew); $check($owner($tNew) === $ben, 'neues Ticket für die Abwesende geht an die Vertretung'); $tManual = $mk($anna); webhook_dispatch($pdo, 'ticket.assigned', $tManual); $check($owner($tManual) === $ben, 'von Hand an die Abwesende zugewiesen geht ebenfalls weiter'); $tAnyone = $mk($cara); webhook_dispatch($pdo, 'ticket.created', $tAnyone); $check($owner($tAnyone) === $cara, 'Tickets anderer Personen bleiben'); // automatische Zuweisung der Abteilung berücksichtigt den Urlaubsstatus bereits $pdo->prepare("UPDATE departments SET auto_assign_mode = 'staff', auto_assign_staff_id = ? WHERE id = ?")->execute([$anna, $dept]); $check(department_auto_assignee($pdo, $dept) === $ben, 'automatische Zuweisung der Abteilung nimmt die Vertretung'); $pdo->prepare("UPDATE departments SET auto_assign_mode = 'none' WHERE id = ?")->execute([$dept]); // Kette: Ben ist selbst abwesend mit Vertretung Cara $chain = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $cara, $day(2), $day(4), 'Kette'); $check($chain['ok'], 'Administrator trägt die Abwesenheit eines anderen ein'); vertretung_tick($pdo); $check($owner($t1) === $cara && $owner($tBen) === $cara, 'Bens Tickets (auch von Anna übernommene) gehen weiter an Cara'); $check(vertretung_target($pdo, $anna) === $cara, 'die Kette führt von Anna über Ben zu Cara'); $tChain = $mk($anna); webhook_dispatch($pdo, 'ticket.created', $tChain); $check($owner($tChain) === $cara, 'neues Ticket für Anna landet am Ende der Kette'); // ── Ende von Ben: seine Tickets kommen zurück (Cara hatte sie nur übernommen) ── $GLOBALS['vertretung_today'] = $day(5); vertretung_tick($pdo); $check((int) $flag($ben)['is_on_vacation'] === 0, 'Bens Urlaubsstatus ist zurückgenommen'); $check($owner($tBen) === $ben, 'Bens eigenes Ticket ist wieder bei ihm'); $check($owner($t1) === $ben && $owner($tChain) === $ben, 'auch die von Anna übernommenen liegen wieder bei Ben (dort ist die Abwesenheit von Anna noch aktiv)'); $st = $pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $chain['id'])->fetchColumn(); $check($st === 'done', 'Abwesenheit steht auf „Beendet“'); // ── Ende von Anna: nur offene, noch bei der Vertretung liegende Tickets kommen zurück ── $pdo->prepare("UPDATE tickets SET status = 'closed' WHERE id = ?")->execute([$t2]); // erledigt während der Abwesenheit $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$cara, $tManual]); // von Hand umverteilt $GLOBALS['vertretung_today'] = $day(7); vertretung_tick($pdo); $check((int) $flag($anna)['is_on_vacation'] === 0 && $flag($anna)['vacation_substitute_id'] === null, 'Annas Urlaubsstatus ist zurückgenommen'); $check($owner($t1) === $anna && $owner($tNew) === $anna && $owner($tChain) === $anna, 'offene Tickets sind wieder bei Anna'); $check($owner($t2) === $ben, 'geschlossenes Ticket bleibt, wo es war'); $check($owner($tManual) === $cara, 'von Hand umverteiltes Ticket bleibt bei der neuen Person'); $check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves WHERE returned_at IS NULL')->fetchColumn() === 0, 'keine offenen Rückgaben mehr'); $check($pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $plan['id'])->fetchColumn() === 'done', 'Annas Abwesenheit steht auf „Beendet“'); // ── Beginn am selben Tag, Abbruch, nicht angetreten, Rechte ──────────────── $tX = $mk($cara); $now1 = vertretung_create($pdo, $actor($cara), $cara, $anna, $day(7), $day(20), ''); $check($now1['ok'] && $owner($tX) === $anna, 'Beginn heute: Tickets gehen sofort weiter'); $check(!vertretung_cancel($pdo, $actor($ben), (int) $now1['id']), 'fremde Abwesenheit kann nicht beendet werden'); $check(vertretung_cancel($pdo, $actor($cara), (int) $now1['id']) && $owner($tX) === $cara && (int) $flag($cara)['is_on_vacation'] === 0, 'Abbruch gibt die Tickets zurück und nimmt den Status zurück'); $later = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(10), $day(12), ''); $check($later['ok'] && vertretung_cancel($pdo, $actor($adm, 'admin'), (int) $later['id']) && $pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $later['id'])->fetchColumn() === 'cancelled', 'geplante Abwesenheit lässt sich löschen'); $ghost = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(8), $day(9), ''); $GLOBALS['vertretung_today'] = $day(15); vertretung_tick($pdo); $check($pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $ghost['id'])->fetchColumn() === 'missed' && (int) $flag($ben)['is_on_vacation'] === 0, 'versäumter Zeitraum wird nicht mehr gestartet'); // Profil wurde zwischendurch von Hand geändert: Plugin nimmt fremde Einstellungen nicht zurück $GLOBALS['vertretung_today'] = $day(30); $pdo->exec('UPDATE plugin_vertretung_absences SET status = \'cancelled\' WHERE status IN (\'planned\',\'active\')'); $c = vertretung_create($pdo, $actor($adm, 'admin'), $cara, $ben, $day(30), $day(31), ''); $pdo->prepare('UPDATE staff SET vacation_substitute_id = ? WHERE id = ?')->execute([$anna, $cara]); $GLOBALS['vertretung_today'] = $day(32); vertretung_tick($pdo); $check((int) $flag($cara)['is_on_vacation'] === 1 && (int) $flag($cara)['vacation_substitute_id'] === $anna, 'von Hand geänderter Urlaubsstatus bleibt bestehen'); unset($GLOBALS['vertretung_today']); foreach (scandir($pluginDir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') continue; is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry); } @rmdir($pluginDir); @unlink($zipPath); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo 'Vertretung plugin checks passed.' . "\n";