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
+883
View File
@@ -0,0 +1,883 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
ChecklistenpunktTyp,
MangelPrioritaet,
Prisma,
PruefErgebnis,
PruefungArt,
PruefungStatus,
SignaturRolle,
WartungsterminStatus,
} from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
import { downloadLink } from "@/lib/storage";
import {
dauerInMinuten,
ergebnisVorschlag,
offenePflichtpunkte,
punktInOrdnung,
type Antwort,
type Punkt,
} from "@/lib/wartung/bewertung";
import {
naechsteFaelligkeit,
protokollNummer,
protokollNummerLesen,
} from "@/lib/wartung/fristen";
import { utcTag } from "@/lib/stammdaten/fristen";
/**
* Prüfungen — der Wizard von der ersten Frage bis zum unterschriebenen PDF.
*
* Der Ablauf in vier Schritten:
*
* starten legt einen Entwurf an und liefert die Checkliste
* ergebnisSpeichern je Punkt, so oft wie nötig — der Wizard speichert laufend
* abschliessen prüft auf Vollständigkeit, unterschreibt, schreibt fest
* (Hintergrund) das PDF entsteht im Worker und wird nachgetragen
*
* Warum laufend gespeichert wird: eine Atemschutzprüfung dauert eine
* Dreiviertelstunde, und ein Tablet im Gerätehaus verliert schon mal das WLAN.
* Wer dann von vorn anfangen muss, macht die Prüfung beim nächsten Mal wieder
* auf Papier.
*
* Nach dem Abschluss ist Schluss: die Datenbank lässt keine Änderung mehr zu
* (siehe Migration 20260903000300). Korrekturen laufen über korrigieren() und
* erzeugen ein neues Protokoll mit Verweis auf das alte.
*/
/** Was die Detailansicht und der Wizard brauchen. */
const PRUEFUNG_EINSCHLUSS = {
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
seriennummer: true,
hersteller: true,
modell: true,
herstellungsdatum: true,
indienststellung: true,
geraetetyp: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
},
vorlage: {
select: {
id: true,
name: true,
intervallMonate: true,
pruefvorschrift: true,
standardDauerMinuten: true,
punkte: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
},
},
},
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
ergebnisse: { include: { punkt: true } },
signaturen: true,
maengel: { where: { geloeschtAm: null } },
termin: { select: { id: true, faelligAm: true, status: true } },
ersetzt: { select: { id: true, protokollNummer: true, durchgefuehrtAm: true } },
} satisfies Prisma.PruefungInclude;
/** Prisma-Punkt in die Form, mit der lib/wartung/bewertung rechnet. */
function alsPunkt(punkt: {
id: string;
text: string;
typ: ChecklistenpunktTyp;
pflicht: boolean;
minWert: Prisma.Decimal | null;
maxWert: Prisma.Decimal | null;
}): Punkt {
return {
id: punkt.id,
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht,
minWert: punkt.minWert ? punkt.minWert.toNumber() : null,
maxWert: punkt.maxWert ? punkt.maxWert.toNumber() : null,
};
}
function alsAntwort(ergebnis: {
punktId: string;
jaNein: boolean | null;
zahlwert: Prisma.Decimal | null;
text: string | null;
fotoDateiId: string | null;
inOrdnung: boolean;
}): Antwort {
return {
punktId: ergebnis.punktId,
jaNein: ergebnis.jaNein,
zahlwert: ergebnis.zahlwert ? ergebnis.zahlwert.toNumber() : null,
text: ergebnis.text,
fotoDateiId: ergebnis.fotoDateiId,
inOrdnung: ergebnis.inOrdnung,
};
}
export const pruefungRouter = router({
/** Liste der Prüfungen, für die Historie am Gerät und die Gesamtübersicht. */
liste: mitRecht("pruefung.lesen")
.input(
z.object({
geraetId: z.string().uuid().optional(),
prueferId: z.string().uuid().optional(),
status: z.array(z.nativeEnum(PruefungStatus)).optional(),
ergebnis: z.array(z.nativeEnum(PruefErgebnis)).optional(),
vonAm: z.coerce.date().optional(),
bisAm: z.coerce.date().optional(),
anzahl: z.number().int().min(1).max(200).default(50),
}),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.pruefung.findMany({
where: {
geloeschtAm: null,
...(input.geraetId ? { geraetId: input.geraetId } : {}),
...(input.prueferId ? { prueferId: input.prueferId } : {}),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(input.ergebnis?.length ? { ergebnis: { in: input.ergebnis } } : {}),
...(input.vonAm || input.bisAm
? {
durchgefuehrtAm: {
...(input.vonAm ? { gte: input.vonAm } : {}),
...(input.bisAm ? { lte: input.bisAm } : {}),
},
}
: {}),
},
orderBy: { durchgefuehrtAm: "desc" },
take: input.anzahl,
select: {
id: true,
protokollNummer: true,
durchgefuehrtAm: true,
art: true,
status: true,
ergebnis: true,
dauerMinuten: true,
externeFirma: true,
kosten: true,
naechstePruefungAm: true,
protokollDateiId: true,
prueferName: true,
pruefer: { select: { id: true, vorname: true, nachname: true } },
vorlage: { select: { id: true, name: true } },
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
_count: { select: { maengel: true } },
},
}),
),
),
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: PRUEFUNG_EINSCHLUSS,
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
return pruefung;
}),
),
/** Eigene Entwürfe — angefangene Prüfungen, die noch offen liegen. */
entwuerfe: mitRecht("pruefung.durchfuehren").query(async ({ ctx }) =>
ctx.db((db) =>
db.pruefung.findMany({
where: {
status: PruefungStatus.ENTWURF,
geloeschtAm: null,
prueferId: ctx.sitzung.personId,
},
orderBy: { erstelltAm: "desc" },
select: {
id: true,
begonnenUm: true,
durchgefuehrtAm: true,
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
vorlage: { select: { id: true, name: true } },
_count: { select: { ergebnisse: true } },
},
}),
),
),
/**
* Prüfung beginnen.
*
* Gibt es zu diesem Gerät und dieser Vorlage bereits einen eigenen Entwurf,
* wird der fortgesetzt statt ein zweiter angelegt. Sonst sammeln sich nach
* jedem verlorenen WLAN halbe Prüfungen an.
*/
starten: mitRecht("pruefung.durchfuehren")
.input(
z.object({
geraetId: z.string().uuid(),
terminId: z.string().uuid().nullish(),
vorlageId: z.string().uuid().nullish(),
art: z.nativeEnum(PruefungArt).default(PruefungArt.INTERN),
externeFirma: z.string().trim().max(200).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.geraetId },
select: { id: true, inventarnummer: true, bezeichnung: true, geraetetypId: true },
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
// Vorlage: ausdrücklich gewählt, sonst über den Termin, sonst die des
// Gerätetyps — in dieser Reihenfolge.
let vorlageId = input.vorlageId ?? null;
if (!vorlageId && input.terminId) {
const termin = await db.wartungstermin.findUnique({
where: { id: input.terminId },
select: { vorlageId: true },
});
vorlageId = termin?.vorlageId ?? null;
}
if (!vorlageId && geraet.geraetetypId) {
const passende = await db.wartungsvorlage.findFirst({
where: { geraetetypId: geraet.geraetetypId, aktiv: true, geloeschtAm: null },
orderBy: { intervallMonate: "asc" },
select: { id: true },
});
vorlageId = passende?.id ?? null;
}
const laufender = await db.pruefung.findFirst({
where: {
geraetId: input.geraetId,
vorlageId,
status: PruefungStatus.ENTWURF,
prueferId: ctx.sitzung.personId,
geloeschtAm: null,
},
include: PRUEFUNG_EINSCHLUSS,
});
if (laufender) return { pruefung: laufender, fortgesetzt: true };
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: { vorname: true, nachname: true },
});
const pruefung = await db.pruefung.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: input.geraetId,
terminId: input.terminId ?? null,
vorlageId,
art: input.art,
externeFirma: input.externeFirma ?? null,
prueferId: ctx.sitzung.personId,
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
durchgefuehrtAm: utcTag(new Date()),
begonnenUm: new Date(),
},
include: PRUEFUNG_EINSCHLUSS,
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfung an ${geraet.inventarnummer} begonnen`,
sitzung: ctx.sitzung,
nachher: { geraetId: geraet.id, vorlageId, art: input.art },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { pruefung, fortgesetzt: false };
}),
),
/**
* Eine Antwort speichern.
*
* Ob der Punkt in Ordnung ist, rechnet der Server — der Browser schickt nur,
* was gemessen oder angekreuzt wurde. Eine ausdrückliche Angabe des Prüfers
* hat Vorrang: er steht davor und sieht mehr als die Zahl.
*/
ergebnisSpeichern: mitRecht("pruefung.durchfuehren")
.input(
z.object({
pruefungId: z.string().uuid(),
punktId: z.string().uuid(),
jaNein: z.boolean().nullish(),
zahlwert: z.number().nullish(),
text: z.string().trim().max(1000).nullish(),
fotoDateiId: z.string().uuid().nullish(),
inOrdnung: z.boolean().nullish(),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.pruefungId },
select: { id: true, status: true, vorlageId: true },
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Prüfung ist abgeschlossen und lässt sich nicht mehr ändern. Für eine Korrektur eine neue Prüfung anlegen.",
});
}
const punkt = await db.checklistenpunkt.findUnique({ where: { id: input.punktId } });
if (!punkt) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
const bewertet = punktInOrdnung(alsPunkt(punkt), {
punktId: input.punktId,
jaNein: input.jaNein,
zahlwert: input.zahlwert,
text: input.text,
fotoDateiId: input.fotoDateiId,
inOrdnung: input.inOrdnung,
});
const daten = {
jaNein: input.jaNein ?? null,
zahlwert: input.zahlwert ?? null,
text: input.text ?? null,
fotoDateiId: input.fotoDateiId ?? null,
bemerkung: input.bemerkung ?? null,
inOrdnung: bewertet,
};
return db.pruefergebnis.upsert({
where: { pruefungId_punktId: { pruefungId: input.pruefungId, punktId: input.punktId } },
create: {
organisationId: ctx.sitzung.organisationId,
pruefungId: input.pruefungId,
punktId: input.punktId,
...daten,
},
update: daten,
});
}),
),
/** Zwischenstand: was fehlt noch, was ist beanstandet, was käme heraus. */
stand: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: {
vorlage: {
select: { punkte: { where: { geloeschtAm: null }, orderBy: { sortierung: "asc" } } },
},
ergebnisse: true,
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
const antworten = pruefung.ergebnisse.map(alsAntwort);
return {
offenePflichtpunkte: offenePflichtpunkte(punkte, antworten).map((punkt) => ({
id: punkt.id,
text: punkt.text,
})),
beantwortet: antworten.length,
gesamt: punkte.length,
vorschlag: ergebnisVorschlag(punkte, antworten),
};
}),
),
/**
* Prüfung abschließen.
*
* Hier passiert alles, was zusammengehört, in einer Transaktion:
* Unterschriften anlegen, Mängel melden, Protokollnummer ziehen, Prüfung
* festschreiben, Termin erledigen, nächsten Termin planen und den Status des
* Geräts nachziehen. Ginge davon etwas schief, darf nichts davon gelten.
*
* Die Reihenfolge ist nicht beliebig: Unterschriften und Mängel müssen vor
* dem Statuswechsel entstehen, denn danach lässt die Datenbank am Protokoll
* nichts mehr zu.
*/
abschliessen: mitRecht("pruefung.durchfuehren")
.input(
z.object({
id: z.string().uuid(),
ergebnis: z.nativeEnum(PruefErgebnis).optional(),
durchgefuehrtAm: z.coerce.date().optional(),
bemerkung: z.string().trim().max(2000).nullish(),
naechstePruefungAm: z.coerce.date().nullish(),
externeFirma: z.string().trim().max(200).nullish(),
externeProtokollDateiId: z.string().uuid().nullish(),
kosten: z.number().nonnegative().max(9_999_999).nullish(),
signaturen: z
.array(
z.object({
rolle: z.nativeEnum(SignaturRolle).default(SignaturRolle.PRUEFER),
name: z.string().trim().min(1).max(200),
personId: z.string().uuid().nullish(),
bildDateiId: z.string().uuid().nullish(),
}),
)
.min(1, "Ohne Unterschrift ist ein Protokoll kein Nachweis.")
.max(4),
maengel: z
.array(
z.object({
beschreibung: z.string().trim().min(1).max(2000),
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
fotoDateiId: z.string().uuid().nullish(),
}),
)
.max(50)
.default([]),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: {
geraet: {
select: {
id: true,
inventarnummer: true,
status: true,
indienststellung: true,
pruefintervallMonate: true,
},
},
vorlage: {
select: {
id: true,
intervallMonate: true,
punkte: { where: { geloeschtAm: null } },
},
},
ergebnisse: true,
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Prüfung ist bereits abgeschlossen.",
});
}
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
const antworten = pruefung.ergebnisse.map(alsAntwort);
const offene = offenePflichtpunkte(punkte, antworten);
if (offene.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Es fehlen noch ${offene.length} Pflichtangaben: ${offene
.slice(0, 3)
.map((punkt) => punkt.text)
.join(", ")}${offene.length > 3 ? " …" : ""}`,
});
}
const ergebnis = input.ergebnis ?? ergebnisVorschlag(punkte, antworten);
const jetzt = new Date();
const durchgefuehrtAm = utcTag(input.durchgefuehrtAm ?? pruefung.durchgefuehrtAm);
// Nächster Termin: was der Prüfer angibt, sonst das Intervall der
// Vorlage, sonst das am Gerät hinterlegte.
const intervall = pruefung.vorlage?.intervallMonate ?? pruefung.geraet.pruefintervallMonate;
const naechste =
input.naechstePruefungAm ??
(intervall
? naechsteFaelligkeit({ letztePruefung: durchgefuehrtAm, intervallMonate: intervall })
: null);
// Unterschriften und Mängel zuerst — danach ist das Protokoll dicht.
for (const signatur of input.signaturen) {
await db.signatur.create({
data: {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
rolle: signatur.rolle,
name: signatur.name,
personId: signatur.personId ?? null,
bildDateiId: signatur.bildDateiId ?? null,
unterzeichnetAm: jetzt,
},
});
}
for (const mangel of input.maengel) {
await db.mangel.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: pruefung.geraetId,
pruefungId: pruefung.id,
melderId: ctx.sitzung.personId,
melderName: ctx.sitzung.name,
beschreibung: mangel.beschreibung,
prioritaet: mangel.prioritaet,
fotoDateiId: mangel.fotoDateiId ?? null,
geraetGesperrt: ergebnis === PruefErgebnis.NICHT_BESTANDEN,
},
});
}
// Fortlaufende Protokollnummer je Wehr und Jahr.
//
// Zwei Prüfer, die gleichzeitig abschließen, würden ohne Sperre
// dieselbe Nummer ziehen — die eindeutige Bedingung in der Datenbank
// fängt das ab, aber der zweite bekäme einen Datenbankfehler statt
// eines Protokolls. Die Sperre gilt nur bis zum Ende dieser
// Transaktion und nur für diese Wehr und dieses Jahr.
const jahr = durchgefuehrtAm.getUTCFullYear();
const sperrschluessel = `pruefung:${ctx.sitzung.organisationId}:${jahr}`;
await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${sperrschluessel}))`;
// Höchste vergebene Nummer plus eins — nicht die Anzahl der Zeilen.
// Nummern werden nie wiederverwendet, auch wenn eine Prüfung im
// Aufbewahrungslauf verschwindet.
const letzte = await db.pruefung.findFirst({
where: { protokollNummer: { startsWith: `${jahr}-` } },
orderBy: { protokollNummer: "desc" },
select: { protokollNummer: true },
});
const nummer = protokollNummer(
jahr,
(protokollNummerLesen(letzte?.protokollNummer)?.laufend ?? 0) + 1,
);
const abgeschlossen = await db.pruefung.update({
where: { id: pruefung.id },
data: {
status: PruefungStatus.ABGESCHLOSSEN,
ergebnis,
durchgefuehrtAm,
bemerkung: input.bemerkung ?? pruefung.bemerkung,
naechstePruefungAm: naechste,
externeFirma: input.externeFirma ?? pruefung.externeFirma,
externeProtokollDateiId:
input.externeProtokollDateiId ?? pruefung.externeProtokollDateiId,
kosten: input.kosten ?? pruefung.kosten,
dauerMinuten: dauerInMinuten(pruefung.begonnenUm, jetzt),
protokollNummer: nummer,
abgeschlossenAm: jetzt,
},
});
// Eine Korrektur löst das alte Protokoll ab. Es bleibt lesbar.
if (pruefung.ersetztPruefungId) {
await db.pruefung.update({
where: { id: pruefung.ersetztPruefungId },
data: { status: PruefungStatus.ERSETZT },
});
}
// Termin erledigen und den nächsten planen.
if (pruefung.terminId) {
await db.wartungstermin.update({
where: { id: pruefung.terminId },
data: { status: WartungsterminStatus.ERLEDIGT, erledigtAm: jetzt },
});
}
if (naechste && pruefung.vorlageId) {
await db.wartungstermin.upsert({
where: {
geraetId_vorlageId_faelligAm: {
geraetId: pruefung.geraetId,
vorlageId: pruefung.vorlageId,
faelligAm: naechste,
},
},
create: {
organisationId: ctx.sitzung.organisationId,
geraetId: pruefung.geraetId,
vorlageId: pruefung.vorlageId,
faelligAm: naechste,
automatischErzeugt: true,
},
update: {},
});
}
// Der Status des Geräts folgt dem Ergebnis: nicht bestanden heißt
// gesperrt. Bestanden hebt eine frühere Sperre bewusst NICHT auf —
// wer gesperrt hat, hatte einen Grund, und der kann außerhalb dieser
// Prüfung liegen. Nur "Prüfung fällig" löst sich von selbst.
const neuerStatus =
ergebnis === PruefErgebnis.NICHT_BESTANDEN
? "GESPERRT"
: pruefung.geraet.status === "PRUEFUNG_FAELLIG"
? "EINSATZBEREIT"
: pruefung.geraet.status;
await db.geraet.update({
where: { id: pruefung.geraetId },
data: {
letztePruefung: durchgefuehrtAm,
naechstePruefung: naechste,
status: neuerStatus,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfung ${nummer} an ${pruefung.geraet.inventarnummer} abgeschlossen: ${ergebnis}`,
sitzung: ctx.sitzung,
vorher: { status: pruefung.status },
nachher: {
status: PruefungStatus.ABGESCHLOSSEN,
ergebnis,
protokollNummer: nummer,
naechstePruefungAm: naechste,
},
kontext: {
maengel: input.maengel.length,
unterschriften: input.signaturen.length,
geraetStatus: neuerStatus,
},
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
// Das PDF entsteht im Hintergrund: Gotenberg braucht ein paar Sekunden,
// und der Prüfer soll währenddessen nicht auf einen Ladebalken sehen.
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
}).catch((fehler) => {
console.error("[pruefung] PDF-Auftrag ließ sich nicht einstellen:", fehler);
});
return abgeschlossen;
}),
),
/**
* Korrektur eines abgeschlossenen Protokolls.
*
* Es wird nichts überschrieben. Stattdessen entsteht ein neuer Entwurf mit
* denselben Antworten und einem Verweis auf das alte Protokoll. Erst mit
* seinem Abschluss gilt das alte als ersetzt.
*/
korrigieren: mitRecht("pruefung.durchfuehren")
.input(idEingabe.extend({ grund: z.string().trim().min(1, "Ein Grund gehört dazu.").max(500) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const alt = await db.pruefung.findUnique({
where: { id: input.id },
include: { ergebnisse: true, geraet: { select: { inventarnummer: true } } },
});
if (!alt) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (alt.status === PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein Entwurf lässt sich unmittelbar ändern — dafür braucht es keine Korrektur.",
});
}
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: { vorname: true, nachname: true },
});
const neu = await db.pruefung.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: alt.geraetId,
terminId: alt.terminId,
vorlageId: alt.vorlageId,
art: alt.art,
externeFirma: alt.externeFirma,
kosten: alt.kosten,
prueferId: ctx.sitzung.personId,
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
durchgefuehrtAm: alt.durchgefuehrtAm,
begonnenUm: new Date(),
bemerkung: alt.bemerkung,
ersetztPruefungId: alt.id,
korrekturGrund: input.grund,
},
});
// Die bisherigen Antworten übernehmen, damit nur das Falsche geändert
// werden muss und nicht alles noch einmal einzutippen ist.
for (const ergebnis of alt.ergebnisse) {
await db.pruefergebnis.create({
data: {
organisationId: ctx.sitzung.organisationId,
pruefungId: neu.id,
punktId: ergebnis.punktId,
jaNein: ergebnis.jaNein,
zahlwert: ergebnis.zahlwert,
text: ergebnis.text,
fotoDateiId: ergebnis.fotoDateiId,
inOrdnung: ergebnis.inOrdnung,
bemerkung: ergebnis.bemerkung,
},
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Pruefung",
entitaetId: neu.id,
beschreibung: `Korrektur zu Protokoll ${alt.protokollNummer ?? alt.id} an ${alt.geraet.inventarnummer} begonnen: ${input.grund}`,
sitzung: ctx.sitzung,
nachher: { ersetztPruefungId: alt.id, korrekturGrund: input.grund },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return neu;
}),
),
/** Entwurf verwerfen. Abgeschlossene Prüfungen sind davon ausgenommen. */
entwurfVerwerfen: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
select: { id: true, status: true, prueferId: true, geraetId: true },
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Abgeschlossene Protokolle lassen sich nicht verwerfen.",
});
}
await db.pruefung.delete({ where: { id: input.id } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Pruefung",
entitaetId: input.id,
beschreibung: "Angefangene Prüfung verworfen",
sitzung: ctx.sitzung,
vorher: pruefung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/**
* Zeitlich begrenzter Link auf das Protokoll-PDF.
*
* Fehlt das PDF noch, wird die Erzeugung angestoßen und der Aufrufer
* gebeten, es gleich noch einmal zu versuchen — statt ihn mit einem Fehler
* stehen zu lassen.
*/
protokollLink: mitRecht("pruefung.lesen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
select: {
id: true,
protokollNummer: true,
status: true,
protokoll: { select: { objektSchluessel: true, dateiname: true } },
geraet: { select: { inventarnummer: true } },
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (!pruefung.protokoll) {
if (pruefung.status === PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein Protokoll entsteht erst mit dem Abschluss der Prüfung.",
});
}
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
});
return { bereit: false as const, adresse: null };
}
const adresse = await downloadLink(
pruefung.protokoll.objektSchluessel,
pruefung.protokoll.dateiname,
);
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.DATEI_ABGERUFEN,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Protokoll ${pruefung.protokollNummer ?? ""} abgerufen`,
sitzung: ctx.sitzung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { bereit: true as const, adresse };
}),
),
});