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