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