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
@@ -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 };
|
||||
}),
|
||||
),
|
||||
});
|
||||
Reference in New Issue
Block a user