Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
+8
View File
@@ -106,6 +106,14 @@ const schema = z.object({
LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(),
// Wartung und Prüfung (Phase 2)
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
/** Wann Erinnerungen verschickt werden — nach der Planung, nicht davor. */
WARTUNG_ERINNERUNG_LAUF: z.string().default("45 3 * * *"),
/** Wie weit im Voraus Termine angelegt werden. */
WARTUNG_PLANUNGSHORIZONT_TAGE: zahl(180),
// Aufbewahrung
AUFBEWAHRUNG_JAHRE: zahl(10),
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
+63
View File
@@ -0,0 +1,63 @@
/**
* Datei-Upload aus dem Browser.
*
* Gegenstück zu src/app/api/dateien/route.ts. Bewusst kein tRPC: Dateien
* gehören als Multipart durch die Leitung, nicht Base64-kodiert durch eine
* JSON-Antwort.
*
* Diese Datei läuft im Browser — hier darf nichts aus lib/storage oder lib/db
* hineingeraten.
*/
export interface HochgeladeneDatei {
id: string;
dateiname: string;
mimeTyp: string;
groesseBytes: number;
}
export class UploadFehler extends Error {}
/**
* Lädt eine Datei hoch und gibt den angelegten Datensatz zurück.
*
* @param entitaet woran die Datei hängt, z. B. "Pruefung"
* @param entitaetId der zugehörige Datensatz, sofern schon vorhanden
* @param kategorie "foto", "signatur" oder "fremdprotokoll"
*/
export async function dateiHochladen(
datei: File | Blob,
entitaet: string,
entitaetId?: string | null,
kategorie = "foto",
dateiname?: string,
): Promise<HochgeladeneDatei> {
const formular = new FormData();
formular.append(
"datei",
datei,
dateiname ?? (datei instanceof File ? datei.name : "datei.png"),
);
formular.append("entitaet", entitaet);
if (entitaetId) formular.append("entitaetId", entitaetId);
formular.append("kategorie", kategorie);
const antwort = await fetch("/api/dateien", { method: "POST", body: formular });
if (!antwort.ok) {
const inhalt = (await antwort.json().catch(() => null)) as { fehler?: string } | null;
throw new UploadFehler(inhalt?.fehler ?? `Der Upload schlug fehl (${antwort.status}).`);
}
return (await antwort.json()) as HochgeladeneDatei;
}
/** Wandelt den Inhalt eines Zeichenfelds in eine PNG-Datei um. */
export async function canvasAlsPng(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((auflösen, ablehnen) => {
canvas.toBlob((blob) => {
if (blob) auflösen(blob);
else ablehnen(new UploadFehler("Die Unterschrift ließ sich nicht in ein Bild umwandeln."));
}, "image/png");
});
}
+8 -4
View File
@@ -23,10 +23,14 @@ import { formatInTimeZone } from "date-fns-tz";
*
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
* unabhängig davon, wo der Server steht.
*
* Die vier Grundfunktionen sind ab Phase 2 exportiert: die Wartungsplanung
* rechnet mit denselben Kalendertagen, und zwei Umsetzungen derselben
* Datumsarithmetik würden früher oder später um einen Tag auseinanderlaufen.
*/
/** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */
function utcTag(wert: Date): Date {
export function utcTag(wert: Date): Date {
return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate()));
}
@@ -35,7 +39,7 @@ function utcTag(wert: Date): Date {
* geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der
* 3. März.
*/
function monateAddieren(basis: Date, monate: number): Date {
export function monateAddieren(basis: Date, monate: number): Date {
const jahr = basis.getUTCFullYear();
const monat = basis.getUTCMonth() + monate;
const tag = basis.getUTCDate();
@@ -56,7 +60,7 @@ function jahreAddieren(basis: Date, jahre: number): Date {
* nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht
* abhängen.
*/
function heutigerTag(jetzt: Date): Date {
export function heutigerTag(jetzt: Date): Date {
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
.split("-")
.map(Number);
@@ -64,7 +68,7 @@ function heutigerTag(jetzt: Date): Date {
}
/** Kalendertage zwischen zwei UTC-Tagen. */
function tagesabstand(von: Date, bis: Date): number {
export function tagesabstand(von: Date, bis: Date): number {
return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Bewertung einer Prüfung.
*
* Die Frage, ob ein Gerät bestanden hat, wird hier beantwortet und nirgends
* sonst. Weder der Browser noch die Datenbank entscheiden das mit: der Browser
* nicht, weil man ihm nicht trauen kann, und die Datenbank nicht, weil die
* Regel fachlich ist und sich ändern kann.
*
* Die Regel in einem Satz: ein nicht erfüllter Pflichtpunkt bedeutet "nicht
* bestanden", ein nicht erfüllter Kann-Punkt "bestanden mit Mängeln".
*
* Dahinter steckt die Festlegung, dass Pflichtpunkte genau die sind, an denen
* die Einsatzbereitschaft hängt — deshalb heißen sie so. Wer einen Punkt als
* Pflicht anlegt, sagt damit: fällt der durch, geht das Gerät aus dem Dienst.
*/
export type PunktTyp = "JA_NEIN" | "MESSWERT" | "TEXT" | "FOTO";
export type Ergebnis = "BESTANDEN" | "BESTANDEN_MIT_MAENGELN" | "NICHT_BESTANDEN";
export const ERGEBNIS_BEZEICHNUNG: Record<Ergebnis, string> = {
BESTANDEN: "bestanden",
BESTANDEN_MIT_MAENGELN: "bestanden mit Mängeln",
NICHT_BESTANDEN: "nicht bestanden",
};
/** Ein Checklistenpunkt, so weit die Bewertung ihn braucht. */
export interface Punkt {
id: string;
text: string;
typ: PunktTyp;
pflicht: boolean;
minWert?: number | null;
maxWert?: number | null;
}
/** Die Antwort darauf. */
export interface Antwort {
punktId: string;
jaNein?: boolean | null;
zahlwert?: number | null;
text?: string | null;
/** Kennung der hochgeladenen Datei, wenn ein Foto verlangt war. */
fotoDateiId?: string | null;
/** Vom Prüfer übersteuert. Fehlt die Angabe, rechnet der Server sie aus. */
inOrdnung?: boolean | null;
}
/**
* Liegt ein Messwert im zulässigen Bereich?
*
* Fehlt eine Grenze, schränkt sie nicht ein — "mindestens 270 bar" ist ein
* Bereich mit Untergrenze und ohne Obergrenze. Fehlt der Wert selbst, ist die
* Frage unbeantwortet; das fängt vollstaendig() ab, nicht diese Funktion.
*/
export function messwertInOrdnung(
wert: number | null | undefined,
minWert?: number | null,
maxWert?: number | null,
): boolean {
if (wert === null || wert === undefined || Number.isNaN(wert)) return false;
if (minWert !== null && minWert !== undefined && wert < minWert) return false;
if (maxWert !== null && maxWert !== undefined && wert > maxWert) return false;
return true;
}
/** Ist der Punkt überhaupt beantwortet? */
export function beantwortet(punkt: Punkt, antwort: Antwort | undefined): boolean {
if (!antwort) return false;
switch (punkt.typ) {
case "JA_NEIN":
return antwort.jaNein === true || antwort.jaNein === false;
case "MESSWERT":
return (
antwort.zahlwert !== null &&
antwort.zahlwert !== undefined &&
!Number.isNaN(antwort.zahlwert)
);
case "TEXT":
return Boolean(antwort.text?.trim());
case "FOTO":
return Boolean(antwort.fotoDateiId);
}
}
/**
* Ob ein einzelner Punkt in Ordnung ist.
*
* Die Angabe des Prüfers hat Vorrang: bei einem Messwert innerhalb der Toleranz
* kann trotzdem etwas nicht stimmen, und wer davorsteht, sieht mehr als die
* Zahl. Ohne ausdrückliche Angabe entscheidet die Art des Punktes:
*
* ja_nein die Antwort selbst
* messwert der zulässige Bereich
* text/foto beantwortet heißt in Ordnung — hier gibt es nichts zu rechnen
*/
export function punktInOrdnung(punkt: Punkt, antwort: Antwort | undefined): boolean {
if (!antwort) return false;
if (antwort.inOrdnung === true || antwort.inOrdnung === false) return antwort.inOrdnung;
switch (punkt.typ) {
case "JA_NEIN":
return antwort.jaNein === true;
case "MESSWERT":
return messwertInOrdnung(antwort.zahlwert, punkt.minWert, punkt.maxWert);
case "TEXT":
case "FOTO":
return beantwortet(punkt, antwort);
}
}
/** Pflichtpunkte, die noch offen sind. Solange welche übrig sind, ist die
* Prüfung nicht abschließbar. */
export function offenePflichtpunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
return punkte.filter((punkt) => punkt.pflicht && !beantwortet(punkt, nachId.get(punkt.id)));
}
/** Punkte, die nicht in Ordnung sind — die Vorlage für die Mängelmeldung. */
export function beanstandetePunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
return punkte.filter((punkt) => {
const antwort = nachId.get(punkt.id);
if (!beantwortet(punkt, antwort)) return false;
return !punktInOrdnung(punkt, antwort);
});
}
/**
* Das Gesamtergebnis.
*
* Es ist ein Vorschlag, kein Urteil: der Prüfer darf es übersteuern, denn er
* trägt die Verantwortung. Was er wählt, steht im Protokoll — was hier
* herauskommt, ist die Vorbelegung und der Abgleich dazu.
*/
export function ergebnisVorschlag(punkte: Punkt[], antworten: Antwort[]): Ergebnis {
const beanstandet = beanstandetePunkte(punkte, antworten);
if (beanstandet.length === 0) return "BESTANDEN";
if (beanstandet.some((punkt) => punkt.pflicht)) return "NICHT_BESTANDEN";
return "BESTANDEN_MIT_MAENGELN";
}
/**
* Welchen Status das Gerät nach dieser Prüfung haben soll.
*
* Nicht bestanden heißt gesperrt — ein Gerät, das die Prüfung nicht besteht,
* gehört nicht auf das Fahrzeug. Mit Mängeln bestanden bleibt einsatzbereit;
* der Mangel wird verfolgt, aber er hindert den Einsatz nicht.
*/
export function geraetStatusNachPruefung(
ergebnis: Ergebnis,
): "EINSATZBEREIT" | "GESPERRT" {
return ergebnis === "NICHT_BESTANDEN" ? "GESPERRT" : "EINSATZBEREIT";
}
/** Wie lange die Prüfung gedauert hat, in vollen Minuten. */
export function dauerInMinuten(begonnenUm: Date | null | undefined, beendetUm: Date): number | null {
if (!begonnenUm) return null;
const minuten = Math.round((beendetUm.getTime() - begonnenUm.getTime()) / 60_000);
return minuten >= 0 ? minuten : null;
}
+166
View File
@@ -0,0 +1,166 @@
import {
heutigerTag,
monateAddieren,
tagesabstand,
utcTag,
} from "@/lib/stammdaten/fristen";
/**
* Fristenlogik der Wartung.
*
* Wie in Phase 1: reine Funktionen ohne Datenbankzugriff. Was hier entschieden
* wird — wann ein Termin fällig ist, in welchen Topf er im Dashboard fällt, ob
* heute erinnert wird — muss ohne laufenden Stack durchtestbar sein.
*
* Alle Angaben sind Kalendertage in Europe/Berlin, gerechnet auf UTC-Basis.
* Die Uhrzeit spielt bei Prüffristen keine Rolle.
*/
/** Die Töpfe des Fristen-Dashboards. */
export type Fristfenster =
| "ueberfaellig"
| "diese_woche"
| "dieser_monat"
| "naechste_90_tage"
| "spaeter";
export const FRISTFENSTER_BEZEICHNUNG: Record<Fristfenster, string> = {
ueberfaellig: "überfällig",
diese_woche: "diese Woche",
dieser_monat: "diesen Monat",
naechste_90_tage: "nächste 90 Tage",
spaeter: "später",
};
/** Reihenfolge der Anzeige — dringend zuerst. */
export const FRISTFENSTER_REIHENFOLGE: Fristfenster[] = [
"ueberfaellig",
"diese_woche",
"dieser_monat",
"naechste_90_tage",
"spaeter",
];
/**
* Die Grenzen der Töpfe, als UTC-Kalendertage.
*
* "Diese Woche" heißt bewusst "innerhalb von sieben Tagen" und nicht "bis
* Sonntag": am Freitag interessiert den Gerätewart, was bis nächsten Freitag
* ansteht, nicht was das Kalenderblatt sagt. Dasselbe gilt für den Monat.
*/
export function fensterGrenzen(heute: Date = new Date()) {
const start = heutigerTag(heute);
const plusTage = (tage: number) => new Date(start.getTime() + tage * 86_400_000);
return {
heute: start,
inSiebenTagen: plusTage(7),
inDreissigTagen: plusTage(30),
inNeunzigTagen: plusTage(90),
};
}
/** In welchen Topf ein Fälligkeitsdatum fällt. */
export function terminFenster(faelligAm: Date, heute: Date = new Date()): Fristfenster {
const tage = tagesabstand(heutigerTag(heute), faelligAm);
if (tage < 0) return "ueberfaellig";
if (tage <= 7) return "diese_woche";
if (tage <= 30) return "dieser_monat";
if (tage <= 90) return "naechste_90_tage";
return "spaeter";
}
/**
* Nächste Fälligkeit nach einer Prüfung.
*
* Basis ist das Datum der letzten durchgeführten Prüfung. Gibt es keine, zählt
* die Indienststellung des Geräts — eine Wehr, die ein Gerät vor drei Jahren in
* Dienst gestellt und nie geprüft hat, soll das im Dashboard sehen und nicht
* durch einen fehlenden Termin verdeckt bekommen.
*
* Ohne beides gibt es keinen Termin. Das ist kein Fehler: bei einem Gerät ohne
* jede Datumsangabe wäre jedes errechnete Datum geraten.
*/
export function naechsteFaelligkeit(angaben: {
letztePruefung?: Date | null;
indienststellung?: Date | null;
intervallMonate: number;
}): Date | null {
const { letztePruefung, indienststellung, intervallMonate } = angaben;
if (!intervallMonate || intervallMonate <= 0) return null;
const basis = letztePruefung ?? indienststellung;
if (!basis) return null;
return monateAddieren(utcTag(basis), intervallMonate);
}
/** Vorwarnstufen der Erinnerung, wie sie im Schema stehen. */
export type Erinnerungsstufe = "T_30" | "T_7" | "T_0" | "UEBERFAELLIG";
/**
* Welche Erinnerung heute ansteht — oder keine.
*
* Der nächtliche Lauf ruft das für jeden offenen Termin auf. Dass eine Stufe
* nicht zweimal verschickt wird, entscheidet nicht diese Funktion, sondern der
* Erinnerungs-Eintrag in der Datenbank: je Termin und Stufe gibt es ihn nur
* einmal.
*
* Deshalb steht hier "kleiner oder gleich" und nicht "genau": ein Termin, der
* erst 20 Tage vor Fälligkeit angelegt wird, soll seine erste Erinnerung sofort
* bekommen und nicht durchrutschen, weil der Tag T-30 nie eingetreten ist.
*
* @param vorwarnzeitTage Vorwarnzeit der Vorlage. Sie ersetzt die 30 Tage der
* ersten Stufe; die Stufen T-7 und T-0 bleiben fest.
*/
export function erinnerungsstufe(
faelligAm: Date,
vorwarnzeitTage = 30,
heute: Date = new Date(),
): Erinnerungsstufe | null {
const tage = tagesabstand(heutigerTag(heute), faelligAm);
if (tage < 0) return "UEBERFAELLIG";
if (tage === 0) return "T_0";
if (tage <= 7) return "T_7";
if (tage <= Math.max(vorwarnzeitTage, 7)) return "T_30";
return null;
}
/**
* Bis wann im Voraus geplant wird.
*
* Der nächtliche Lauf legt Termine nicht bis in alle Ewigkeit an, sondern nur
* innerhalb dieses Horizonts. Sonst stünden im Dashboard Termine für 2031, und
* jede Änderung an einer Vorlage müsste hunderte Zukunftstermine nachziehen.
*/
export function innerhalbPlanungshorizont(
faelligAm: Date,
horizontTage: number,
heute: Date = new Date(),
): boolean {
return tagesabstand(heutigerTag(heute), faelligAm) <= horizontTage;
}
/**
* Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042".
*
* Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz,
* sprechbar und ohne Bindestrichwüste.
*/
export function protokollNummer(jahr: number, laufend: number): string {
return `${jahr}-${String(laufend).padStart(4, "0")}`;
}
/** Zerlegt eine Protokollnummer wieder. Gibt null zurück, wenn sie nicht passt. */
export function protokollNummerLesen(
nummer: string | null | undefined,
): { jahr: number; laufend: number } | null {
if (!nummer) return null;
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
if (!treffer) return null;
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
}
+119
View File
@@ -0,0 +1,119 @@
import { heutigerTag } from "@/lib/stammdaten/fristen";
import { innerhalbPlanungshorizont, naechsteFaelligkeit } from "@/lib/wartung/fristen";
/**
* Terminplanung.
*
* Der nächtliche Lauf beantwortet für jedes Gerät und jede Vorlage dieselbe
* Frage: steht ein Termin an, den es noch nicht gibt? Die Antwort steht hier
* als reine Funktion — der Job in src/worker holt die Daten und schreibt das
* Ergebnis, entscheiden tut er nichts.
*
* Das ist Absicht: die Planung ist die Stelle, an der eine Wehr merkt, dass ein
* Gerät seit vier Jahren nicht geprüft wurde. Solche Logik gehört testbar, ohne
* dass eine Datenbank, ein Redis und eine Uhr richtig stehen müssen.
*/
/** Ein Gerät, so weit die Planung es braucht. */
export interface PlanungsGeraet {
id: string;
inventarnummer: string;
status: string;
indienststellung?: Date | null;
/** Datum der letzten abgeschlossenen Prüfung nach dieser Vorlage. */
letztePruefungZurVorlage?: Date | null;
/** Ersatzweise das Prüfdatum am Gerät selbst (Phase 1, aus dem Import). */
letztePruefung?: Date | null;
/** Es gibt bereits einen offenen Termin für diese Vorlage. */
hatOffenenTermin: boolean;
}
export interface Planungsvorlage {
id: string;
intervallMonate: number;
}
export interface GeplanterTermin {
geraetId: string;
vorlageId: string;
faelligAm: Date;
/** Es gab keinen Anhaltspunkt für ein Datum — der Termin ist sofort fällig. */
ohneBasisdatum: boolean;
}
/** Geräte, für die grundsätzlich nicht geplant wird. */
const NICHT_PLANEN = new Set(["AUSGESONDERT"]);
/**
* Welche Termine anzulegen sind.
*
* Vier Fälle, in dieser Reihenfolge:
*
* 1. Ausgesonderte Geräte fallen raus. Ein ausgesondertes Gerät wird nicht
* mehr geprüft, und ein Termin darauf wäre nur Rauschen im Dashboard.
* 2. Gibt es schon einen offenen Termin, kommt kein zweiter dazu. Erst wenn
* der erledigt ist, wird der nächste geplant.
* 3. Fehlt jeder Anhaltspunkt — keine frühere Prüfung, keine Indienststellung
* —, wird der Termin auf heute gesetzt. Ein Gerät ohne jedes Datum ist
* nicht "in Ordnung", sondern ungeklärt, und genau so soll es auffallen.
* 4. Sonst: letzte Prüfung plus Intervall. Liegt das Ergebnis jenseits des
* Planungshorizonts, wird noch nicht geplant.
*/
export function zuPlanendeTermine(
vorlage: Planungsvorlage,
geraete: PlanungsGeraet[],
optionen: { horizontTage: number; heute?: Date },
): GeplanterTermin[] {
const heute = optionen.heute ?? new Date();
const heutigerStichtag = heutigerTag(heute);
const geplant: GeplanterTermin[] = [];
for (const geraet of geraete) {
if (NICHT_PLANEN.has(geraet.status)) continue;
if (geraet.hatOffenenTermin) continue;
const basis = geraet.letztePruefungZurVorlage ?? geraet.letztePruefung ?? null;
const faellig = naechsteFaelligkeit({
letztePruefung: basis,
indienststellung: geraet.indienststellung,
intervallMonate: vorlage.intervallMonate,
});
if (!faellig) {
geplant.push({
geraetId: geraet.id,
vorlageId: vorlage.id,
faelligAm: heutigerStichtag,
ohneBasisdatum: true,
});
continue;
}
if (!innerhalbPlanungshorizont(faellig, optionen.horizontTage, heute)) continue;
geplant.push({
geraetId: geraet.id,
vorlageId: vorlage.id,
faelligAm: faellig,
ohneBasisdatum: false,
});
}
return geplant;
}
/**
* Der Prüftermin, der am Gerät steht.
*
* Ein Gerät kann mehreren Vorlagen unterliegen — ein Pressluftatmer wird
* jährlich sichtgeprüft und alle sechs Jahre grundüberholt. Am Gerät selbst
* steht die nächste Frist, und das ist immer die früheste offene.
*/
export function fruehesteFaelligkeit(termine: Array<{ faelligAm: Date }>): Date | null {
if (termine.length === 0) return null;
return termine.reduce(
(frueheste, termin) => (termin.faelligAm < frueheste ? termin.faelligAm : frueheste),
termine[0]!.faelligAm,
);
}
+308
View File
@@ -0,0 +1,308 @@
import "server-only";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { herunterladen, hochladen, objektSchluessel } from "@/lib/storage";
import {
alsDataUri,
protokollPdf,
type ProtokollDaten,
type ProtokollPunkt,
} from "@/lib/wartung/protokoll";
import type { Ergebnis, PunktTyp } from "@/lib/wartung/bewertung";
/**
* Vom Datensatz zum abgelegten PDF.
*
* Läuft im Hintergrundprozess, nicht in der Anfrage: Gotenberg braucht ein paar
* Sekunden, und die Fotos wollen aus MinIO geholt und eingebettet werden. Wer
* gerade eine Prüfung abgeschlossen hat, soll dabei nicht warten.
*
* Der Ablauf ist bewusst dreigeteilt — lesen, rendern, schreiben. Zwischen
* Lesen und Schreiben liegen Netzaufrufe zu MinIO und Gotenberg; die dürfen
* dauern. Eine offene Datenbanktransaktion dürfte das nicht, sie liefe in ihr
* Zeitlimit und gäbe eine Verbindung nicht frei.
*
* Der Vorgang ist wiederholbar: gibt es das Protokoll schon, passiert nichts.
* So darf der Auftrag zweimal laufen, ohne dass zwei PDFs entstehen.
*/
/** Mehr Fotos passen nicht sinnvoll in ein Protokoll — und nicht in den Speicher. */
const MAX_FOTOS = 24;
/** Ein einzelnes Bild größer als das wird nicht eingebettet. */
const MAX_BILD_BYTES = 8 * 1024 * 1024;
const ROLLE_TEXT: Record<string, string> = {
PRUEFER: "Prüfer",
GERAETEWART: "Gerätewart",
FREMDFIRMA: "Fachfirma",
};
const PRIORITAET_TEXT: Record<string, string> = {
NIEDRIG: "niedrig",
MITTEL: "mittel",
HOCH: "hoch",
KRITISCH: "kritisch",
};
/** Antwort in Klartext für die Protokollspalte "Ist". */
function istwert(
typ: PunktTyp,
ergebnis: {
jaNein: boolean | null;
zahlwert: { toNumber(): number } | null;
text: string | null;
fotoDateiId: string | null;
} | null,
einheit?: string | null,
): string {
if (!ergebnis) return "nicht geprüft";
switch (typ) {
case "JA_NEIN":
return ergebnis.jaNein === true ? "ja" : ergebnis.jaNein === false ? "nein" : "—";
case "MESSWERT": {
if (!ergebnis.zahlwert) return "—";
const zahl = ergebnis.zahlwert.toNumber();
return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: 3 })}${
einheit ? ` ${einheit}` : ""
}`;
}
case "TEXT":
return ergebnis.text ?? "—";
case "FOTO":
return ergebnis.fotoDateiId ? "Foto liegt bei" : "—";
}
}
/**
* Erzeugt das Protokoll-PDF und legt es in MinIO ab.
*
* @returns die Kennung der Datei, oder null wenn es zu dieser Prüfung kein
* Protokoll geben kann (unbekannt oder noch Entwurf)
*/
export async function protokollErzeugen(
organisationId: string,
pruefungId: string,
): Promise<{ dateiId: string; neu: boolean } | null> {
// ---------------------------------------------------------------- lesen
const geladen = await fuerOrganisation(organisationId, async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: pruefungId },
include: {
organisation: { select: { name: true, slug: true, strasse: true, plz: true, ort: true } },
geraet: {
include: {
geraetetyp: { select: { name: true } },
fahrzeug: { select: { funkrufname: true } },
standort: { select: { name: true } },
geraeteraum: { select: { bezeichnung: true } },
},
},
vorlage: {
select: {
name: true,
pruefvorschrift: true,
intervallMonate: true,
punkte: { orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }] },
},
},
pruefer: { select: { mitgliedsnummer: true } },
ergebnisse: true,
signaturen: { orderBy: { unterzeichnetAm: "asc" } },
maengel: { where: { geloeschtAm: null } },
ersetzt: { select: { protokollNummer: true } },
},
});
if (!pruefung) return null;
if (pruefung.status === "ENTWURF") return null;
const bildIds = [
...pruefung.ergebnisse.map((ergebnis) => ergebnis.fotoDateiId),
...pruefung.signaturen.map((signatur) => signatur.bildDateiId),
].filter((id): id is string => Boolean(id));
const dateien = bildIds.length
? await db.datei.findMany({
where: { id: { in: bildIds } },
select: { id: true, objektSchluessel: true, mimeTyp: true, groesseBytes: true },
})
: [];
return { pruefung, dateien };
});
if (!geladen) return null;
const { pruefung, dateien } = geladen;
if (pruefung.protokollDateiId) {
return { dateiId: pruefung.protokollDateiId, neu: false };
}
// --------------------------------------------------------------- rendern
const bilder = new Map<string, string>();
let eingebettet = 0;
for (const datei of dateien) {
if (eingebettet >= MAX_FOTOS) break;
if (Number(datei.groesseBytes) > MAX_BILD_BYTES) continue;
try {
const inhalt = await herunterladen(datei.objektSchluessel);
bilder.set(datei.id, alsDataUri(inhalt, datei.mimeTyp));
eingebettet += 1;
} catch (fehler) {
// Ein fehlendes Bild darf das Protokoll nicht verhindern — der Nachweis
// steckt in den Werten, nicht im Foto.
console.error(`[protokoll] Bild ${datei.id} nicht ladbar:`, fehler);
}
}
const ergebnisNachPunkt = new Map(
pruefung.ergebnisse.map((ergebnis) => [ergebnis.punktId, ergebnis]),
);
const punkte: ProtokollPunkt[] = (pruefung.vorlage?.punkte ?? []).map((punkt) => {
const ergebnis = ergebnisNachPunkt.get(punkt.id) ?? null;
return {
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht,
sollwert: punkt.sollwert,
einheit: punkt.einheit,
istwert: istwert(punkt.typ, ergebnis, punkt.einheit),
inOrdnung: ergebnis?.inOrdnung ?? false,
bemerkung: ergebnis?.bemerkung ?? null,
bild: ergebnis?.fotoDateiId ? (bilder.get(ergebnis.fotoDateiId) ?? null) : null,
};
});
const lagerung = pruefung.geraet.fahrzeug
? [pruefung.geraet.fahrzeug.funkrufname, pruefung.geraet.geraeteraum?.bezeichnung]
.filter(Boolean)
.join(" · ")
: (pruefung.geraet.standort?.name ?? null);
const daten: ProtokollDaten = {
organisation: {
name: pruefung.organisation.name,
strasse: pruefung.organisation.strasse,
plz: pruefung.organisation.plz,
ort: pruefung.organisation.ort,
},
geraet: {
inventarnummer: pruefung.geraet.inventarnummer,
bezeichnung: pruefung.geraet.bezeichnung,
hersteller: pruefung.geraet.hersteller,
modell: pruefung.geraet.modell,
seriennummer: pruefung.geraet.seriennummer,
typName: pruefung.geraet.geraetetyp?.name ?? null,
lagerung,
baujahr: pruefung.geraet.herstellungsdatum,
indienststellung: pruefung.geraet.indienststellung,
},
pruefung: {
protokollNummer: pruefung.protokollNummer,
durchgefuehrtAm: pruefung.durchgefuehrtAm,
art: pruefung.art,
ergebnis: (pruefung.ergebnis ?? "BESTANDEN") as Ergebnis,
dauerMinuten: pruefung.dauerMinuten,
prueferName: pruefung.prueferName,
prueferKennung: pruefung.pruefer?.mitgliedsnummer ?? null,
externeFirma: pruefung.externeFirma,
kosten: pruefung.kosten ? pruefung.kosten.toNumber() : null,
naechstePruefungAm: pruefung.naechstePruefungAm,
bemerkung: pruefung.bemerkung,
ersetztProtokollNummer: pruefung.ersetzt?.protokollNummer ?? null,
korrekturGrund: pruefung.korrekturGrund,
},
vorlage: pruefung.vorlage
? {
name: pruefung.vorlage.name,
pruefvorschrift: pruefung.vorlage.pruefvorschrift,
intervallMonate: pruefung.vorlage.intervallMonate,
}
: null,
punkte,
maengel: pruefung.maengel.map((mangel) => ({
beschreibung: mangel.beschreibung,
prioritaet: PRIORITAET_TEXT[mangel.prioritaet] ?? mangel.prioritaet,
})),
signaturen: pruefung.signaturen.map((signatur) => ({
name: signatur.name,
rolle: ROLLE_TEXT[signatur.rolle] ?? signatur.rolle,
unterzeichnetAm: signatur.unterzeichnetAm,
bild: signatur.bildDateiId ? (bilder.get(signatur.bildDateiId) ?? null) : null,
})),
erzeugtAm: new Date(),
};
const pdf = await protokollPdf(daten);
const dateiname = `Pruefprotokoll-${pruefung.protokollNummer ?? pruefung.id}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: pruefung.organisation.slug,
entitaet: "Pruefung",
entitaetId: pruefung.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
pruefung: pruefung.id,
protokoll: pruefung.protokollNummer ?? "",
});
// -------------------------------------------------------------- schreiben
return fuerOrganisation(organisationId, async (db) => {
// Zwischenzeitlich könnte ein zweiter Lauf schneller gewesen sein.
const stand = await db.pruefung.findUnique({
where: { id: pruefung.id },
select: { protokollDateiId: true },
});
if (stand?.protokollDateiId) {
return { dateiId: stand.protokollDateiId, neu: false };
}
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: abgelegt.objektSchluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: BigInt(abgelegt.groesseBytes),
pruefsumme: abgelegt.pruefsumme,
entitaet: "Pruefung",
entitaetId: pruefung.id,
kategorie: "pruefprotokoll",
hochgeladenVonId: pruefung.prueferId,
},
});
// Erlaubt trotz Änderungssperre: das Protokollfeld war leer (siehe Trigger
// pruefung_unveraenderlich in Migration 20260903000300).
await db.pruefung.update({
where: { id: pruefung.id },
data: { protokollDateiId: datei.id },
});
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfprotokoll ${pruefung.protokollNummer ?? ""} erzeugt (${Math.round(
abgelegt.groesseBytes / 1024,
)} kB)`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
});
return { dateiId: datei.id, neu: true };
});
}
+479
View File
@@ -0,0 +1,479 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
import { ERGEBNIS_BEZEICHNUNG, type Ergebnis, type PunktTyp } from "@/lib/wartung/bewertung";
/**
* Das Prüfprotokoll als PDF.
*
* Es ist der Nachweis, den die Wehr bei einer Prüfung durch die Unfallkasse
* vorlegt. Danach richtet sich der Aufbau:
*
* Kopf wer geprüft hat, welche Wehr, welches Gerät, welche Vorschrift
* Mitte jeder Checklistenpunkt mit Soll, Ist und Befund
* Fotos eingebettet, nicht verlinkt — ein Link ins Nichts ist kein Beleg
* Mängel was aufgefallen ist
* Fuß Unterschriften, Prüfvorschrift, Prüfdatum, Seitenzahl
*
* Alles ist eingebettet: keine externen Schriften, keine nachgeladenen Bilder.
* Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute, auch wenn
* es dann keinen Server mehr gibt, der irgendetwas nachliefert.
*/
/** Bild als data-URI, damit es fest im PDF steckt. */
export function alsDataUri(inhalt: Buffer, mimeTyp: string): string {
return `data:${mimeTyp};base64,${inhalt.toString("base64")}`;
}
export interface ProtokollPunkt {
text: string;
typ: PunktTyp;
pflicht: boolean;
sollwert?: string | null;
einheit?: string | null;
/** Die Antwort, schon für die Anzeige aufbereitet. */
istwert: string;
inOrdnung: boolean;
bemerkung?: string | null;
/** Foto zum Punkt, bereits als data-URI. */
bild?: string | null;
}
export interface ProtokollSignatur {
name: string;
rolle: string;
unterzeichnetAm: Date;
/** PNG des Zeichenfelds als data-URI. */
bild?: string | null;
}
export interface ProtokollDaten {
organisation: {
name: string;
strasse?: string | null;
plz?: string | null;
ort?: string | null;
};
geraet: {
inventarnummer: string;
bezeichnung: string;
hersteller?: string | null;
modell?: string | null;
seriennummer?: string | null;
typName?: string | null;
lagerung?: string | null;
baujahr?: Date | null;
indienststellung?: Date | null;
};
pruefung: {
protokollNummer?: string | null;
durchgefuehrtAm: Date;
art: "INTERN" | "EXTERN";
ergebnis: Ergebnis;
dauerMinuten?: number | null;
prueferName?: string | null;
/** Mitgliedsnummer oder Kennung des Prüfers — die "Prüfer-ID" im Kopf. */
prueferKennung?: string | null;
externeFirma?: string | null;
kosten?: number | null;
naechstePruefungAm?: Date | null;
bemerkung?: string | null;
/** Bei einer Korrektur: Nummer des ersetzten Protokolls. */
ersetztProtokollNummer?: string | null;
korrekturGrund?: string | null;
};
vorlage?: {
name: string;
pruefvorschrift?: string | null;
intervallMonate: number;
} | null;
punkte: ProtokollPunkt[];
maengel: Array<{ beschreibung: string; prioritaet: string }>;
signaturen: ProtokollSignatur[];
erzeugtAm: Date;
}
const ERGEBNIS_FARBE: Record<Ergebnis, string> = {
BESTANDEN: "#15803d",
BESTANDEN_MIT_MAENGELN: "#b45309",
NICHT_BESTANDEN: "#b91c1c",
};
function zeile(bezeichnung: string, wert: string | null | undefined): string {
return `
<div class="feld">
<div class="beschriftung">${html(bezeichnung)}</div>
<div class="wert">${html(wert && wert !== "" ? wert : "—")}</div>
</div>`;
}
function punktZeile(punkt: ProtokollPunkt, nummer: number): string {
const soll = [punkt.sollwert, punkt.einheit].filter(Boolean).join(" ");
return `
<tr class="${punkt.inOrdnung ? "" : "beanstandet"}">
<td class="nummer">${nummer}</td>
<td>
${html(punkt.text)}
${punkt.pflicht ? '<span class="pflicht" title="Pflichtpunkt">*</span>' : ""}
${punkt.bemerkung ? `<div class="bemerkung">${html(punkt.bemerkung)}</div>` : ""}
</td>
<td class="soll">${html(soll || "—")}</td>
<td class="ist">${html(punkt.istwert || "—")}</td>
<td class="befund">${punkt.inOrdnung ? "i. O." : "nicht i. O."}</td>
</tr>`;
}
function fotoBlock(punkte: ProtokollPunkt[]): string {
const mitBild = punkte.filter((punkt) => punkt.bild);
if (mitBild.length === 0) return "";
return `
<section>
<h2>Fotos</h2>
<div class="fotos">
${mitBild
.map(
(punkt) => `
<figure>
<img src="${punkt.bild}" alt="">
<figcaption>${html(punkt.text)}</figcaption>
</figure>`,
)
.join("")}
</div>
</section>`;
}
/** Baut das eigenständige HTML des Protokolls. */
export function protokollHtml(daten: ProtokollDaten): string {
const { organisation, geraet, pruefung, vorlage, punkte, maengel, signaturen } = daten;
const anschrift = [
organisation.strasse,
[organisation.plz, organisation.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(", ");
const geraetetext = [geraet.hersteller, geraet.modell].filter(Boolean).join(" ");
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Prüfprotokoll ${html(pruefung.protokollNummer ?? geraet.inventarnummer)}</title>
<style>
/* Ränder kommen von Gotenberg (siehe protokollPdf) — hier keine setzen,
sonst bleibt kein Platz für Kopf- und Fußzeile. */
@page { size: A4; }
* { box-sizing: border-box; }
html, body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.5pt;
color: #111;
-webkit-print-color-adjust: exact;
}
h1 { font-size: 15pt; margin: 0 0 1mm; }
h2 {
font-size: 10.5pt;
margin: 6mm 0 2mm;
padding-bottom: 1mm;
border-bottom: 0.4mm solid #111;
}
.kopf {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8mm;
padding-bottom: 3mm;
border-bottom: 0.6mm solid #111;
}
.kopf .wehr { font-size: 8.5pt; color: #333; }
.ergebnis {
text-align: right;
white-space: nowrap;
}
.ergebnis .marke {
display: inline-block;
padding: 1.5mm 3mm;
border: 0.5mm solid currentColor;
border-radius: 1.5mm;
font-weight: 700;
font-size: 11pt;
}
.ergebnis .nummer { font-size: 8.5pt; color: #333; margin-top: 1.5mm; }
.felder {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2mm 6mm;
margin-top: 3mm;
}
.feld .beschriftung {
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
}
.feld .wert { font-size: 9.5pt; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
border-bottom: 0.3mm solid #999;
padding: 1.5mm 2mm;
}
td {
padding: 1.8mm 2mm;
border-bottom: 0.2mm solid #ddd;
vertical-align: top;
}
tr { break-inside: avoid; }
td.nummer { width: 8mm; color: #666; }
td.soll, td.ist { width: 28mm; }
td.befund { width: 24mm; font-weight: 600; }
tr.beanstandet td.befund { color: #b91c1c; }
tr.beanstandet td { background: #fdf2f2; }
.pflicht { color: #b91c1c; font-weight: 700; }
.bemerkung { font-size: 8pt; color: #444; margin-top: 0.8mm; }
.fotos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}
.fotos figure { margin: 0; break-inside: avoid; }
.fotos img {
width: 100%;
height: 45mm;
object-fit: cover;
border: 0.2mm solid #bbb;
}
.fotos figcaption { font-size: 7.5pt; color: #444; margin-top: 1mm; }
.maengel li { margin-bottom: 1.5mm; }
.unterschriften {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8mm;
margin-top: 4mm;
}
.unterschrift { break-inside: avoid; }
.unterschrift .bild {
height: 22mm;
border-bottom: 0.3mm solid #111;
display: flex;
align-items: flex-end;
}
.unterschrift .bild img { max-height: 21mm; max-width: 100%; }
.unterschrift .name { font-size: 8.5pt; margin-top: 1.5mm; }
.unterschrift .rolle { font-size: 7.5pt; color: #555; }
.hinweis {
margin-top: 6mm;
padding: 2.5mm 3mm;
background: #f4f4f5;
border-left: 1mm solid #999;
font-size: 8pt;
color: #333;
}
.korrektur {
margin-top: 3mm;
padding: 2.5mm 3mm;
border: 0.4mm solid #b45309;
color: #7c2d12;
font-size: 8.5pt;
}
</style>
</head>
<body>
<div class="kopf">
<div>
<h1>Prüfprotokoll</h1>
<div class="wehr">
${html(organisation.name)}${anschrift ? ` · ${html(anschrift)}` : ""}
</div>
</div>
<div class="ergebnis" style="color: ${ERGEBNIS_FARBE[pruefung.ergebnis]}">
<div class="marke">${html(ERGEBNIS_BEZEICHNUNG[pruefung.ergebnis])}</div>
<div class="nummer">
${pruefung.protokollNummer ? `Protokoll ${html(pruefung.protokollNummer)}` : "ohne Nummer"}
</div>
</div>
</div>
${
pruefung.ersetztProtokollNummer
? `<div class="korrektur">
<strong>Korrekturprotokoll.</strong> Dieses Protokoll ersetzt
${html(pruefung.ersetztProtokollNummer)}.
${pruefung.korrekturGrund ? ` Grund: ${html(pruefung.korrekturGrund)}` : ""}
</div>`
: ""
}
<section>
<h2>Gerät</h2>
<div class="felder">
${zeile("Inventarnummer", geraet.inventarnummer)}
${zeile("Bezeichnung", geraet.bezeichnung)}
${zeile("Gerätetyp", geraet.typName)}
${zeile("Hersteller / Modell", geraetetext)}
${zeile("Seriennummer", geraet.seriennummer)}
${zeile("Lagerung", geraet.lagerung)}
${zeile("Baujahr", geraet.baujahr ? alsDatum(geraet.baujahr) : null)}
${zeile("Indienststellung", geraet.indienststellung ? alsDatum(geraet.indienststellung) : null)}
</div>
</section>
<section>
<h2>Prüfung</h2>
<div class="felder">
${zeile("Prüfdatum", alsDatum(pruefung.durchgefuehrtAm))}
${zeile("Prüfer", pruefung.prueferName)}
${zeile("Prüfer-Kennung", pruefung.prueferKennung)}
${zeile("Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern")}
${zeile("Fachfirma", pruefung.externeFirma)}
${zeile("Kosten", pruefung.kosten !== null && pruefung.kosten !== undefined ? alsEuro(pruefung.kosten) : null)}
${zeile("Vorlage", vorlage?.name)}
${zeile("Prüfvorschrift", vorlage?.pruefvorschrift)}
${zeile("Intervall", vorlage ? `${vorlage.intervallMonate} Monate` : null)}
${zeile("Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : null)}
${zeile("Nächste Prüfung", pruefung.naechstePruefungAm ? alsDatum(pruefung.naechstePruefungAm) : null)}
</div>
${pruefung.bemerkung ? `<p style="margin-top:3mm">${html(pruefung.bemerkung)}</p>` : ""}
</section>
<section>
<h2>Checkliste</h2>
<table>
<thead>
<tr>
<th>Nr.</th>
<th>Prüfpunkt</th>
<th>Soll</th>
<th>Ist</th>
<th>Befund</th>
</tr>
</thead>
<tbody>
${punkte.map((punkt, index) => punktZeile(punkt, index + 1)).join("")}
</tbody>
</table>
<p style="font-size:7.5pt;color:#555;margin-top:2mm">
<span class="pflicht">*</span> Pflichtpunkt — davon hängt die
Einsatzbereitschaft ab.
</p>
</section>
${fotoBlock(punkte)}
${
maengel.length > 0
? `<section>
<h2>Festgestellte Mängel</h2>
<ul class="maengel">
${maengel
.map(
(mangel) =>
`<li><strong>${html(mangel.prioritaet)}:</strong> ${html(mangel.beschreibung)}</li>`,
)
.join("")}
</ul>
</section>`
: ""
}
<section>
<h2>Unterschriften</h2>
<div class="unterschriften">
${signaturen
.map(
(signatur) => `
<div class="unterschrift">
<div class="bild">${signatur.bild ? `<img src="${signatur.bild}" alt="">` : ""}</div>
<div class="name">${html(signatur.name)}</div>
<div class="rolle">${html(signatur.rolle)} · ${html(alsDatumZeit(signatur.unterzeichnetAm))}</div>
</div>`,
)
.join("")}
</div>
</section>
<div class="hinweis">
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Dieses
Protokoll ist nach dem Abschluss unveränderbar gespeichert. Korrekturen
erscheinen als eigenes Protokoll mit Verweis auf dieses.
</div>
</body>
</html>`;
}
/** Kopfzeile des PDF — sie wiederholt sich auf jeder Seite. */
export function kopfzeileHtml(daten: ProtokollDaten): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>${html(daten.organisation.name)}</span>
<span>Prüfprotokoll ${html(daten.pruefung.protokollNummer ?? "")} · ${html(daten.geraet.inventarnummer)}</span>
</div>
</body></html>`;
}
/** Fußzeile mit Prüfvorschrift, Prüfdatum und Seitenzahl. */
export function fusszeileHtml(daten: ProtokollDaten): string {
const vorschrift = daten.vorlage?.pruefvorschrift ?? "ohne Angabe der Vorschrift";
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>${html(vorschrift)} · geprüft am ${html(alsDatum(daten.pruefung.durchgefuehrtAm))}</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>
</body></html>`;
}
/** Erzeugt das Protokoll als PDF über Gotenberg. */
export async function protokollPdf(daten: ProtokollDaten): Promise<Buffer> {
return htmlZuPdf(protokollHtml(daten), {
randOben: 0.55,
randUnten: 0.55,
randLinks: 0.5,
randRechts: 0.5,
hintergrundDrucken: true,
kopfzeileHtml: kopfzeileHtml(daten),
fusszeileHtml: fusszeileHtml(daten),
});
}
+81
View File
@@ -0,0 +1,81 @@
import type {
BetroffenheitStatus,
ChecklistenpunktTyp,
MangelPrioritaet,
MangelStatus,
PruefErgebnis,
PruefungStatus,
RueckrufStatus,
WartungsterminStatus,
} from "@prisma/client";
import type { Fristfenster } from "@/lib/wartung/fristen";
/**
* Klartext zu den Aufzählungen der Wartung.
*
* Steht bewusst in einer Datei ohne "use client": Server-Komponenten und
* Client-Komponenten brauchen dieselben Beschriftungen. Läge das in einem
* Client-Modul, bekäme der Server statt der Texte nur Platzhalter — Next.js
* ersetzt alle Exporte eines "use client"-Moduls durch Verweise.
*/
export const ERGEBNIS_TEXT: Record<PruefErgebnis, string> = {
BESTANDEN: "bestanden",
BESTANDEN_MIT_MAENGELN: "mit Mängeln bestanden",
NICHT_BESTANDEN: "nicht bestanden",
};
export const PRUEFUNG_STATUS_TEXT: Record<PruefungStatus, string> = {
ENTWURF: "Entwurf",
ABGESCHLOSSEN: "abgeschlossen",
ERSETZT: "ersetzt",
};
export const TERMIN_STATUS_TEXT: Record<WartungsterminStatus, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
ENTFALLEN: "entfallen",
};
export const MANGEL_STATUS_TEXT: Record<MangelStatus, string> = {
GEMELDET: "gemeldet",
ZUGEWIESEN: "zugewiesen",
IN_ARBEIT: "in Arbeit",
BEHOBEN: "behoben",
VERWORFEN: "verworfen",
};
export const PRIORITAET_TEXT: Record<MangelPrioritaet, string> = {
NIEDRIG: "niedrig",
MITTEL: "mittel",
HOCH: "hoch",
KRITISCH: "kritisch",
};
export const RUECKRUF_STATUS_TEXT: Record<RueckrufStatus, string> = {
OFFEN: "offen",
IN_BEARBEITUNG: "in Bearbeitung",
ERLEDIGT: "erledigt",
};
export const BETROFFENHEIT_TEXT: Record<BetroffenheitStatus, string> = {
OFFEN: "offen",
NICHT_BETROFFEN: "nicht betroffen",
MASSNAHME_LAEUFT: "Maßnahme läuft",
ERLEDIGT: "erledigt",
};
export const PUNKTTYP_TEXT: Record<ChecklistenpunktTyp, string> = {
JA_NEIN: "Ja / Nein",
MESSWERT: "Messwert",
TEXT: "Text",
FOTO: "Foto",
};
export const FENSTER_TEXT: Record<Fristfenster, string> = {
ueberfaellig: "überfällig",
diese_woche: "diese Woche",
dieser_monat: "diesen Monat",
naechste_90_tage: "nächste 90 Tage",
spaeter: "später",
};