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