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
+429
View File
@@ -0,0 +1,429 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { ChecklistenpunktTyp, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Wartungsvorlagen und ihre Checklisten.
*
* Die Vorlage ist die Regel — "Pressluftatmer, alle 12 Monate, DGUV 49" — und
* die Checklistenpunkte sind die Fragen, die dabei zu beantworten sind. Beides
* pflegt der Gerätewart, beides ist Voraussetzung für alles Weitere: ohne
* Vorlage gibt es keine Termine, ohne Punkte keine Prüfung.
*
* Gelöscht wird nur weich. Eine Vorlage hängt an abgeschlossenen Protokollen,
* und die müssen in zehn Jahren noch erklärbar sein.
*/
const SORTIERFELDER = ["name", "intervallMonate", "erstelltAm"] as const;
const SUCHFELDER = ["name", "pruefvorschrift", "beschreibung"];
const LISTEN_AUSWAHL = {
id: true,
name: true,
intervallMonate: true,
vorwarnzeitTage: true,
pruefvorschrift: true,
standardDauerMinuten: true,
standardExtern: true,
automatisch: true,
aktiv: true,
geloeschtAm: true,
geraetetyp: { select: { id: true, name: true, kategorie: true } },
_count: { select: { punkte: true, termine: true, pruefungen: true } },
} satisfies Prisma.WartungsvorlageSelect;
const vorlageFelder = z.object({
name: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(160),
geraetetypId: z.string().uuid().nullish(),
intervallMonate: z
.number()
.int()
.min(1, "Ein Intervall von null Monaten ergibt keinen Termin.")
.max(600),
vorwarnzeitTage: z.number().int().min(0).max(365).default(30),
pruefvorschrift: z.string().trim().max(200).nullish(),
standardDauerMinuten: z.number().int().min(1).max(10_000).nullish(),
standardExtern: z.boolean().default(false),
automatisch: z.boolean().default(true),
beschreibung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
const punktBasis = z.object({
text: z.string().trim().min(1, "Der Prüfpunkt braucht einen Text.").max(300),
typ: z.nativeEnum(ChecklistenpunktTyp).default(ChecklistenpunktTyp.JA_NEIN),
pflicht: z.boolean().default(true),
sollwert: z.string().trim().max(120).nullish(),
einheit: z.string().trim().max(20).nullish(),
minWert: z.number().nullish(),
maxWert: z.number().nullish(),
hinweis: z.string().trim().max(500).nullish(),
sortierung: z.number().int().min(0).max(9999).default(0),
});
/** Ein Bereich, dessen Untergrenze über der Obergrenze liegt, ist kein Bereich. */
function bereichSinnvoll(wert: { minWert?: number | null; maxWert?: number | null }): boolean {
if (wert.minWert === null || wert.minWert === undefined) return true;
if (wert.maxWert === null || wert.maxWert === undefined) return true;
return wert.minWert <= wert.maxWert;
}
const BEREICH_FEHLER = {
message: "Der kleinste zulässige Wert darf nicht über dem größten liegen.",
path: ["minWert"],
};
const punktFelder = punktBasis.refine(bereichSinnvoll, BEREICH_FEHLER);
const punktAenderung = punktBasis.partial().refine(bereichSinnvoll, BEREICH_FEHLER);
export const wartungsvorlageRouter = router({
liste: mitRecht("pruefung.lesen")
.input(
listenEingabe(SORTIERFELDER).extend({
geraetetypId: z.string().uuid().optional(),
nurAktive: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.WartungsvorlageWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.geraetetypId ? { geraetetypId: input.geraetetypId } : {}),
...(input.nurAktive ? { aktiv: true } : {}),
...(sucheUeber(SUCHFELDER, input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.wartungsvorlage.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.WartungsvorlageOrderByWithRelationInput,
...blaettern(input),
}),
db.wartungsvorlage.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
/** Eine Vorlage mit ihrer vollständigen Checkliste. */
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorlage = await db.wartungsvorlage.findUnique({
where: { id: input.id },
include: {
geraetetyp: { select: { id: true, name: true, kategorie: true } },
punkte: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
},
_count: { select: { termine: true, pruefungen: true } },
},
});
if (!vorlage) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
return vorlage;
}),
),
anlegen: mitRecht("pruefung.planen")
.input(vorlageFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorlage = await db.wartungsvorlage.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wartungsvorlage",
entitaetId: vorlage.id,
beschreibung: `Wartungsvorlage "${vorlage.name}" angelegt (alle ${vorlage.intervallMonate} Monate)`,
sitzung: ctx.sitzung,
nachher: vorlage,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return vorlage;
}),
),
aendern: mitRecht("pruefung.planen")
.input(vorlageFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.wartungsvorlage.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
const nachher = await db.wartungsvorlage.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungsvorlage",
entitaetId: id,
beschreibung: `Wartungsvorlage "${nachher.name}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Weiches Löschen. Offene Termine dieser Vorlage werden gleich mit auf
* "entfallen" gesetzt — sonst bliebe im Dashboard eine Fälligkeit stehen,
* zu der es keine Regel mehr gibt.
*/
loeschen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
const nachher = await db.wartungsvorlage.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
const entfallen = await db.wartungstermin.updateMany({
where: { vorlageId: input.id, status: "OFFEN", geloeschtAm: null },
data: { status: "ENTFALLEN" },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Wartungsvorlage",
entitaetId: input.id,
beschreibung: `Wartungsvorlage "${vorher.name}" gelöscht, ${entfallen.count} offene Termine entfallen`,
sitzung: ctx.sitzung,
vorher,
nachher,
kontext: { entfalleneTermine: entfallen.count },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true, entfalleneTermine: entfallen.count };
}),
),
wiederherstellen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
if (!vorher?.geloeschtAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Vorlage ist gar nicht gelöscht.",
});
}
const nachher = await db.wartungsvorlage.update({
where: { id: input.id },
data: { geloeschtAm: null, aktiv: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.WIEDERHERGESTELLT,
entitaet: "Wartungsvorlage",
entitaetId: input.id,
beschreibung: `Wartungsvorlage "${nachher.name}" wiederhergestellt`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
// ---------------------------------------------------------------------------
// Checklistenpunkte
// ---------------------------------------------------------------------------
punktAnlegen: mitRecht("pruefung.planen")
.input(z.object({ vorlageId: z.string().uuid() }).and(punktFelder))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { vorlageId, ...felder } = input;
const vorlage = await db.wartungsvorlage.findUnique({
where: { id: vorlageId },
select: { id: true, name: true },
});
if (!vorlage) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
// Neue Punkte hängen sich hinten an, wenn keine Sortierung angegeben ist.
const sortierung =
felder.sortierung > 0
? felder.sortierung
: ((
await db.checklistenpunkt.aggregate({
where: { vorlageId, geloeschtAm: null },
_max: { sortierung: true },
})
)._max.sortierung ?? 0) + 10;
const punkt = await db.checklistenpunkt.create({
data: {
organisationId: ctx.sitzung.organisationId,
vorlageId,
...felder,
sortierung,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Checklistenpunkt",
entitaetId: punkt.id,
beschreibung: `Prüfpunkt "${punkt.text}" zu "${vorlage.name}" ergänzt`,
sitzung: ctx.sitzung,
nachher: punkt,
kontext: { vorlageId },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return punkt;
}),
),
punktAendern: mitRecht("pruefung.planen")
.input(z.object({ id: z.string().uuid() }).and(punktAenderung))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.checklistenpunkt.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
const nachher = await db.checklistenpunkt.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Checklistenpunkt",
entitaetId: id,
beschreibung: `Prüfpunkt "${nachher.text}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Weiches Löschen — ein Punkt kann in abgeschlossenen Protokollen stehen.
* Die Datenbank verhindert das harte Löschen ohnehin (Restrict auf
* pruefergebnis), aber die verständliche Fassung steht hier.
*/
punktLoeschen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.checklistenpunkt.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
await db.checklistenpunkt.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Checklistenpunkt",
entitaetId: input.id,
beschreibung: `Prüfpunkt "${vorher.text}" entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/** Reihenfolge der Checkliste festlegen — die Prüfung läuft sie so ab. */
punkteSortieren: mitRecht("pruefung.planen")
.input(
z.object({
vorlageId: z.string().uuid(),
reihenfolge: z.array(z.string().uuid()).min(1).max(500),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
for (const [index, punktId] of input.reihenfolge.entries()) {
await db.checklistenpunkt.updateMany({
where: { id: punktId, vorlageId: input.vorlageId },
data: { sortierung: (index + 1) * 10 },
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungsvorlage",
entitaetId: input.vorlageId,
beschreibung: `Reihenfolge der Checkliste geändert (${input.reihenfolge.length} Punkte)`,
sitzung: ctx.sitzung,
kontext: { reihenfolge: input.reihenfolge },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { sortiert: input.reihenfolge.length };
}),
),
});