Files
FireAdministration/src/lib/wartung/planung.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

120 lines
4.0 KiB
TypeScript

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,
);
}