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:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
+107 -18
View File
@@ -1,38 +1,84 @@
// Im Container liefert Compose die Umgebung, lokal die .env. dotenv
// überschreibt nichts, was schon gesetzt ist — im Betrieb ändert die Zeile
// also nichts, macht aber "npm run worker" auf dem Entwicklungsrechner möglich.
import "dotenv/config";
import { Worker, type Job } from "bullmq";
import { env } from "@/lib/env";
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange";
import { verbindungenTrennen } from "@/lib/db";
import {
AUFTRAG,
WARTESCHLANGE,
beauftrageWiederkehrend,
redis,
type AuftragsDaten,
} from "@/lib/warteschlange";
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
/**
* Hintergrundprozess.
*
* Läuft als eigener Container neben der Anwendung und arbeitet die
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung,
* Fehlerbehandlung und ein sauberes Herunterfahren.
* Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die
* Aufbewahrung folgt in Phase 8.
*
* Beim Start trägt sich der Prozess die wiederkehrenden Läufe je Wehr ein.
* Das ist bewusst hier und nicht in der Anwendung: der Worker ist der Einzige,
* der sie auch ausführt, und ein Zeitplan ohne Ausführenden ist nutzlos.
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[worker] ${nachricht}`, ...weiteres);
}
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> {
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen.
return { geprueft: 0 };
async function fristenPruefen(auftrag: Job<AuftragsDaten>) {
const { organisationId } = auftrag.data;
const ergebnis = await terminePlanen(organisationId);
protokoll(
`Fristen geprüft für ${organisationId}: ${ergebnis.angelegt} Termine geplant, ` +
`${ergebnis.statusGeaendert} Geräte im Status geändert`,
);
return ergebnis;
}
async function erinnerungSenden(auftrag: Job<AuftragsDaten>): Promise<{ versendet: number }> {
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 7: Mail und Telegram über lib/integrations.
return { versendet: 0 };
async function erinnerungSenden(auftrag: Job<AuftragsDaten>) {
const { organisationId, anlass, mangelId } = auftrag.data as AuftragsDaten & {
anlass?: string;
mangelId?: string;
};
// Mängel melden sich sofort, Termine im nächtlichen Sammellauf.
if (anlass?.startsWith("mangel") && mangelId) {
return mangelBenachrichtigung(organisationId, mangelId, anlass);
}
return erinnerungenSenden(organisationId);
}
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> {
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
return { erzeugt: false };
async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
const { organisationId, pruefungId } = auftrag.data as AuftragsDaten & { pruefungId?: string };
if (!pruefungId) {
throw new Error("PDF-Auftrag ohne Angabe, wozu — pruefungId fehlt.");
}
const ergebnis = await protokollErzeugen(organisationId, pruefungId);
if (!ergebnis) {
protokoll(`Kein Protokoll für Prüfung ${pruefungId} — unbekannt oder noch Entwurf.`);
return { erzeugt: false };
}
protokoll(
ergebnis.neu
? `Protokoll für Prüfung ${pruefungId} erzeugt.`
: `Protokoll für Prüfung ${pruefungId} lag bereits vor.`,
);
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
@@ -49,6 +95,36 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
};
/**
* Trägt die nächtlichen Läufe für jede aktive Wehr ein.
*
* BullMQ hält wiederkehrende Aufträge in Redis; ein zweiter Eintrag mit
* derselben Auftragskennung ersetzt den ersten. Der Aufruf ist deshalb bei
* jedem Start wiederholbar, und eine neu angelegte Wehr kommt beim nächsten
* Neustart des Workers dazu.
*/
async function zeitplaeneEinrichten(): Promise<number> {
const organisationen = await ohneMandantenfilter(
"Wartungsjobs für alle Wehren einplanen",
(db) => db.organisation.findMany({ where: { aktiv: true, geloeschtAm: null }, select: { id: true, kurzname: true } }),
);
for (const organisation of organisationen) {
await beauftrageWiederkehrend(
AUFTRAG.FRISTEN_PRUEFEN,
{ organisationId: organisation.id },
env.WARTUNG_NACHTLAUF,
);
await beauftrageWiederkehrend(
AUFTRAG.ERINNERUNG_SENDEN,
{ organisationId: organisation.id },
env.WARTUNG_ERINNERUNG_LAUF,
);
}
return organisationen.length;
}
const worker = new Worker<AuftragsDaten>(
WARTESCHLANGE,
async (auftrag) => {
@@ -78,6 +154,19 @@ worker.on("ready", () => {
protokoll(
`bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`,
);
void zeitplaeneEinrichten()
.then((anzahl) =>
protokoll(
`Zeitpläne eingerichtet für ${anzahl} Wehren ` +
`(Planung ${env.WARTUNG_NACHTLAUF}, Erinnerungen ${env.WARTUNG_ERINNERUNG_LAUF})`,
),
)
.catch((fehler) => {
// Ein fehlgeschlagener Zeitplan darf den Worker nicht aufhalten: die
// Aufträge aus der Anwendung sollen weiter abgearbeitet werden.
console.error("[worker] Zeitpläne ließen sich nicht einrichten:", fehler);
});
});
/** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */