Files
FireAdministration/src/lib/wartung/bewertung.ts
T
Florian WildeandClaude Opus 5 e503aafc18 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>
2026-09-03 12:07:11 +02:00

163 lines
5.8 KiB
TypeScript

/**
* 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;
}