Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine Kreisebene, die zusieht. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto, und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig. Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit). Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase 0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft. Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht: `personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat, und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen die Kreisaufsicht nichts verloren hat. Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst, minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie neue Leserechte automatisch und neue Schreibrechte nie. Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand zu hinterlassen. Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte ausloesen, bevor eine Seite ueberhaupt geladen war. 581 Unit-Tests und 266 E2E-Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 };
|
|
}),
|
|
),
|
|
});
|