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,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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user