import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { ChecklistenpunktTyp, MangelPrioritaet, Prisma, PruefErgebnis, PruefungArt, PruefungStatus, SignaturRolle, WartungsterminStatus, } from "@prisma/client"; import { router, mitRecht, eigeneAkte } 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: eigeneAkte(ctx.sitzung) }, 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: eigeneAkte(ctx.sitzung) }, 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 }; }), ), });