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
+427
-1
@@ -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:");
|
||||
|
||||
Reference in New Issue
Block a user