OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ServiceReportTest.php

37 KB
<?php

declare(strict_types=1);

use OpenNIT\Tickets\ServiceReportDocument;
use OpenNIT\Tickets\ServiceReportManager;

/**
 * Deckt die Leistungsnachweise ab.
 *
 * Drei Zusagen tragen das Modul: Ein Nachweis geht an genau eine Stelle – den
 * nächsten Standort mit Adresse –, jede erfasste Minute erscheint in genau
 * einem Nachweis, und ein herausgegebenes Dokument ändert sich nicht mehr.
 * Dazu die Verdrahtung, ohne die ein Modul zwar existiert, aber nicht
 * erreichbar ist.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);

    /** Ein Manager ohne Datenbank; nur für die Methoden ohne Abfrage. */
    $manager = static function (): ServiceReportManager {
        return (new ReflectionClass(ServiceReportManager::class))->newInstanceWithoutConstructor();
    };

    // ── Dokument ────────────────────────────────────────────────────────

    test('Minuten werden als Stunden und Minuten ausgewiesen', function () {
        assertSame('0:00 h',  ServiceReportDocument::formatMinutes(0));
        assertSame('0:05 h',  ServiceReportDocument::formatMinutes(5));
        assertSame('1:00 h',  ServiceReportDocument::formatMinutes(60));
        assertSame('2:05 h',  ServiceReportDocument::formatMinutes(125));
        assertSame('25:30 h', ServiceReportDocument::formatMinutes(1530));
        // Eine negative Dauer kann es nicht geben; sie fällt auf null zurück.
        assertSame('0:00 h',  ServiceReportDocument::formatMinutes(-30));
    });

    test('Jede Zeile nennt Ticket, Techniker und Zeit', function () use ($wurzel) {
        // Eine Position muss fuer sich stehen koennen – auch aus dem
        // Zusammenhang gerissen, etwa auf einer Folgeseite.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportDocument.php');
        preg_match('/private function entriesTable\((.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Positionstabelle wurde nicht gefunden');

        foreach (['date', 'clock', 'ticket', 'user', 'activity', 'duration'] as $spalte) {
            assertTrue(str_contains($m[1], "'" . $spalte . "'"), 'Spalte fehlt: ' . $spalte);
        }
        assertTrue(str_contains($m[1], "#' . (int)\$p['ticket_id']"), 'Die Ticketnummer steht nicht in der Zeile');
        assertTrue(str_contains($m[1], "\$p['user']"), 'Der Techniker steht nicht in der Zeile');
        assertTrue(str_contains($m[1], 'self::clock($p)'), 'Die Uhrzeit steht nicht in der Zeile');

        // Die Ueberschriften stehen an einer Stelle, zusammen mit der Auswahl.
        foreach (['Datum', 'Zeit', 'Ticket', 'Techniker', 'Tätigkeit', 'Dauer'] as $titel) {
            assertTrue(in_array($titel, ServiceReportManager::COLUMNS, true), 'Ueberschrift fehlt: ' . $titel);
        }
    });

    test('Jede Zeile sagt, was getan wurde', function () use ($wurzel) {
        // Ohne Notiz an der Erfassung traegt der Betreff des Vorgangs die
        // Zeile – auch im Monatsnachweis, wo er ueber der Gruppe schon steht.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportDocument.php');
        preg_match('/private function activity\(array \$position\): string\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Taetigkeit wurde nicht gefunden');
        assertTrue(str_contains($m[1], "\$position['note']"), 'Die Notiz der Erfassung wird nicht genutzt');
        assertTrue(str_contains($m[1], "\$position['subject']"), 'Ohne Notiz bliebe die Zeile stumm');
    });

    test('Die Uhrzeit nennt Beginn und Ende, wo beide bekannt sind', function () {
        assertSame('09:15–13:35', ServiceReportDocument::clock([
            'date' => '2026-08-10 09:15:00', 'end' => '2026-08-10 13:35:00',
        ]));
        // Eine an einer Antwort erfasste Zeit kennt kein Ende.
        assertSame('09:15', ServiceReportDocument::clock(['date' => '2026-08-10 09:15:00', 'end' => null]));
        assertSame('09:15', ServiceReportDocument::clock(['date' => '2026-08-10 09:15:00']));
        // Ein Ende vor dem Beginn ist keine Spanne.
        assertSame('09:15', ServiceReportDocument::clock([
            'date' => '2026-08-10 09:15:00', 'end' => '2026-08-10 09:15:00',
        ]));
        assertSame('—', ServiceReportDocument::clock(['date' => '']));
    });

    test('Das Ende der Erfassung wird mitgefuehrt', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        assertTrue(str_contains($quelle, 'tte.stopped_at AS bis'), 'Das Ende fehlt in der Zeitquelle');
        assertTrue(str_contains($quelle, "'end'       => \$r['bis']"), 'Das Ende landet nicht in den Positionen');
    });

    test('Die Vorlage kennt die Platzhalter, die das Dokument setzt', function () {
        foreach (['number', 'location', 'period', 'total_time', 'entries_table', 'signature_block'] as $p) {
            assertTrue(in_array($p, ServiceReportDocument::PLACEHOLDERS, true), 'Platzhalter fehlt: ' . $p);
        }
        // Ohne gespeicherte Vorlage bleibt ein Gerüst, das die Positionen zeigt.
        $geruest = ServiceReportDocument::fallbackTemplate();
        assertTrue(str_contains($geruest, '{{entries_table}}'), 'Das Gerüst zeigt keine Positionen');
        assertTrue(str_contains($geruest, '{{number}}'), 'Das Gerüst nennt keine Nummer');
    });

    test('Jeder Platzhalter der Vorlage wird auch gesetzt', function () use ($wurzel) {
        // Ein Platzhalter ohne Wert bliebe als {{…}} im Dokument stehen.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportDocument.php');
        preg_match('/private function values\(array \$report\): array\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Werteliste wurde nicht gefunden');

        foreach (ServiceReportDocument::PLACEHOLDERS as $p) {
            assertTrue(str_contains($m[1], "'" . $p . "'"), 'Ohne Wert: ' . $p);
        }
    });

    test('Die mitgelieferten Vorlagen nutzen nur bekannte Platzhalter', function () use ($wurzel) {
        $sql = (string)file_get_contents($wurzel . '/database/migrations/247_service_reports.sql');
        preg_match_all('/\{\{([a-z_]+)\}\}/', $sql, $m);

        $bekannt = array_merge(ServiceReportDocument::PLACEHOLDERS, [
            // Die E-Mail-Vorlagen kennen zusätzlich den Link und seine Frist.
            'signature_url', 'expires_at', 'total_hours', 'app_name', 'platzhalter',
        ]);
        $unbekannt = array_values(array_unique(array_diff($m[1], $bekannt)));
        assertSame([], $unbekannt, 'Unbekannte Platzhalter: ' . implode(', ', $unbekannt));
    });

    // ── Gestaltung ──────────────────────────────────────────────────────

    test('Ohne eigene Auswahl bleibt die Taetigkeit aussen vor', function () {
        // Sie stammt aus der Notiz der Zeiterfassung, die intern gedacht ist.
        assertSame(['date', 'clock', 'ticket', 'user', 'duration'], ServiceReportManager::parseColumns(null));
        assertTrue(isset(ServiceReportManager::COLUMNS['activity']), 'Zuschalten muss möglich bleiben');
    });

    test('Die Spaltenauswahl folgt der Einstellung', function () {
        assertSame(['date', 'user', 'duration'], ServiceReportManager::parseColumns('date,user,duration'));
        // Die Reihenfolge steht fest, egal wie die Auswahl ankommt.
        assertSame(['date', 'user', 'duration'], ServiceReportManager::parseColumns('user,duration,date'));
        // Unbekannte Namen fallen heraus.
        assertSame(['ticket', 'duration'], ServiceReportManager::parseColumns('ticket,preis,duration'));
    });

    test('Die Dauer bleibt in jedem Fall stehen', function () {
        // Ein Nachweis ohne Dauer waere keiner.
        assertTrue(in_array('duration', ServiceReportManager::parseColumns('date'), true));
        assertTrue(in_array('duration', ServiceReportManager::parseColumns(''), true));
        assertSame(['duration'], ServiceReportManager::parseColumns(''));
    });

    test('Die Logo-Hoehe bleibt in einem brauchbaren Bereich', function () {
        assertSame(48,  ServiceReportManager::clampLogoHeight('48'));
        assertSame(16,  ServiceReportManager::clampLogoHeight('4'));
        assertSame(200, ServiceReportManager::clampLogoHeight('9999'));
        // Ohne Angabe die Vorgabe, nicht null.
        assertSame(48,  ServiceReportManager::clampLogoHeight(''));
        assertSame(48,  ServiceReportManager::clampLogoHeight('-20'));
    });

    test('Das Logo hat einen Platzhalter und steht in den Vorlagen', function () use ($wurzel) {
        assertTrue(in_array('logo', ServiceReportDocument::PLACEHOLDERS, true), 'Kein Platzhalter für das Logo');

        $sql = (string)file_get_contents($wurzel . '/database/migrations/247_service_reports.sql');
        assertSame(2, substr_count($sql, '{{logo}}'), 'Die mitgelieferten Vorlagen zeigen kein Logo');

        // Bestandsinstanzen bekommen die Zeile nachgereicht, ohne eine
        // angepasste Vorlage zu ueberschreiben.
        $nach = (string)file_get_contents($wurzel . '/database/migrations/248_service_report_design.sql');
        assertTrue(str_contains($nach, "NOT LIKE '%{{logo}}%'"), 'Der Nachtrag hat keinen Waechter');
    });

    test('Das Logo wird eingebettet, nicht verlinkt', function () use ($wurzel) {
        // Der PDF-Erzeuger holt sonst ein Bild ueber das Netz – im Zweifel
        // steht dort nichts.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportDocument.php');
        preg_match('/private function logo\(\): string\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Das Logo wurde nicht gefunden');
        assertTrue(str_contains($m[1], 'base64_encode'), 'Das Logo wird nicht eingebettet');
        assertTrue(str_contains($m[1], "str_starts_with(\$typ, 'image/')"), 'Es wird jede Datei eingebettet');
    });

    // ── Anschreiben ─────────────────────────────────────────────────────

    test('Die Anschreiben liegen bei den Ticket-Mails', function () {
        $ticket = \OpenNIT\Email\DefaultTemplates::ticketTemplates();
        foreach (['service_report_info', 'service_report_signature'] as $key) {
            assertTrue(isset($ticket[$key]), 'Vorlage fehlt bei den Ticket-Mails: ' . $key);
            assertTrue(isset($ticket[$key]['subject'], $ticket[$key]['body']),
                'Vorlage ohne Betreff oder Rumpf: ' . $key);
        }

        // Und nicht mehr bei den allgemeinen Benachrichtigungen – sonst gaebe
        // es dieselbe Nachricht an zwei Stellen.
        $allgemein = \OpenNIT\Email\DefaultTemplates::notificationTemplates();
        assertFalse(isset($allgemein['service_report_info']), 'Die Vorlage steht doppelt');
        assertFalse(isset($allgemein['service_report_signature']), 'Die Vorlage steht doppelt');

        $sql = (string)file_get_contents(dirname(__DIR__) . '/database/migrations/248_service_report_design.sql');
        assertTrue(str_contains($sql, 'DELETE FROM `email_templates`'), 'Die alten Eintraege bleiben stehen');
    });

    test('Das Anschreiben wird wie eine Ticket-Mail aufgebaut', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/private function mail\((.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Der Versand wurde nicht gefunden');
        assertTrue(str_contains($m[1], 'ticketTemplate('), 'Es wird nicht die Ticket-Vorlage genommen');
        assertTrue(str_contains($m[1], 'applyConditions('), 'Ein {{#if}}-Block bliebe im Text stehen');
        assertTrue(str_contains($m[1], 'TicketManager::finalizeOutgoingBody'), 'Der Rumpf wird nicht aufbereitet');
    });

    test('Eine Vorlage ohne Zeile laesst sich trotzdem bearbeiten', function () use ($wurzel) {
        // Die beiden Anschreiben bringt die Anwendung mit; eine Zeile entsteht
        // erst beim ersten Anpassen – ohne den Einsatz bliebe das Speichern
        // ohne Wirkung, und in der Liste fehlte die Vorlage ganz.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/TicketManager.php');
        preg_match('/public function updateEmailTemplate\((.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Das Speichern wurde nicht gefunden');
        assertTrue(str_contains($m[1], 'SELECT id FROM ticket_email_templates'),
            'Geprüft wird die eingebaute Vorlage statt der Zeile');

        preg_match('/public function getEmailTemplates\(\): array\s*\{(.*?)\n    \}/s', $quelle, $liste);
        assertTrue(isset($liste[1]) && str_contains($liste[1], 'DefaultTemplates::ticketTemplates()'),
            'Eine neue Vorlage erscheint nicht in der Übersicht');

        // Die Ersatzvorlage muss dieselbe Form haben wie eine Zeile – die
        // Bearbeitungsseite liest daraus auch den Ereignisnamen.
        preg_match('/public function getEmailTemplate\(string \$eventType\): \?array\s*\{(.*?)\n    \}/s', $quelle, $eine);
        assertTrue(isset($eine[1]) && str_contains($eine[1], "'event_type' => \$eventType"),
            'Die Ersatzvorlage nennt ihr Ereignis nicht');
    });

    test('Die Verwaltung kennt die neuen Anschreiben', function () use ($wurzel) {
        $liste = (string)file_get_contents($wurzel . '/templates/admin/tickets/templates.php');
        assertTrue(str_contains($liste, "'service_report_info'"), 'Ohne Bezeichnung in der Übersicht');

        $edit = (string)file_get_contents($wurzel . '/templates/admin/tickets/template_edit.php');
        assertTrue(str_contains($edit, "str_starts_with((string)(\$template['event_type'] ?? ''), 'service_report_')"),
            'Der Editor zeigt die Ticket-Platzhalter statt der des Nachweises');
        assertTrue(str_contains($edit, "'signature_url'"), 'Der Link zum Unterschreiben fehlt als Platzhalter');
    });

    // ── Betriebsarten und Umfang ────────────────────────────────────────

    test('Es gibt genau drei Betriebsarten', function () {
        assertSame(['info', 'signature_link', 'signature_tablet'], ServiceReportManager::MODES);
    });

    test('Der Umfang des Monatsnachweises ist einstellbar', function () {
        assertSame(['time_in_month', 'closed_in_month'], ServiceReportManager::SCOPES);
    });

    test('Der unbeaufsichtigte Monatslauf nutzt kein Tablet', function () use ($wurzel) {
        // Am Tablet stünde niemand, der unterschreibt.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function monthlyMode\(\): string\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Betriebsart des Monatslaufs wurde nicht gefunden');
        assertTrue(str_contains($m[1], "['info', 'signature_link']"), 'Das Tablet ist nicht ausgeschlossen');
    });

    // ── Empfänger ───────────────────────────────────────────────────────

    test('Die Adresse wird am Standort gesucht und dann aufwaerts', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function resolveRecipient\(\?int \$locationId\): \?array\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Auflösung wurde nicht gefunden');
        assertTrue(str_contains($m[1], 'locationChain'), 'Es wird nicht aufgestiegen');
        assertTrue(str_contains($m[1], 'FILTER_VALIDATE_EMAIL'), 'Eine unbrauchbare Adresse würde genommen');
    });

    test('Der Aufstieg endet auch bei einer Schleife im Bestand', function () use ($wurzel) {
        // Ein Standort, der sich selbst übergeordnet ist, liefe sonst endlos.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function locationChain\(int \$locationId\): array\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Kette wurde nicht gefunden');
        assertTrue(str_contains($m[1], 'gesehen'), 'Die Kette merkt sich nichts');
    });

    test('Jeder Standort gehoert zu genau einer empfangenden Stelle', function () use ($wurzel) {
        // Sonst stünde dieselbe Minute in zwei Nachweisen.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function billingUnits\(\): array\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Zuordnung wurde nicht gefunden');
        assertTrue(str_contains($m[1], '$einheiten[$traeger][\'members\'][] = $id;'),
            'Ein Standort wird nicht genau einer Stelle zugeordnet');
        assertTrue(str_contains($m[1], 'break;'), 'Es wird über die erste Adresse hinaus gesucht');
    });

    // ── Revisionssicherheit ─────────────────────────────────────────────

    test('Ein herausgegebener Nachweis wird festgeschrieben', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        assertTrue(str_contains($quelle, 'public function freeze(int $id): void'), 'Ohne Festschreiben');
        preg_match('/public function dispatch\(int \$id\): array\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]) && str_contains($m[1], '$this->freeze($id);'),
            'Die Herausgabe schreibt das Dokument nicht fest');
        // Ein zweites Mal herausgeben würde eine zweite Fassung erzeugen.
        assertTrue(str_contains($m[1], "['signed', 'sent']"), 'Eine Herausgabe lässt sich wiederholen');
    });

    test('Die Positionen stecken im Nachweis, nicht in der Zeiterfassung', function () use ($wurzel) {
        $sql = (string)file_get_contents($wurzel . '/database/migrations/247_service_reports.sql');
        assertTrue(str_contains($sql, '`entries_json`'), 'Ohne festgehaltene Positionen');
        assertTrue(str_contains($sql, '`document_html`'), 'Ohne festgehaltenes Dokument');
    });

    test('Eine Unterschrift entwertet ihr Token', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function storeSignature\((.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Unterschrift wurde nicht gefunden');
        assertTrue(str_contains($m[1], 'signature_token = NULL'), 'Das Token bleibt gültig');
        assertTrue(str_contains($m[1], "status = 'awaiting_signature'"),
            'Ein bereits abgeschlossener Nachweis liesse sich erneut unterschreiben');
        assertTrue(str_contains($m[1], 'signature_expires_at'), 'Die Frist wird nicht geprüft');
        assertTrue(str_contains($m[1], 'data:image/png;base64,'), 'Es wird jedes Bildformat angenommen');
    });

    // ── Verdrahtung ─────────────────────────────────────────────────────

    test('Die Routen sind registriert und verlangen ein Recht', function () use ($wurzel) {
        $routen = (string)file_get_contents($wurzel . '/src/Core/Application.php');

        foreach ([
            "'/admin/tickets/service-reports'"          => 'admin_tickets',
            "'/admin/tickets/service-reports/settings'" => 'admin_tickets',
            "'/admin/tickets/service-reports/run'"      => 'admin_tickets',
            "'/tickets/{id}/service-report'"            => 'manage_tickets',
            "'/service-reports/{id}/pdf'"               => 'view_tickets',
        ] as $route => $recht) {
            assertTrue(str_contains($routen, $route), 'Route fehlt: ' . $route);
            $zeile = substr($routen, (int)strpos($routen, $route), 220);
            assertTrue(str_contains($zeile, "'capability' => '" . $recht . "'"),
                $route . ' verlangt nicht ' . $recht);
        }

        // Die öffentliche Seite kommt ohne Anmeldung aus – das Token trägt sie.
        assertTrue(str_contains($routen, "'/leistungsnachweis/{token}'"), 'Die öffentliche Seite fehlt');
    });

    test('Die Vorlagen-Route steht vor der {id}-Route', function () use ($wurzel) {
        // Sonst matcht der Router {id}=template.
        $routen = (string)file_get_contents($wurzel . '/src/Core/Application.php');
        $vorlage = (int)strpos($routen, "'/admin/tickets/service-reports/template/{key}'");
        $einzeln = (int)strpos($routen, "'/admin/tickets/service-reports/{id}/preview'");
        assertTrue($vorlage > 0 && $einzeln > 0 && $vorlage < $einzeln, 'Die Reihenfolge stimmt nicht');
    });

    test('Der Monatslauf haengt am Scheduler', function () use ($wurzel) {
        $sql = (string)file_get_contents($wurzel . '/database/migrations/247_service_reports.sql');
        assertTrue(str_contains($sql, 'ServiceReportMonthlyHandler'), 'Kein Task eingetragen');
        assertTrue(file_exists($wurzel . '/src/Cron/Handlers/ServiceReportMonthlyHandler.php'),
            'Der Handler fehlt');

        $handler = (string)file_get_contents($wurzel . '/src/Cron/Handlers/ServiceReportMonthlyHandler.php');
        assertTrue(str_contains($handler, "modules']['tickets']"), 'Der Lauf prüft das Modul nicht');
        assertTrue(str_contains($handler, 'monthlyEnabled()'), 'Der Lauf prüft die Einstellung nicht');
        assertTrue(str_contains($handler, 'monthlyDay()'), 'Der Stichtag wird nicht beachtet');
    });

    test('Ein Monat wird nicht zweimal abgerechnet', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function runMonthly\((.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]) && str_contains($m[1], 'monthlyExists'),
            'Ein zweiter Anlauf würde doppelt versenden');
        assertTrue(str_contains($m[1], "report_monthly"), 'Ein abgeschalteter Standort bekäme trotzdem einen');
    });

    test('Das Tablet kennt den Leistungsnachweis', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Controllers/SignatureController.php');
        assertTrue(str_contains($quelle, 'pendingServiceReport'), 'Das Tablet zeigt keinen Nachweis');
        assertTrue(str_contains($quelle, 'storeSignature'), 'Das Tablet nimmt keine Unterschrift entgegen');
        assertTrue(str_contains($quelle, "'signature_tablet'"), 'Es würde jeder Nachweis am Tablet landen');
    });

    test('Der Abschluss eines Tickets fragt oder folgt der Vorgabe', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Controllers/TicketController.php');
        assertTrue(str_contains($quelle, 'leistungsnachweisNachAbschluss'), 'Der Abschluss erzeugt nichts');
        preg_match('/private function leistungsnachweisNachAbschluss\((.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Stelle wurde nicht gefunden');
        assertTrue(str_contains($m[1], 'ticketDefaultMode()'), 'Die Vorgabe greift nicht');
        assertTrue(str_contains($m[1], "\$modus === 'none'"), 'Das Abschalten wirkt nicht');
        assertTrue(str_contains($m[1], 'enabled()'), 'Es wird auch ohne eingeschaltete Funktion erstellt');
    });

    test('Das Schliessen-Fenster fragt nur ohne Vorgabe', function () use ($wurzel) {
        $modal = (string)file_get_contents($wurzel . '/templates/tickets/_close_confirm_modal.php');
        assertTrue(str_contains($modal, "(\$service_report_mode ?? '') === ''"), 'Es wird immer gefragt');
        assertTrue(str_contains($modal, "(int)(\$service_report_minutes ?? 0) > 0"),
            'Ohne erfasste Zeit wird trotzdem gefragt');

        // Die Auswahl steht im Modal, abgeschickt wird das Formular daneben.
        $js = (string)file_get_contents($wurzel . '/templates/tickets/_js_misc.php');
        assertTrue(str_contains($js, 'tsUebernehmeNachweis'), 'Die Auswahl erreicht den Server nicht');
    });

    test('Gezaehlt wird dieselbe Zeit, die spaeter im Nachweis steht', function () use ($wurzel) {
        // Die Stoppuhr-Summe am Ticket laesst die an einer Antwort erfasste
        // Zeit aus; danach zu fragen haette Nachweise verschwiegen.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        assertTrue(str_contains($quelle, 'public function minutesForTicket(int $ticketId): int'),
            'Die eigene Summe fehlt');
        preg_match('/public function minutesForTicket\(int \$ticketId\): int\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]) && str_contains($m[1], 'TIME_SOURCE'),
            'Es wird nicht aus der Quelle der Positionen gezählt');

        $ctrl = (string)file_get_contents($wurzel . '/src/Controllers/TicketController.php');
        assertTrue(str_contains($ctrl, "'service_report_minutes'"), 'Die Summe erreicht die Vorlage nicht');
    });

    test('Die Standortadresse laesst sich pflegen', function () use ($wurzel) {
        $tpl = (string)file_get_contents($wurzel . '/templates/admin/locations/index.php');
        assertTrue(str_contains($tpl, 'name="report_email"'), 'Kein Feld für die Adresse');
        assertTrue(str_contains($tpl, 'name="report_monthly"'), 'Der Monatslauf lässt sich nicht abschalten');

        $ctrl = (string)file_get_contents($wurzel . '/src/Controllers/Admin/LocationAdminController.php');
        assertTrue(str_contains($ctrl, "'report_email'"), 'Die Adresse wird nicht gespeichert');
        assertTrue(str_contains($ctrl, 'FILTER_VALIDATE_EMAIL'), 'Jede Eingabe würde gespeichert');
    });

    test('Die Nummer laeuft je Jahr fort', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/private function nextNumber\(\): string\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Nummernvergabe wurde nicht gefunden');
        assertTrue(str_contains($m[1], "date('Y')"), 'Die Nummer trägt kein Jahr');
        assertTrue(str_contains($m[1], 'MAX(number)'), 'Es wird nicht fortgezählt');

        $sql = (string)file_get_contents($wurzel . '/database/migrations/247_service_reports.sql');
        assertTrue(str_contains($sql, 'UNIQUE KEY `uq_sr_number`'), 'Eine Nummer könnte doppelt vergeben werden');
    });

    test('Jede Aenderung steht im Audit-Log', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        foreach ([
            'service_report_created',
            'service_report_sent',
            'service_report_signed',
            'service_report_cancelled',
            'service_report_signature_requested',
        ] as $eintrag) {
            assertTrue(str_contains($quelle, $eintrag), 'Ohne Eintrag: ' . $eintrag);
        }
    });

    test('Die Migrationen sind wiederholbar', function () use ($wurzel) {
        foreach ([
            '247_service_reports.sql',
            '248_service_report_design.sql',
            '249_service_report_letter.sql',
        ] as $datei) {
            $sql = (string)file_get_contents($wurzel . '/database/migrations/' . $datei);
            assertFalse((bool)preg_match('/\bTRUNCATE\b/i', $sql), 'TRUNCATE in ' . $datei);
            // Jede Datenzeile muss einen zweiten Lauf unbeschadet überstehen.
            preg_match_all('/\bINSERT\b(?!\s+IGNORE)/i', $sql, $roh);
            assertSame([], $roh[0], 'INSERT ohne IGNORE in ' . $datei);
        }

        $erste = (string)file_get_contents($wurzel . '/database/migrations/247_service_reports.sql');
        assertTrue(str_contains($erste, 'CREATE TABLE IF NOT EXISTS'), 'Tabellen ohne IF NOT EXISTS');

        // Der Nachtrag darf beim zweiten Lauf nichts erneut anhaengen: jedes
        // UPDATE braucht eine Bedingung, die den bereits erledigten Fall ausnimmt.
        foreach (['248_service_report_design.sql', '249_service_report_letter.sql'] as $datei) {
            $sql = (string)file_get_contents($wurzel . '/database/migrations/' . $datei);
            assertSame(
                preg_match_all('/^\s*UPDATE\b/mi', $sql),
                preg_match_all('/NOT LIKE/i', $sql),
                'Ein UPDATE ohne Waechter in ' . $datei
            );
        }
    });

    // ── Zeiteinheiten ───────────────────────────────────────────────────

    test('Eine angefangene Zeiteinheit zaehlt voll', function () {
        // Die Vorgabe: aufrunden. Zwei Minuten sind bei 15er-Einheiten eine.
        assertSame(1, ServiceReportManager::toUnits(2, 15));
        assertSame(1, ServiceReportManager::toUnits(15, 15));
        assertSame(2, ServiceReportManager::toUnits(16, 15));
        assertSame(4, ServiceReportManager::toUnits(60, 15));
        assertSame(9, ServiceReportManager::toUnits(125, 15));
        // Ohne Zeit keine Einheit – sonst stünde auf jedem Nachweis eine zu viel.
        assertSame(0, ServiceReportManager::toUnits(0, 15));
        assertSame(0, ServiceReportManager::toUnits(-5, 15));
    });

    test('Die Rundung laesst sich umstellen', function () {
        assertSame(1, ServiceReportManager::toUnits(20, 30, 'nearest'));
        assertSame(1, ServiceReportManager::toUnits(44, 30, 'nearest'));
        assertSame(2, ServiceReportManager::toUnits(46, 30, 'nearest'));
        // Kaufmännisch gerundet bleibt eine kurze Leistung trotzdem eine
        // Einheit; niemand weist null Aufwand für geleistete Arbeit aus.
        assertSame(1, ServiceReportManager::toUnits(2, 30, 'nearest'));

        assertSame(0, ServiceReportManager::toUnits(29, 30, 'down'));
        assertSame(1, ServiceReportManager::toUnits(30, 30, 'down'));
        assertSame(2, ServiceReportManager::toUnits(125, 60, 'down'));

        // Ein unbekannter Name fällt auf die Vorgabe zurück.
        assertSame(3, ServiceReportManager::toUnits(125, 60, 'unsinn'));

        foreach (array_keys(ServiceReportManager::ROUNDINGS) as $art) {
            assertTrue(ServiceReportManager::toUnits(90, 15, $art) > 0, 'Rundung ohne Wirkung: ' . $art);
        }
    });

    test('Die Laenge einer Einheit bleibt im Rahmen', function () {
        assertSame(15,  ServiceReportManager::clampUnitMinutes(''));
        assertSame(15,  ServiceReportManager::clampUnitMinutes(0));
        assertSame(15,  ServiceReportManager::clampUnitMinutes(-30));
        assertSame(30,  ServiceReportManager::clampUnitMinutes('30'));
        assertSame(480, ServiceReportManager::clampUnitMinutes(99999));
    });

    test('Die Summe entsteht aus den gerundeten Positionen', function () use ($wurzel) {
        // Erst runden, dann summieren – sonst passt die Gesamtzeile nicht zu
        // den Zeilen darüber, und der Empfänger rechnet nach.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportDocument.php');
        preg_match('/private function totalUnits\(array \$positionen\): int\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Summe der Einheiten wurde nicht gefunden');
        assertTrue(str_contains($m[1], '$this->units('), 'Es wird die Gesamtdauer gerundet statt jede Position');
    });

    test('Die Darstellung des Aufwands ist einstellbar', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        foreach ([
            'service_report_time_mode',
            'service_report_unit_minutes',
            'service_report_unit_rounding',
            'service_report_unit_label',
        ] as $key) {
            assertTrue(str_contains($quelle, $key), 'Einstellung fehlt: ' . $key);
        }

        $ctrl = (string)file_get_contents($wurzel . '/src/Controllers/Admin/ServiceReportAdminController.php');
        foreach (['service_report_time_mode', 'service_report_unit_minutes', 'service_report_unit_rounding'] as $key) {
            assertTrue(str_contains($ctrl, $key), 'Wird nicht gespeichert: ' . $key);
        }

        $tpl = (string)file_get_contents($wurzel . '/templates/admin/service-reports/index.php');
        assertTrue(str_contains($tpl, 'name="time_mode"'), 'Die Darstellung lässt sich nicht wählen');
        assertTrue(str_contains($tpl, 'name="unit_minutes"'), 'Die Länge einer Einheit lässt sich nicht setzen');
        assertTrue(str_contains($tpl, 'name="unit_rounding"'), 'Die Rundung lässt sich nicht wählen');
    });

    // ── Briefform ───────────────────────────────────────────────────────

    test('Der Nachweis traegt die Bausteine eines Briefes', function () use ($wurzel) {
        foreach (['sender_line', 'address_block', 'info_block', 'footer', 'total_units'] as $platzhalter) {
            assertTrue(in_array($platzhalter, ServiceReportDocument::PLACEHOLDERS, true),
                'Platzhalter fehlt: ' . $platzhalter);
        }

        $sql = (string)file_get_contents($wurzel . '/database/migrations/249_service_report_letter.sql');
        foreach (['{{sender_line}}', '{{address_block}}', '{{info_block}}', '{{footer}}'] as $platzhalter) {
            assertTrue(str_contains($sql, $platzhalter), 'Die Vorlage nutzt ' . $platzhalter . ' nicht');
        }
    });

    test('Leere Angaben lassen keine leeren Zeilen zurueck', function () use ($wurzel) {
        // Ein Brief mit Lücken im Anschriftenfeld ist nicht versandfähig.
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportDocument.php');
        foreach (['senderLine', 'addressBlock', 'infoBlock', 'footer'] as $baustein) {
            preg_match('/private function ' . $baustein . '\((.*?)\): string\s*\{(.*?)\n    \}/s', $quelle, $m);
            assertTrue(isset($m[2]), 'Baustein fehlt: ' . $baustein);
            assertTrue(str_contains($m[2], 'array_filter') || str_contains($m[2], "trim(\$wert) === ''"),
                'Leere Angaben erzeugen eine leere Zeile: ' . $baustein);
        }
    });

    test('Die Anschrift wird wie die Adresse nach oben gesucht', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function addressFor\(\?int \$locationId\): array\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Die Anschrift wird nicht aufgelöst');
        assertTrue(str_contains($m[1], 'locationChainFull'), 'Es wird nicht die Standortkette abgelaufen');
        // Maßgeblich ist eine tatsächliche Anschrift, nicht ein gefülltes Feld.
        assertTrue(str_contains($m[1], 'report_street') && str_contains($m[1], 'report_city'),
            'Eine halbe Anschrift würde die Suche beenden');
    });

    test('Die Anschrift steht nur bei aktiven Nachweisen im Standort', function () use ($wurzel) {
        $tpl = (string)file_get_contents($wurzel . '/templates/admin/locations/index.php');
        assertTrue(str_contains($tpl, 'name="report_street"'), 'Kein Feld für die Straße');
        assertTrue(str_contains($tpl, 'if (!empty($service_reports))'),
            'Die Anschrift erscheint auch ohne Leistungsnachweise');

        $ctrl = (string)file_get_contents($wurzel . '/src/Controllers/Admin/LocationAdminController.php');
        foreach ([
            'report_recipient', 'report_addition', 'report_street',
            'report_zip', 'report_city', 'report_country', 'report_customer_number',
        ] as $feld) {
            assertTrue(str_contains($ctrl, "'" . $feld . "'"), 'Wird nicht gespeichert: ' . $feld);
        }
    });

    test('Der Ansprechpartner ist fest oder der Bearbeiter', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function contactFor\(array \$report\): string\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]), 'Der Ansprechpartner wird nicht ermittelt');
        assertTrue(str_contains($m[1], "contactMode() === 'fixed'"), 'Die feste Angabe greift nicht');
        assertTrue(str_contains($m[1], 'assignee_id'), 'Der Bearbeiter des Tickets wird nicht gelesen');
        // Ohne Bearbeiter bleibt der Monatsnachweis nicht ohne Kontakt.
        assertTrue(str_contains($m[1], 'arsort'), 'Der größte Anteil entscheidet nicht');
    });

    test('Der Absender steht zentral und nicht in der Vorlage', function () use ($wurzel) {
        $ctrl = (string)file_get_contents($wurzel . '/src/Controllers/Admin/ServiceReportAdminController.php');
        assertTrue(str_contains($ctrl, "'service_report_sender_' . \$feld"), 'Der Absender wird nicht gespeichert');
        $tpl = (string)file_get_contents($wurzel . '/templates/admin/service-reports/index.php');
        foreach (['company', 'street', 'zip', 'city', 'phone', 'email', 'web'] as $feld) {
            assertTrue(str_contains($tpl, 'name="sender_' . $feld . '"'), 'Absenderfeld fehlt: ' . $feld);
        }

        $quelle = (string)file_get_contents($wurzel . '/src/Tickets/ServiceReportManager.php');
        preg_match('/public function sender\(\): array\s*\{(.*?)\n    \}/s', $quelle, $m);
        assertTrue(isset($m[1]) && str_contains($m[1], "\$this->config['app']['name']"),
            'Ohne Eintrag bleibt der Absender leer');
    });
};