884 lines
30 KiB
TypeScript
884 lines
30 KiB
TypeScript
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 };
|
|
}),
|
|
),
|
|
});
|