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:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user