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>
120 lines
4.0 KiB
TypeScript
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,
|
|
);
|
|
}
|