Quelltext des aktuellen Standes
<?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');
});
};