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
+107
-18
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user