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
+427 -1
View File
@@ -5,7 +5,9 @@
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
* Mannschaft.
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
* Beispielrückruf.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -19,15 +21,22 @@
import "dotenv/config";
import {
AusweisTokenTyp,
ChecklistenpunktTyp,
Dienstgrad,
FahrzeugArt,
GeraeteKategorie,
GeraetStatus,
LebensdauerBezug,
LebensdauerKategorie,
MangelPrioritaet,
MangelStatus,
PrismaClient,
PruefErgebnis,
PruefungArt,
PruefungStatus,
QualifikationsArt,
RolleSchluessel,
SignaturRolle,
StandortArt,
} from "@prisma/client";
import bcrypt from "bcryptjs";
@@ -1092,6 +1101,417 @@ async function qualifikationenAnlegen(organisationId: string, typen: Map<string,
log(`Qualifikationen: ${angelegt}`);
}
// -----------------------------------------------------------------------------
// Phase 2 — Wartungsvorlagen, Termine, ein Beispielprotokoll
//
// Die Vorlagen sind bewusst echt: Intervalle und Vorschriften stammen aus den
// gängigen Regelwerken. Eine Beispielwehr, in der jede Prüfung "Prüfung 1"
// heißt, zeigt nicht, worum es geht.
// -----------------------------------------------------------------------------
interface PunktVorlage {
text: string;
typ: ChecklistenpunktTyp;
pflicht?: boolean;
sollwert?: string;
einheit?: string;
minWert?: number;
maxWert?: number;
hinweis?: string;
}
interface WartungsVorlage {
name: string;
geraetetyp: string;
intervallMonate: number;
vorwarnzeitTage?: number;
pruefvorschrift?: string;
standardDauerMinuten?: number;
punkte: PunktVorlage[];
}
const WARTUNGSVORLAGEN: WartungsVorlage[] = [
{
name: "Pressluftatmer — Jahresprüfung",
geraetetyp: "Pressluftatmer PA 94",
intervallMonate: 12,
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
standardDauerMinuten: 25,
punkte: [
{ text: "Trageeinrichtung auf Beschädigung und Vollständigkeit prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Bänderung auf Risse, Schnitte und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Hochdruckdichtheit: Druckabfall in einer Minute",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 10 bar in einer Minute",
einheit: "bar",
minWert: 0,
maxWert: 10,
},
{
text: "Ansprechdruck der Warneinrichtung",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "50 bis 60 bar",
einheit: "bar",
minWert: 50,
maxWert: 60,
},
{
text: "Lungenautomat: Öffnungsdruck",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "bis 3,5 mbar",
einheit: "mbar",
maxWert: 3.5,
},
{ text: "Auffälligkeiten", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
],
},
{
name: "Atemanschluss — Halbjahresprüfung",
geraetetyp: "Atemschutzmaske FPS 7000",
intervallMonate: 6,
pruefvorschrift: "DGUV Regel 112-190",
standardDauerMinuten: 15,
punkte: [
{ text: "Maskenkörper auf Risse, Verformung und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Sichtscheibe frei von Kratzern im Sichtfeld", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Ausatemventil auf Dichtheit und Sitz prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Dichtprüfung: Druckabfall in einer Minute",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 0,5 mbar",
einheit: "mbar",
minWert: 0,
maxWert: 0.5,
},
{ text: "Foto der Maske nach der Reinigung", typ: ChecklistenpunktTyp.FOTO, pflicht: false },
],
},
{
name: "Feuerwehrleine — Jahresprüfung",
geraetetyp: "Feuerwehrleine 30 m",
intervallMonate: 12,
pruefvorschrift: "DGUV Information 205-014, DIN 14920",
standardDauerMinuten: 10,
punkte: [
{ text: "Leine über die gesamte Länge durch die Hand ziehen, Beschädigungen prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Kennzeichnung und Herstellungsjahr lesbar", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Leinenbeutel unbeschädigt und trocken", typ: ChecklistenpunktTyp.JA_NEIN, pflicht: false },
{ text: "Befund", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
],
},
{
name: "Druckschlauch — Druckprüfung",
geraetetyp: "B-Druckschlauch 20 m",
intervallMonate: 12,
pruefvorschrift: "DIN 14811",
standardDauerMinuten: 8,
punkte: [
{
text: "Prüfdruck halten",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "mindestens 8 bar über 5 Minuten",
einheit: "bar",
minWert: 8,
},
{ text: "Kupplungen fest, Dichtringe vorhanden", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Schlauchgewebe ohne Scheuerstellen und Löcher", typ: ChecklistenpunktTyp.JA_NEIN },
],
},
{
name: "Elektrische Betriebsmittel — DGUV-V3-Prüfung",
geraetetyp: "Stromerzeuger 8 kVA",
intervallMonate: 12,
pruefvorschrift: "DGUV Vorschrift 3, DIN VDE 0701-0702",
standardDauerMinuten: 30,
punkte: [
{ text: "Sichtprüfung Gehäuse, Leitungen und Steckvorrichtungen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Schutzleiterwiderstand",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 0,3 Ohm",
einheit: "Ohm",
minWert: 0,
maxWert: 0.3,
},
{
text: "Isolationswiderstand",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "mindestens 1 Megaohm",
einheit: "MOhm",
minWert: 1,
},
{ text: "Funktionsprüfung unter Last", typ: ChecklistenpunktTyp.JA_NEIN },
],
},
];
async function wartungsvorlagenAnlegen(organisationId: string, typen: Map<string, string>) {
const karte = new Map<string, { id: string; intervallMonate: number; punkte: string[] }>();
for (const vorlage of WARTUNGSVORLAGEN) {
const geraetetypId = typen.get(vorlage.geraetetyp) ?? null;
const angelegt = await prisma.wartungsvorlage.upsert({
where: { organisationId_name: { organisationId, name: vorlage.name } },
update: {},
create: {
organisationId,
name: vorlage.name,
geraetetypId,
intervallMonate: vorlage.intervallMonate,
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 30,
pruefvorschrift: vorlage.pruefvorschrift ?? null,
standardDauerMinuten: vorlage.standardDauerMinuten ?? null,
},
});
const punktIds: string[] = [];
const vorhandene = await prisma.checklistenpunkt.count({ where: { vorlageId: angelegt.id } });
if (vorhandene === 0) {
for (const [index, punkt] of vorlage.punkte.entries()) {
const erzeugt = await prisma.checklistenpunkt.create({
data: {
organisationId,
vorlageId: angelegt.id,
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht ?? true,
sollwert: punkt.sollwert ?? null,
einheit: punkt.einheit ?? null,
minWert: punkt.minWert ?? null,
maxWert: punkt.maxWert ?? null,
hinweis: punkt.hinweis ?? null,
sortierung: (index + 1) * 10,
},
});
punktIds.push(erzeugt.id);
}
} else {
const bestand = await prisma.checklistenpunkt.findMany({
where: { vorlageId: angelegt.id },
orderBy: { sortierung: "asc" },
select: { id: true },
});
punktIds.push(...bestand.map((punkt) => punkt.id));
}
karte.set(vorlage.name, {
id: angelegt.id,
intervallMonate: vorlage.intervallMonate,
punkte: punktIds,
});
}
log(`Wartungsvorlagen: ${karte.size}`);
return karte;
}
/**
* Termine für alle Geräte der jeweiligen Bauart.
*
* Das ist dieselbe Rechnung, die der nächtliche Lauf anstellt — hier nur ohne
* Worker, damit eine frisch aufgesetzte Wehr sofort ein gefülltes
* Fristen-Dashboard hat.
*/
async function wartungstermineAnlegen(
organisationId: string,
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
) {
let angelegt = 0;
for (const vorlage of WARTUNGSVORLAGEN) {
const eintrag = vorlagen.get(vorlage.name);
if (!eintrag) continue;
const geraete = await prisma.geraet.findMany({
where: {
organisationId,
geloeschtAm: null,
geraetetyp: { name: vorlage.geraetetyp },
},
select: { id: true, letztePruefung: true, indienststellung: true },
});
for (const geraet of geraete) {
const basis = geraet.letztePruefung ?? geraet.indienststellung;
if (!basis) continue;
const faelligAm = plusMonate(basis, vorlage.intervallMonate);
const ergebnis = await prisma.wartungstermin.createMany({
data: [
{
organisationId,
geraetId: geraet.id,
vorlageId: eintrag.id,
faelligAm,
automatischErzeugt: true,
},
],
skipDuplicates: true,
});
angelegt += ergebnis.count;
}
}
log(`Wartungstermine: ${angelegt}`);
}
/**
* Ein abgeschlossenes Protokoll samt Mangel.
*
* Ohne ein Beispiel bleibt die Protokollansicht leer, und niemand sieht, wie
* ein fertiger Nachweis aussieht. Das PDF fehlt bewusst: es entsteht im
* Worker, sobald einer läuft.
*/
async function beispielPruefungAnlegen(
organisationId: string,
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
) {
const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung");
if (!vorlage) return;
const vorhanden = await prisma.pruefung.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Beispielprüfung: bereits vorhanden");
return;
}
const geraet = await prisma.geraet.findFirst({
where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null },
select: { id: true, inventarnummer: true },
});
if (!geraet) return;
const pruefer = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } },
},
select: { id: true, vorname: true, nachname: true },
});
const geprueftAm = plusMonate(new Date(), -2);
const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart";
const punkte = await prisma.checklistenpunkt.findMany({
where: { vorlageId: vorlage.id },
orderBy: { sortierung: "asc" },
});
// Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der
// Trigger aus Migration 20260903000300 lässt an einer bereits
// abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und
// das ist auch für den Seed richtig so.
const pruefung = await prisma.pruefung.create({
data: {
organisationId,
geraetId: geraet.id,
vorlageId: vorlage.id,
art: PruefungArt.INTERN,
status: PruefungStatus.ENTWURF,
ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN,
prueferId: pruefer?.id ?? null,
prueferName,
durchgefuehrtAm: geprueftAm,
begonnenUm: geprueftAm,
dauerMinuten: 12,
naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate),
protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`,
abgeschlossenAm: geprueftAm,
bemerkung:
"Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.",
ergebnisse: {
create: punkte.map((punkt, index) => ({
organisationId,
punktId: punkt.id,
jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null,
text:
punkt.typ === ChecklistenpunktTyp.TEXT
? "Leine selbst ohne Befund, Beutel beschädigt."
: null,
inOrdnung: index !== 2,
bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null,
})),
},
signaturen: {
create: [
{
organisationId,
rolle: SignaturRolle.PRUEFER,
personId: pruefer?.id ?? null,
name: prueferName,
unterzeichnetAm: geprueftAm,
},
],
},
},
});
await prisma.pruefung.update({
where: { id: pruefung.id },
data: { status: PruefungStatus.ABGESCHLOSSEN },
});
await prisma.mangel.create({
data: {
organisationId,
geraetId: geraet.id,
pruefungId: pruefung.id,
melderId: pruefer?.id ?? null,
melderName: prueferName,
beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.",
prioritaet: MangelPrioritaet.NIEDRIG,
status: MangelStatus.ZUGEWIESEN,
zugewiesenAnId: pruefer?.id ?? null,
},
});
log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`);
}
/** Ein Rückruf, damit die Ansicht nicht leer ist. */
async function rueckrufAnlegen(organisationId: string) {
const vorhanden = await prisma.rueckruf.count({ where: { organisationId } });
if (vorhanden > 0) return;
const typ = await prisma.geraetetyp.findFirst({
where: { organisationId, name: "Atemschutzmaske FPS 7000" },
select: { id: true },
});
await prisma.rueckruf.create({
data: {
organisationId,
hersteller: "Dräger",
titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000",
referenz: "SI-2026-03",
beschreibung:
"Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. "
+ "Betroffen sind Masken mit Herstellung vor 2020.",
massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.",
veroeffentlichtAm: plusMonate(new Date(), -1),
sperrtGeraete: false,
typen: typ
? {
create: [
{
organisationId,
geraetetypId: typ.id,
herstellungBis: datum(2019, 12, 31),
},
],
}
: undefined,
},
});
log("Rückruf: 1 Beispiel");
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -1113,6 +1533,12 @@ async function main(): Promise<void> {
const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
await qualifikationenAnlegen(organisation.id, qualiTypen);
// Phase 2 — Wartung und Prüfung
const wartungsvorlagen = await wartungsvorlagenAnlegen(organisation.id, typen);
await wartungstermineAnlegen(organisation.id, wartungsvorlagen);
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
await rueckrufAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");