Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
@@ -0,0 +1,469 @@
-- =============================================================================
-- Phase 2: Tabellen für Wartung und Prüfung.
--
-- Erzeugt mit `prisma migrate dev --create-only` und danach an einer Stelle von
-- Hand bereinigt: Prisma wollte die Volltext- und Geoindizes aus Phase 1
-- (gin_trgm_ops, gist auf Koordinaten) löschen, weil es sie im Schema nicht
-- kennt — Prisma kann diese Indexarten nicht abbilden. Die DROP INDEX-Zeilen
-- sind deshalb entfernt. Wer diese Migration neu erzeugt, muss das wieder tun.
--
-- Mandantentrennung, Unveränderbarkeit der Protokolle und die Suchindizes für
-- die neuen Tabellen stehen in der nachfolgenden Migration.
-- =============================================================================
-- CreateEnum
CREATE TYPE "WartungsterminStatus" AS ENUM ('OFFEN', 'ERLEDIGT', 'ENTFALLEN');
-- CreateEnum
CREATE TYPE "PruefungStatus" AS ENUM ('ENTWURF', 'ABGESCHLOSSEN', 'ERSETZT');
-- CreateEnum
CREATE TYPE "PruefErgebnis" AS ENUM ('BESTANDEN', 'BESTANDEN_MIT_MAENGELN', 'NICHT_BESTANDEN');
-- CreateEnum
CREATE TYPE "PruefungArt" AS ENUM ('INTERN', 'EXTERN');
-- CreateEnum
CREATE TYPE "ChecklistenpunktTyp" AS ENUM ('JA_NEIN', 'MESSWERT', 'TEXT', 'FOTO');
-- CreateEnum
CREATE TYPE "SignaturRolle" AS ENUM ('PRUEFER', 'GERAETEWART', 'FREMDFIRMA');
-- CreateEnum
CREATE TYPE "MangelPrioritaet" AS ENUM ('NIEDRIG', 'MITTEL', 'HOCH', 'KRITISCH');
-- CreateEnum
CREATE TYPE "MangelStatus" AS ENUM ('GEMELDET', 'ZUGEWIESEN', 'IN_ARBEIT', 'BEHOBEN', 'VERWORFEN');
-- CreateEnum
CREATE TYPE "RueckrufStatus" AS ENUM ('OFFEN', 'IN_BEARBEITUNG', 'ERLEDIGT');
-- CreateEnum
CREATE TYPE "BetroffenheitStatus" AS ENUM ('OFFEN', 'NICHT_BETROFFEN', 'MASSNAHME_LAEUFT', 'ERLEDIGT');
-- CreateEnum
CREATE TYPE "ErinnerungStufe" AS ENUM ('T_30', 'T_7', 'T_0', 'UEBERFAELLIG');
-- CreateTable
CREATE TABLE "wartungsvorlage" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"geraetetypId" UUID,
"intervallMonate" INTEGER NOT NULL,
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 30,
"pruefvorschrift" VARCHAR(200),
"standardDauerMinuten" INTEGER,
"standardExtern" BOOLEAN NOT NULL DEFAULT false,
"automatisch" BOOLEAN NOT NULL DEFAULT true,
"beschreibung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wartungsvorlage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "checklistenpunkt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorlageId" UUID NOT NULL,
"text" VARCHAR(300) NOT NULL,
"typ" "ChecklistenpunktTyp" NOT NULL DEFAULT 'JA_NEIN',
"pflicht" BOOLEAN NOT NULL DEFAULT true,
"sollwert" VARCHAR(120),
"einheit" VARCHAR(20),
"minWert" DECIMAL(12,3),
"maxWert" DECIMAL(12,3),
"hinweis" VARCHAR(500),
"sortierung" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "checklistenpunkt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wartungstermin" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"vorlageId" UUID,
"faelligAm" DATE NOT NULL,
"status" "WartungsterminStatus" NOT NULL DEFAULT 'OFFEN',
"automatischErzeugt" BOOLEAN NOT NULL DEFAULT false,
"zugewiesenAnId" UUID,
"bemerkung" VARCHAR(1000),
"erledigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wartungstermin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pruefung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"terminId" UUID,
"vorlageId" UUID,
"art" "PruefungArt" NOT NULL DEFAULT 'INTERN',
"status" "PruefungStatus" NOT NULL DEFAULT 'ENTWURF',
"prueferId" UUID,
"prueferName" VARCHAR(200),
"durchgefuehrtAm" DATE NOT NULL,
"begonnenUm" TIMESTAMPTZ(3),
"dauerMinuten" INTEGER,
"ergebnis" "PruefErgebnis",
"externeFirma" VARCHAR(200),
"externeProtokollDateiId" UUID,
"kosten" DECIMAL(10,2),
"naechstePruefungAm" DATE,
"protokollDateiId" UUID,
"protokollNummer" VARCHAR(40),
"abgeschlossenAm" TIMESTAMPTZ(3),
"ersetztPruefungId" UUID,
"korrekturGrund" VARCHAR(500),
"bemerkung" VARCHAR(2000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "pruefung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pruefergebnis" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"pruefungId" UUID NOT NULL,
"punktId" UUID NOT NULL,
"jaNein" BOOLEAN,
"zahlwert" DECIMAL(12,3),
"text" VARCHAR(1000),
"fotoDateiId" UUID,
"inOrdnung" BOOLEAN NOT NULL DEFAULT true,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "pruefergebnis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "signatur" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"pruefungId" UUID NOT NULL,
"rolle" "SignaturRolle" NOT NULL DEFAULT 'PRUEFER',
"personId" UUID,
"name" VARCHAR(200) NOT NULL,
"bildDateiId" UUID,
"unterzeichnetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "signatur_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mangel" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"pruefungId" UUID,
"melderId" UUID,
"melderName" VARCHAR(200),
"beschreibung" VARCHAR(2000) NOT NULL,
"fotoDateiId" UUID,
"prioritaet" "MangelPrioritaet" NOT NULL DEFAULT 'MITTEL',
"status" "MangelStatus" NOT NULL DEFAULT 'GEMELDET',
"zugewiesenAnId" UUID,
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"behobenAm" TIMESTAMPTZ(3),
"behobenVonId" UUID,
"behebung" VARCHAR(2000),
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "mangel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"hersteller" VARCHAR(160) NOT NULL,
"titel" VARCHAR(200) NOT NULL,
"referenz" VARCHAR(80),
"beschreibung" VARCHAR(4000),
"massnahme" VARCHAR(2000),
"veroeffentlichtAm" DATE,
"quelle" VARCHAR(500),
"sperrtGeraete" BOOLEAN NOT NULL DEFAULT false,
"status" "RueckrufStatus" NOT NULL DEFAULT 'OFFEN',
"erledigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "rueckruf_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf_geraetetyp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rueckrufId" UUID NOT NULL,
"geraetetypId" UUID,
"modell" VARCHAR(160),
"seriennummerVon" VARCHAR(120),
"seriennummerBis" VARCHAR(120),
"herstellungVon" DATE,
"herstellungBis" DATE,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rueckruf_geraetetyp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf_betroffenheit" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rueckrufId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"status" "BetroffenheitStatus" NOT NULL DEFAULT 'OFFEN',
"festgestelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erledigtAm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(1000),
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rueckruf_betroffenheit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "erinnerung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"terminId" UUID NOT NULL,
"stufe" "ErinnerungStufe" NOT NULL,
"versendetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"kanal" VARCHAR(40) NOT NULL DEFAULT 'mail',
"empfaenger" TEXT[] DEFAULT ARRAY[]::TEXT[],
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
"meldung" VARCHAR(500),
CONSTRAINT "erinnerung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "wartungsvorlage_organisationId_geraetetypId_idx" ON "wartungsvorlage"("organisationId", "geraetetypId");
-- CreateIndex
CREATE INDEX "wartungsvorlage_organisationId_aktiv_idx" ON "wartungsvorlage"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "wartungsvorlage_organisationId_name_key" ON "wartungsvorlage"("organisationId", "name");
-- CreateIndex
CREATE INDEX "checklistenpunkt_organisationId_vorlageId_idx" ON "checklistenpunkt"("organisationId", "vorlageId");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_faelligAm_idx" ON "wartungstermin"("organisationId", "faelligAm");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_status_faelligAm_idx" ON "wartungstermin"("organisationId", "status", "faelligAm");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_geraetId_idx" ON "wartungstermin"("organisationId", "geraetId");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_zugewiesenAnId_idx" ON "wartungstermin"("organisationId", "zugewiesenAnId");
-- CreateIndex
CREATE UNIQUE INDEX "wartungstermin_geraetId_vorlageId_faelligAm_key" ON "wartungstermin"("geraetId", "vorlageId", "faelligAm");
-- CreateIndex
CREATE INDEX "pruefung_organisationId_geraetId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "geraetId", "durchgefuehrtAm" DESC);
-- CreateIndex
CREATE INDEX "pruefung_organisationId_status_idx" ON "pruefung"("organisationId", "status");
-- CreateIndex
CREATE INDEX "pruefung_organisationId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "durchgefuehrtAm" DESC);
-- CreateIndex
CREATE INDEX "pruefung_organisationId_prueferId_idx" ON "pruefung"("organisationId", "prueferId");
-- CreateIndex
CREATE UNIQUE INDEX "pruefung_organisationId_protokollNummer_key" ON "pruefung"("organisationId", "protokollNummer");
-- CreateIndex
CREATE INDEX "pruefergebnis_organisationId_pruefungId_idx" ON "pruefergebnis"("organisationId", "pruefungId");
-- CreateIndex
CREATE UNIQUE INDEX "pruefergebnis_pruefungId_punktId_key" ON "pruefergebnis"("pruefungId", "punktId");
-- CreateIndex
CREATE INDEX "signatur_organisationId_pruefungId_idx" ON "signatur"("organisationId", "pruefungId");
-- CreateIndex
CREATE INDEX "mangel_organisationId_status_prioritaet_idx" ON "mangel"("organisationId", "status", "prioritaet");
-- CreateIndex
CREATE INDEX "mangel_organisationId_geraetId_idx" ON "mangel"("organisationId", "geraetId");
-- CreateIndex
CREATE INDEX "mangel_organisationId_zugewiesenAnId_idx" ON "mangel"("organisationId", "zugewiesenAnId");
-- CreateIndex
CREATE INDEX "rueckruf_organisationId_status_idx" ON "rueckruf"("organisationId", "status");
-- CreateIndex
CREATE INDEX "rueckruf_organisationId_hersteller_idx" ON "rueckruf"("organisationId", "hersteller");
-- CreateIndex
CREATE INDEX "rueckruf_geraetetyp_organisationId_rueckrufId_idx" ON "rueckruf_geraetetyp"("organisationId", "rueckrufId");
-- CreateIndex
CREATE INDEX "rueckruf_betroffenheit_organisationId_rueckrufId_idx" ON "rueckruf_betroffenheit"("organisationId", "rueckrufId");
-- CreateIndex
CREATE INDEX "rueckruf_betroffenheit_organisationId_geraetId_idx" ON "rueckruf_betroffenheit"("organisationId", "geraetId");
-- CreateIndex
CREATE UNIQUE INDEX "rueckruf_betroffenheit_rueckrufId_geraetId_key" ON "rueckruf_betroffenheit"("rueckrufId", "geraetId");
-- CreateIndex
CREATE INDEX "erinnerung_organisationId_versendetAm_idx" ON "erinnerung"("organisationId", "versendetAm" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "erinnerung_terminId_stufe_key" ON "erinnerung"("terminId", "stufe");
-- AddForeignKey
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_externeProtokollDateiId_fkey" FOREIGN KEY ("externeProtokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_ersetztPruefungId_fkey" FOREIGN KEY ("ersetztPruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_punktId_fkey" FOREIGN KEY ("punktId") REFERENCES "checklistenpunkt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_bildDateiId_fkey" FOREIGN KEY ("bildDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_melderId_fkey" FOREIGN KEY ("melderId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_behobenVonId_fkey" FOREIGN KEY ("behobenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf" ADD CONSTRAINT "rueckruf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,243 @@
-- =============================================================================
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
--
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
--
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
-- für die Tabellen aus Phase 0 und 1.
--
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
-- der Datenbank, nicht nur in lib/ und server/.
--
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
-- Rückrufe.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'wartungsvorlage',
'checklistenpunkt',
'wartungstermin',
'pruefung',
'pruefergebnis',
'signatur',
'mangel',
'rueckruf',
'rueckruf_geraetetyp',
'rueckruf_betroffenheit',
'erinnerung'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Unveränderbarkeit abgeschlossener Prüfungen
--
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
--
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
--
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
vergleich "pruefung"%ROWTYPE;
BEGIN
-- Entwürfe sind frei änderbar.
IF OLD."status" = 'ENTWURF' THEN
RETURN NEW;
END IF;
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
vergleich := NEW;
vergleich."geaendertAm" := OLD."geaendertAm";
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
vergleich."status" := OLD."status";
END IF;
IF OLD."protokollDateiId" IS NULL THEN
vergleich."protokollDateiId" := OLD."protokollDateiId";
END IF;
IF OLD."protokollNummer" IS NULL THEN
vergleich."protokollNummer" := OLD."protokollNummer";
END IF;
IF vergleich IS DISTINCT FROM OLD THEN
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
OLD."id"
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD."status" = 'ENTWURF' THEN
RETURN OLD;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
'(nur im Aufbewahrungslauf).',
OLD."id"
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER pruefung_abschluss_schuetzen
BEFORE UPDATE ON "pruefung"
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
CREATE TRIGGER pruefung_loeschschutz
BEFORE DELETE ON "pruefung"
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
-- -----------------------------------------------------------------------------
-- Dasselbe für die Bestandteile eines Protokolls.
--
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
betroffene_pruefung uuid;
zustand "PruefungStatus";
BEGIN
IF TG_OP = 'DELETE' THEN
betroffene_pruefung := OLD."pruefungId";
ELSE
betroffene_pruefung := NEW."pruefungId";
END IF;
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
betroffene_pruefung, TG_OP, TG_TABLE_NAME
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER pruefergebnis_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
CREATE TRIGGER signatur_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
-- -----------------------------------------------------------------------------
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
-- -----------------------------------------------------------------------------
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
);
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
-- erzeugen.
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
);
-- -----------------------------------------------------------------------------
-- Indizes
--
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
-- -----------------------------------------------------------------------------
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+644 -67
View File
@@ -145,27 +145,40 @@ model Organisation {
/// Funkrufname-Präfix der Wehr, z. B. "Florian Musterhausen".
funkPraefix String? @db.VarChar(80)
zeitzone String @default("Europe/Berlin") @db.VarChar(60)
zeitzone String @default("Europe/Berlin") @db.VarChar(60)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
personen Person[]
rollen Rolle[]
personRollen PersonRolle[]
rolleRechte RolleBerechtigung[]
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
dateien Datei[]
fahrzeuge Fahrzeug[]
geraete Geraet[]
standorte Standort[]
geraeteraeume Geraeteraum[]
geraetetypen Geraetetyp[]
personen Person[]
rollen Rolle[]
personRollen PersonRolle[]
rolleRechte RolleBerechtigung[]
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
dateien Datei[]
fahrzeuge Fahrzeug[]
geraete Geraet[]
standorte Standort[]
geraeteraeume Geraeteraum[]
geraetetypen Geraetetyp[]
qualifikationstypen Qualifikationstyp[]
qualifikationen Qualifikation[]
qualifikationen Qualifikation[]
// Phase 2 — Wartung und Prüfung
wartungsvorlagen Wartungsvorlage[]
checklistenpunkte Checklistenpunkt[]
wartungstermine Wartungstermin[]
pruefungen Pruefung[]
pruefergebnisse Pruefergebnis[]
signaturen Signatur[]
maengel Mangel[]
rueckrufe Rueckruf[]
rueckrufTypen RueckrufGeraetetyp[]
rueckrufBetroffenheiten RueckrufBetroffenheit[]
erinnerungen Erinnerung[]
@@index([aktiv])
@@map("organisation")
@@ -182,16 +195,16 @@ model Person {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
vorname String @db.VarChar(100)
nachname String @db.VarChar(100)
vorname String @db.VarChar(100)
nachname String @db.VarChar(100)
/// Fortlaufende Mitgliedsnummer innerhalb der Wehr.
mitgliedsnummer String? @db.VarChar(30)
geburtsdatum DateTime? @db.Date
dienstgrad Dienstgrad @default(KEIN)
mitgliedsnummer String? @db.VarChar(30)
geburtsdatum DateTime? @db.Date
dienstgrad Dienstgrad @default(KEIN)
email String? @db.VarChar(200)
email String? @db.VarChar(200)
telefon String? @db.VarChar(40)
mobil String? @db.VarChar(40)
mobil String? @db.VarChar(40)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
@@ -222,16 +235,24 @@ model Person {
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
rollen PersonRolle[] @relation("PersonRollenInhaber")
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
qualifikationen Qualifikation[] @relation("QualifikationInhaber")
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
rollen PersonRolle[] @relation("PersonRollenInhaber")
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
qualifikationen Qualifikation[] @relation("QualifikationInhaber")
bestaetigteQualifikationen Qualifikation[] @relation("QualifikationBestaetiger")
// Phase 2 — Wartung und Prüfung
zugewieseneTermine Wartungstermin[] @relation("TerminZustaendig")
durchgefuehrtePruefungen Pruefung[] @relation("PruefungPruefer")
signaturen Signatur[] @relation("SignaturUnterzeichner")
gemeldeteMaengel Mangel[] @relation("MangelMelder")
zugewieseneMaengel Mangel[] @relation("MangelZustaendig")
behobeneMaengel Mangel[] @relation("MangelBehebung")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -249,11 +270,11 @@ model Rolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
schluessel RolleSchluessel
name String @db.VarChar(100)
beschreibung String? @db.VarChar(500)
schluessel RolleSchluessel
name String @db.VarChar(100)
beschreibung String? @db.VarChar(500)
/// Systemrollen dürfen nicht gelöscht werden.
systemRolle Boolean @default(false)
systemRolle Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
@@ -275,14 +296,14 @@ model PersonRolle {
personId String @db.Uuid
rolleId String @db.Uuid
zugewiesenAm DateTime @default(now()) @db.Timestamptz(3)
zugewiesenVonId String? @db.Uuid
zugewiesenAm DateTime @default(now()) @db.Timestamptz(3)
zugewiesenVonId String? @db.Uuid
/// Befristete Zuweisung, z. B. Vertretung während Urlaub.
gueltigBis DateTime? @db.Timestamptz(3)
gueltigBis DateTime? @db.Timestamptz(3)
/// true, wenn die Rolle aus einer authentik-Gruppe vorbelegt wurde. Sobald
/// jemand sie in FLORIAN ändert, steht hier false und OIDC überschreibt nicht
/// mehr.
ausOidcGruppe Boolean @default(false)
ausOidcGruppe Boolean @default(false)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("PersonRollenInhaber", fields: [personId], references: [id], onDelete: Cascade)
@@ -304,11 +325,11 @@ model PersonRolle {
/// trägt organisationId. Die Tabelle ist deshalb für alle lesbar und für die
/// Anwendungsrolle nicht beschreibbar.
model Berechtigung {
id String @id @default(uuid()) @db.Uuid
schluessel String @unique @db.VarChar(80)
id String @id @default(uuid()) @db.Uuid
schluessel String @unique @db.VarChar(80)
/// Fachbereich für die Gruppierung in der Oberfläche.
bereich String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
bereich String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
beschreibung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
@@ -350,17 +371,17 @@ model AusweisToken {
typ AusweisTokenTyp
/// bcrypt-Hash der PIN bzw. der RFID-UID.
tokenHash String @db.VarChar(120)
tokenHash String @db.VarChar(120)
/// Letzte vier Stellen der Karten-UID, damit man Karten unterscheiden kann.
kennzeichen String? @db.VarChar(20)
bezeichnung String? @db.VarChar(120)
kennzeichen String? @db.VarChar(20)
bezeichnung String? @db.VarChar(120)
aktiv Boolean @default(true)
gueltigAb DateTime @default(now()) @db.Timestamptz(3)
gueltigBis DateTime? @db.Timestamptz(3)
letzteNutzung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
aktiv Boolean @default(true)
gueltigAb DateTime @default(now()) @db.Timestamptz(3)
gueltigBis DateTime? @db.Timestamptz(3)
letzteNutzung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
@@ -387,19 +408,19 @@ model AuditLog {
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
akteurTyp AkteurTyp @default(PERSON)
akteurPersonId String? @db.Uuid
akteurTyp AkteurTyp @default(PERSON)
akteurPersonId String? @db.Uuid
/// Klarname zum Zeitpunkt der Aktion — bleibt lesbar, auch wenn die Person
/// später anonymisiert wird.
akteurName String? @db.VarChar(200)
akteurName String? @db.VarChar(200)
anmeldeArt AnmeldeArt?
aktion AuditAktion
aktion AuditAktion
/// Modellname, z. B. "Geraet".
entitaet String @db.VarChar(80)
entitaetId String? @db.Uuid
entitaet String @db.VarChar(80)
entitaetId String? @db.Uuid
/// Menschenlesbare Kurzfassung für die Oberfläche.
beschreibung String? @db.VarChar(500)
beschreibung String? @db.VarChar(500)
vorher Json? @db.JsonB
nachher Json? @db.JsonB
@@ -428,15 +449,15 @@ model Datei {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bucket String @db.VarChar(80)
bucket String @db.VarChar(80)
/// Objektschlüssel in MinIO, z. B. "ff-musterhausen/geraet/<uuid>/foto.jpg".
objektSchluessel String @db.VarChar(500)
dateiname String @db.VarChar(300)
mimeTyp String @db.VarChar(160)
dateiname String @db.VarChar(300)
mimeTyp String @db.VarChar(160)
groesseBytes BigInt
/// SHA-256 des Inhalts. Erkennt Dubletten und belegt Unverändertheit.
pruefsumme String? @db.VarChar(64)
pruefsumme String? @db.VarChar(64)
/// Woran die Datei hängt (z. B. "Geraet") und an welchem Datensatz.
entitaet String? @db.VarChar(80)
@@ -444,15 +465,22 @@ model Datei {
/// Freie Einordnung: "pruefprotokoll", "foto", "rechnung", "export".
kategorie String? @db.VarChar(60)
hochgeladenVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
hochgeladenVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
/// Soft-Delete: das Objekt in MinIO räumt erst der Aufbewahrungs-Job ab.
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
alsAvatarGenutzt Person[] @relation("PersonAvatar")
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
alsAvatarGenutzt Person[] @relation("PersonAvatar")
// Phase 2 — woran eine Datei sonst noch hängen kann
alsPruefprotokoll Pruefung[] @relation("PruefungProtokoll")
alsFremdprotokoll Pruefung[] @relation("PruefungFremdprotokoll")
alsErgebnisFoto Pruefergebnis[] @relation("PruefergebnisFoto")
alsSignaturbild Signatur[] @relation("SignaturBild")
alsMangelFoto Mangel[] @relation("MangelFoto")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@ -688,6 +716,10 @@ model Geraetetyp {
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraete Geraet[]
// Phase 2
wartungsvorlagen Wartungsvorlage[]
rueckrufTypen RueckrufGeraetetyp[]
@@unique([organisationId, name])
@@index([organisationId, kategorie])
@@index([organisationId, aktiv])
@@ -746,6 +778,12 @@ model Geraet {
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
geraeteraum Geraeteraum? @relation(fields: [geraeteraumId], references: [id], onDelete: SetNull)
// Phase 2 — alles, was am Gerät geprüft, gemeldet und zurückgerufen wird
wartungstermine Wartungstermin[]
pruefungen Pruefung[]
maengel Mangel[]
rueckrufBetroffenheiten RueckrufBetroffenheit[]
@@unique([organisationId, inventarnummer])
@@index([organisationId, status])
@@index([organisationId, naechstePruefung])
@@ -826,3 +864,542 @@ model Qualifikation {
@@index([organisationId, gueltigBis])
@@map("qualifikation")
}
// =============================================================================
// Phase 2 — Wartungs- und Prüfmanagement
//
// Ordnungsprinzip:
// Wartungsvorlage was geprüft wird und in welchem Takt — je Gerätetyp
// Checklistenpunkt eine einzelne Frage der Prüfung
// Wartungstermin ein konkreter Prüftermin an einem konkreten Gerät
// Pruefung die durchgeführte Prüfung samt Ergebnis
// Pruefergebnis die Antwort auf einen Checklistenpunkt
// Signatur die Unterschrift unter das Protokoll
// Mangel was dabei auffiel und noch behoben werden muss
// Rueckruf eine Herstellerwarnung und die betroffenen Geräte
//
// Zwei Festlegungen, die den Aufbau erklären:
//
// 1. Ein abgeschlossenes Prüfprotokoll ist unveränderbar. Ein Trigger in der
// Datenbank lässt an einer Prüfung mit Status ABGESCHLOSSEN keine
// Änderung mehr zu — außer der Kennzeichnung als ERSETZT. Eine Korrektur
// ist eine neue Prüfung, die über ersetztPruefungId auf die alte zeigt.
//
// 2. "Überfällig" ist kein gespeicherter Zustand, sondern ergibt sich aus
// faelligAm und dem heutigen Tag. Gespeicherte Zustände laufen der Uhr
// hinterher; berechnete nicht.
// =============================================================================
/// Zustand eines Prüftermins. Überfälligkeit steht bewusst nicht hier drin.
enum WartungsterminStatus {
OFFEN
ERLEDIGT
/// Der Termin ist gegenstandslos geworden — Gerät ausgesondert, Vorlage
/// abgeschafft, Prüfpflicht entfallen.
ENTFALLEN
}
/// Lebenszyklus einer Prüfung.
enum PruefungStatus {
/// Der Wizard läuft noch. Nur in diesem Zustand ist Ändern erlaubt.
ENTWURF
/// Unterschrieben und festgeschrieben.
ABGESCHLOSSEN
/// Durch eine Korrekturprüfung abgelöst. Bleibt erhalten und lesbar.
ERSETZT
}
/// Ergebnis einer Prüfung nach DGUV-Logik.
enum PruefErgebnis {
BESTANDEN
BESTANDEN_MIT_MAENGELN
NICHT_BESTANDEN
}
/// Wer geprüft hat — die eigene Wehr oder eine Fachfirma.
enum PruefungArt {
INTERN
EXTERN
}
/// Art eines Checklistenpunkts. Bestimmt, welches Eingabefeld der Wizard zeigt.
enum ChecklistenpunktTyp {
JA_NEIN
MESSWERT
TEXT
FOTO
}
/// In welcher Eigenschaft jemand unterschreibt.
enum SignaturRolle {
PRUEFER
GERAETEWART
/// Bei externer Wartung: der Vertreter der Fachfirma.
FREMDFIRMA
}
/// Dringlichkeit eines Mangels.
enum MangelPrioritaet {
NIEDRIG
MITTEL
HOCH
/// Gerät ist nicht mehr einsatzbereit.
KRITISCH
}
/// Bearbeitungsstand eines Mangels: melden → zugewiesen → in Arbeit → behoben.
enum MangelStatus {
GEMELDET
ZUGEWIESEN
IN_ARBEIT
BEHOBEN
/// Kein Mangel — Fehlmeldung, bleibt aber nachvollziehbar stehen.
VERWORFEN
}
/// Bearbeitungsstand eines Rückrufs.
enum RueckrufStatus {
OFFEN
IN_BEARBEITUNG
ERLEDIGT
}
/// Stand je betroffenem Gerät innerhalb eines Rückrufs.
enum BetroffenheitStatus {
OFFEN
/// Geprüft und doch nicht betroffen — falsche Charge, anderes Baujahr.
NICHT_BETROFFEN
MASSNAHME_LAEUFT
ERLEDIGT
}
/// Vorwarnstufe einer Erinnerung. Je Termin und Stufe wird höchstens einmal
/// erinnert — der Eintrag ist gleichzeitig die Sperre gegen Doppelversand.
enum ErinnerungStufe {
T_30
T_7
T_0
UEBERFAELLIG
}
// -----------------------------------------------------------------------------
/// Was an einem Gerätetyp regelmäßig zu prüfen ist.
///
/// Die Vorlage ist die Regel, der Wartungstermin ihre Anwendung auf ein
/// einzelnes Gerät. Ändert sich die Vorlage, ändern sich künftige Termine —
/// bereits abgeschlossene Prüfungen bleiben unberührt, weil ihre Ergebnisse
/// samt Fragestellung erhalten bleiben.
model Wartungsvorlage {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(160)
/// Für welchen Gerätetyp sie gilt. Leer bedeutet: nur von Hand zuweisbar.
geraetetypId String? @db.Uuid
/// Prüfintervall in Monaten. Zählt ab der letzten durchgeführten Prüfung.
intervallMonate Int
/// Wie viele Tage vorher gewarnt und erinnert wird.
vorwarnzeitTage Int @default(30)
/// Fundstelle, z. B. "DGUV Vorschrift 49" oder "DIN 14800-16".
pruefvorschrift String? @db.VarChar(200)
/// Erfahrungswert für die Planung, in Minuten.
standardDauerMinuten Int?
/// Vorbelegung: diese Prüfung macht üblicherweise eine Fachfirma.
standardExtern Boolean @default(false)
/// Der nächtliche Lauf erzeugt Termine für alle Geräte dieses Typs.
automatisch Boolean @default(true)
beschreibung String? @db.VarChar(1000)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
punkte Checklistenpunkt[]
termine Wartungstermin[]
pruefungen Pruefung[]
@@unique([organisationId, name])
@@index([organisationId, geraetetypId])
@@index([organisationId, aktiv])
@@map("wartungsvorlage")
}
/// Eine einzelne Frage der Checkliste.
model Checklistenpunkt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
vorlageId String @db.Uuid
text String @db.VarChar(300)
typ ChecklistenpunktTyp @default(JA_NEIN)
/// Pflichtpunkte müssen beantwortet sein, bevor sich die Prüfung
/// abschließen lässt.
pflicht Boolean @default(true)
/// Sollwert als Klartext für die Anzeige, z. B. "mindestens 270 bar".
sollwert String? @db.VarChar(120)
/// Einheit eines Messwerts, z. B. "bar", "mbar", "V", "kN".
einheit String? @db.VarChar(20)
/// Zulässiger Bereich eines Messwerts. Außerhalb davon gilt der Punkt als
/// nicht in Ordnung — gerechnet wird das im Server, nicht im Browser.
minWert Decimal? @db.Decimal(12, 3)
maxWert Decimal? @db.Decimal(12, 3)
hinweis String? @db.VarChar(500)
sortierung Int @default(0)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
vorlage Wartungsvorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade)
ergebnisse Pruefergebnis[]
@@index([organisationId, vorlageId])
@@map("checklistenpunkt")
}
/// Ein fälliger Prüftermin an einem einzelnen Gerät.
///
/// Termine entstehen entweder im nächtlichen Lauf aus den Vorlagen oder von
/// Hand. Die Eindeutigkeit über Gerät, Vorlage und Fälligkeitstag verhindert,
/// dass ein zweiter Lauf denselben Termin noch einmal anlegt.
model Wartungstermin {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
geraetId String @db.Uuid
vorlageId String? @db.Uuid
faelligAm DateTime @db.Date
status WartungsterminStatus @default(OFFEN)
/// true, wenn der nächtliche Lauf den Termin erzeugt hat.
automatischErzeugt Boolean @default(false)
/// Wer den Termin übernehmen soll.
zugewiesenAnId String? @db.Uuid
bemerkung String? @db.VarChar(1000)
erledigtAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
zugewiesenAn Person? @relation("TerminZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull)
pruefungen Pruefung[]
erinnerungen Erinnerung[]
/// Doppelte Termine aus wiederholten Läufen ausschließen. Für Termine ohne
/// Vorlage greift die Bedingung nicht — Postgres wertet NULL als
/// unterschiedlich —, die legt aber ohnehin nur ein Mensch an.
@@unique([geraetId, vorlageId, faelligAm])
@@index([organisationId, faelligAm])
@@index([organisationId, status, faelligAm])
@@index([organisationId, geraetId])
@@index([organisationId, zugewiesenAnId])
@@map("wartungstermin")
}
/// Eine durchgeführte Prüfung.
///
/// Ist der Status ABGESCHLOSSEN, ist der Datensatz festgeschrieben: die
/// Datenbank weist jede weitere Änderung ab. Was korrigiert werden muss, wird
/// als neue Prüfung erfasst, die über ersetztPruefungId auf die alte zeigt.
model Pruefung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
geraetId String @db.Uuid
terminId String? @db.Uuid
vorlageId String? @db.Uuid
art PruefungArt @default(INTERN)
status PruefungStatus @default(ENTWURF)
prueferId String? @db.Uuid
/// Klarname des Prüfers zum Zeitpunkt der Prüfung. Bleibt lesbar, auch wenn
/// die Person später austritt und anonymisiert wird — die Nachweispflicht
/// gegenüber der Unfallkasse verlangt einen benennbaren Prüfer.
prueferName String? @db.VarChar(200)
durchgefuehrtAm DateTime @db.Date
/// Beginn der Zeiterfassung im Wizard.
begonnenUm DateTime? @db.Timestamptz(3)
dauerMinuten Int?
ergebnis PruefErgebnis?
/// Nur bei art = EXTERN: wer geprüft hat, was es gekostet hat und das
/// Protokoll der Fachfirma als PDF.
externeFirma String? @db.VarChar(200)
externeProtokollDateiId String? @db.Uuid
kosten Decimal? @db.Decimal(10, 2)
/// Nächster Termin laut dieser Prüfung. Kann vom Intervall abweichen, wenn
/// der Prüfer eine kürzere Frist ansetzt.
naechstePruefungAm DateTime? @db.Date
/// Das erzeugte Protokoll-PDF in MinIO und seine laufende Nummer.
protokollDateiId String? @db.Uuid
protokollNummer String? @db.VarChar(40)
abgeschlossenAm DateTime? @db.Timestamptz(3)
/// Korrektur: diese Prüfung ersetzt eine frühere.
ersetztPruefungId String? @db.Uuid
korrekturGrund String? @db.VarChar(500)
bemerkung String? @db.VarChar(2000)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
termin Wartungstermin? @relation(fields: [terminId], references: [id], onDelete: SetNull)
vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
pruefer Person? @relation("PruefungPruefer", fields: [prueferId], references: [id], onDelete: SetNull)
protokoll Datei? @relation("PruefungProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull)
externesProtokoll Datei? @relation("PruefungFremdprotokoll", fields: [externeProtokollDateiId], references: [id], onDelete: SetNull)
ersetzt Pruefung? @relation("PruefungKorrektur", fields: [ersetztPruefungId], references: [id], onDelete: SetNull)
ersetztDurch Pruefung[] @relation("PruefungKorrektur")
ergebnisse Pruefergebnis[]
signaturen Signatur[]
maengel Mangel[]
@@unique([organisationId, protokollNummer])
@@index([organisationId, geraetId, durchgefuehrtAm(sort: Desc)])
@@index([organisationId, status])
@@index([organisationId, durchgefuehrtAm(sort: Desc)])
@@index([organisationId, prueferId])
@@map("pruefung")
}
/// Die Antwort auf einen Checklistenpunkt.
model Pruefergebnis {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
pruefungId String @db.Uuid
punktId String @db.Uuid
/// Je nach Typ des Punktes ist eines der drei Felder belegt.
jaNein Boolean?
zahlwert Decimal? @db.Decimal(12, 3)
text String? @db.VarChar(1000)
fotoDateiId String? @db.Uuid
/// Ob der Punkt in Ordnung ist. Bei Messwerten rechnet der Server das aus
/// dem zulässigen Bereich aus, sonst folgt es der Antwort.
inOrdnung Boolean @default(true)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade)
punkt Checklistenpunkt @relation(fields: [punktId], references: [id], onDelete: Restrict)
foto Datei? @relation("PruefergebnisFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
@@unique([pruefungId, punktId])
@@index([organisationId, pruefungId])
@@map("pruefergebnis")
}
/// Unterschrift unter ein Prüfprotokoll. Das Bild ist ein PNG aus dem
/// Zeichenfeld und liegt wie alles andere in MinIO.
model Signatur {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
pruefungId String @db.Uuid
rolle SignaturRolle @default(PRUEFER)
personId String? @db.Uuid
/// Klarname, so wie er im Protokoll steht.
name String @db.VarChar(200)
bildDateiId String? @db.Uuid
unterzeichnetAm DateTime @default(now()) @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade)
person Person? @relation("SignaturUnterzeichner", fields: [personId], references: [id], onDelete: SetNull)
bild Datei? @relation("SignaturBild", fields: [bildDateiId], references: [id], onDelete: SetNull)
@@index([organisationId, pruefungId])
@@map("signatur")
}
/// Ein Mangel an einem Gerät — gemeldet aus einer Prüfung heraus oder einzeln.
model Mangel {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
geraetId String @db.Uuid
/// Gesetzt, wenn der Mangel bei einer Prüfung aufgefallen ist.
pruefungId String? @db.Uuid
melderId String? @db.Uuid
melderName String? @db.VarChar(200)
beschreibung String @db.VarChar(2000)
fotoDateiId String? @db.Uuid
prioritaet MangelPrioritaet @default(MITTEL)
status MangelStatus @default(GEMELDET)
zugewiesenAnId String? @db.Uuid
gemeldetAm DateTime @default(now()) @db.Timestamptz(3)
behobenAm DateTime? @db.Timestamptz(3)
behobenVonId String? @db.Uuid
behebung String? @db.VarChar(2000)
/// Beim Melden wurde das Gerät gesperrt. Merkt sich, dass die Sperre zu
/// diesem Mangel gehört — beim Beheben wird sie wieder aufgehoben.
geraetGesperrt Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
pruefung Pruefung? @relation(fields: [pruefungId], references: [id], onDelete: SetNull)
melder Person? @relation("MangelMelder", fields: [melderId], references: [id], onDelete: SetNull)
zugewiesenAn Person? @relation("MangelZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull)
behobenVon Person? @relation("MangelBehebung", fields: [behobenVonId], references: [id], onDelete: SetNull)
foto Datei? @relation("MangelFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
@@index([organisationId, status, prioritaet])
@@index([organisationId, geraetId])
@@index([organisationId, zugewiesenAnId])
@@map("mangel")
}
/// Eine Herstellerwarnung. Betroffene Geräte werden gesucht, markiert und auf
/// Wunsch gesperrt.
model Rueckruf {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
hersteller String @db.VarChar(160)
titel String @db.VarChar(200)
/// Nummer der Bekanntmachung des Herstellers.
referenz String? @db.VarChar(80)
beschreibung String? @db.VarChar(4000)
massnahme String? @db.VarChar(2000)
veroeffentlichtAm DateTime? @db.Date
/// Fundstelle, z. B. die Adresse der Herstellermitteilung.
quelle String? @db.VarChar(500)
/// Betroffene Geräte beim Zuordnen automatisch sperren.
sperrtGeraete Boolean @default(false)
status RueckrufStatus @default(OFFEN)
erledigtAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
typen RueckrufGeraetetyp[]
betroffenheiten RueckrufBetroffenheit[]
@@index([organisationId, status])
@@index([organisationId, hersteller])
@@map("rueckruf")
}
/// Welche Bauart und welche Charge ein Rückruf betrifft.
model RueckrufGeraetetyp {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rueckrufId String @db.Uuid
geraetetypId String? @db.Uuid
/// Freitext, wenn die betroffene Bauart nicht als Gerätetyp erfasst ist.
modell String? @db.VarChar(160)
/// Eingrenzung der betroffenen Stücke. Alle Angaben sind freiwillig; was
/// leer bleibt, schränkt nicht ein.
seriennummerVon String? @db.VarChar(120)
seriennummerBis String? @db.VarChar(120)
herstellungVon DateTime? @db.Date
herstellungBis DateTime? @db.Date
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade)
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
@@index([organisationId, rueckrufId])
@@map("rueckruf_geraetetyp")
}
/// Ein einzelnes Gerät innerhalb eines Rückrufs.
model RueckrufBetroffenheit {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rueckrufId String @db.Uuid
geraetId String @db.Uuid
status BetroffenheitStatus @default(OFFEN)
festgestelltAm DateTime @default(now()) @db.Timestamptz(3)
erledigtAm DateTime? @db.Timestamptz(3)
bemerkung String? @db.VarChar(1000)
/// Der Rückruf hat dieses Gerät gesperrt — beim Erledigen wird die Sperre
/// wieder aufgehoben.
geraetGesperrt Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade)
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
@@unique([rueckrufId, geraetId])
@@index([organisationId, rueckrufId])
@@index([organisationId, geraetId])
@@map("rueckruf_betroffenheit")
}
/// Nachweis einer versendeten Erinnerung. Der Eintrag ist gleichzeitig die
/// Sperre gegen Doppelversand: je Termin und Stufe gibt es ihn nur einmal.
model Erinnerung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
terminId String @db.Uuid
stufe ErinnerungStufe
versendetAm DateTime @default(now()) @db.Timestamptz(3)
/// Kanal laut lib/integrations — "mail", "telegram", "protokoll".
kanal String @default("mail") @db.VarChar(40)
empfaenger String[] @default([])
erfolgreich Boolean @default(true)
meldung String? @db.VarChar(500)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
termin Wartungstermin @relation(fields: [terminId], references: [id], onDelete: Cascade)
@@unique([terminId, stufe])
@@index([organisationId, versendetAm(sort: Desc)])
@@map("erinnerung")
}
+427 -1
View File
@@ -5,7 +5,9 @@
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
* Mannschaft.
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
* Beispielrückruf.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -19,15 +21,22 @@
import "dotenv/config";
import {
AusweisTokenTyp,
ChecklistenpunktTyp,
Dienstgrad,
FahrzeugArt,
GeraeteKategorie,
GeraetStatus,
LebensdauerBezug,
LebensdauerKategorie,
MangelPrioritaet,
MangelStatus,
PrismaClient,
PruefErgebnis,
PruefungArt,
PruefungStatus,
QualifikationsArt,
RolleSchluessel,
SignaturRolle,
StandortArt,
} from "@prisma/client";
import bcrypt from "bcryptjs";
@@ -1092,6 +1101,417 @@ async function qualifikationenAnlegen(organisationId: string, typen: Map<string,
log(`Qualifikationen: ${angelegt}`);
}
// -----------------------------------------------------------------------------
// Phase 2 — Wartungsvorlagen, Termine, ein Beispielprotokoll
//
// Die Vorlagen sind bewusst echt: Intervalle und Vorschriften stammen aus den
// gängigen Regelwerken. Eine Beispielwehr, in der jede Prüfung "Prüfung 1"
// heißt, zeigt nicht, worum es geht.
// -----------------------------------------------------------------------------
interface PunktVorlage {
text: string;
typ: ChecklistenpunktTyp;
pflicht?: boolean;
sollwert?: string;
einheit?: string;
minWert?: number;
maxWert?: number;
hinweis?: string;
}
interface WartungsVorlage {
name: string;
geraetetyp: string;
intervallMonate: number;
vorwarnzeitTage?: number;
pruefvorschrift?: string;
standardDauerMinuten?: number;
punkte: PunktVorlage[];
}
const WARTUNGSVORLAGEN: WartungsVorlage[] = [
{
name: "Pressluftatmer — Jahresprüfung",
geraetetyp: "Pressluftatmer PA 94",
intervallMonate: 12,
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
standardDauerMinuten: 25,
punkte: [
{ text: "Trageeinrichtung auf Beschädigung und Vollständigkeit prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Bänderung auf Risse, Schnitte und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Hochdruckdichtheit: Druckabfall in einer Minute",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 10 bar in einer Minute",
einheit: "bar",
minWert: 0,
maxWert: 10,
},
{
text: "Ansprechdruck der Warneinrichtung",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "50 bis 60 bar",
einheit: "bar",
minWert: 50,
maxWert: 60,
},
{
text: "Lungenautomat: Öffnungsdruck",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "bis 3,5 mbar",
einheit: "mbar",
maxWert: 3.5,
},
{ text: "Auffälligkeiten", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
],
},
{
name: "Atemanschluss — Halbjahresprüfung",
geraetetyp: "Atemschutzmaske FPS 7000",
intervallMonate: 6,
pruefvorschrift: "DGUV Regel 112-190",
standardDauerMinuten: 15,
punkte: [
{ text: "Maskenkörper auf Risse, Verformung und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Sichtscheibe frei von Kratzern im Sichtfeld", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Ausatemventil auf Dichtheit und Sitz prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Dichtprüfung: Druckabfall in einer Minute",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 0,5 mbar",
einheit: "mbar",
minWert: 0,
maxWert: 0.5,
},
{ text: "Foto der Maske nach der Reinigung", typ: ChecklistenpunktTyp.FOTO, pflicht: false },
],
},
{
name: "Feuerwehrleine — Jahresprüfung",
geraetetyp: "Feuerwehrleine 30 m",
intervallMonate: 12,
pruefvorschrift: "DGUV Information 205-014, DIN 14920",
standardDauerMinuten: 10,
punkte: [
{ text: "Leine über die gesamte Länge durch die Hand ziehen, Beschädigungen prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Kennzeichnung und Herstellungsjahr lesbar", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Leinenbeutel unbeschädigt und trocken", typ: ChecklistenpunktTyp.JA_NEIN, pflicht: false },
{ text: "Befund", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
],
},
{
name: "Druckschlauch — Druckprüfung",
geraetetyp: "B-Druckschlauch 20 m",
intervallMonate: 12,
pruefvorschrift: "DIN 14811",
standardDauerMinuten: 8,
punkte: [
{
text: "Prüfdruck halten",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "mindestens 8 bar über 5 Minuten",
einheit: "bar",
minWert: 8,
},
{ text: "Kupplungen fest, Dichtringe vorhanden", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Schlauchgewebe ohne Scheuerstellen und Löcher", typ: ChecklistenpunktTyp.JA_NEIN },
],
},
{
name: "Elektrische Betriebsmittel — DGUV-V3-Prüfung",
geraetetyp: "Stromerzeuger 8 kVA",
intervallMonate: 12,
pruefvorschrift: "DGUV Vorschrift 3, DIN VDE 0701-0702",
standardDauerMinuten: 30,
punkte: [
{ text: "Sichtprüfung Gehäuse, Leitungen und Steckvorrichtungen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Schutzleiterwiderstand",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 0,3 Ohm",
einheit: "Ohm",
minWert: 0,
maxWert: 0.3,
},
{
text: "Isolationswiderstand",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "mindestens 1 Megaohm",
einheit: "MOhm",
minWert: 1,
},
{ text: "Funktionsprüfung unter Last", typ: ChecklistenpunktTyp.JA_NEIN },
],
},
];
async function wartungsvorlagenAnlegen(organisationId: string, typen: Map<string, string>) {
const karte = new Map<string, { id: string; intervallMonate: number; punkte: string[] }>();
for (const vorlage of WARTUNGSVORLAGEN) {
const geraetetypId = typen.get(vorlage.geraetetyp) ?? null;
const angelegt = await prisma.wartungsvorlage.upsert({
where: { organisationId_name: { organisationId, name: vorlage.name } },
update: {},
create: {
organisationId,
name: vorlage.name,
geraetetypId,
intervallMonate: vorlage.intervallMonate,
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 30,
pruefvorschrift: vorlage.pruefvorschrift ?? null,
standardDauerMinuten: vorlage.standardDauerMinuten ?? null,
},
});
const punktIds: string[] = [];
const vorhandene = await prisma.checklistenpunkt.count({ where: { vorlageId: angelegt.id } });
if (vorhandene === 0) {
for (const [index, punkt] of vorlage.punkte.entries()) {
const erzeugt = await prisma.checklistenpunkt.create({
data: {
organisationId,
vorlageId: angelegt.id,
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht ?? true,
sollwert: punkt.sollwert ?? null,
einheit: punkt.einheit ?? null,
minWert: punkt.minWert ?? null,
maxWert: punkt.maxWert ?? null,
hinweis: punkt.hinweis ?? null,
sortierung: (index + 1) * 10,
},
});
punktIds.push(erzeugt.id);
}
} else {
const bestand = await prisma.checklistenpunkt.findMany({
where: { vorlageId: angelegt.id },
orderBy: { sortierung: "asc" },
select: { id: true },
});
punktIds.push(...bestand.map((punkt) => punkt.id));
}
karte.set(vorlage.name, {
id: angelegt.id,
intervallMonate: vorlage.intervallMonate,
punkte: punktIds,
});
}
log(`Wartungsvorlagen: ${karte.size}`);
return karte;
}
/**
* Termine für alle Geräte der jeweiligen Bauart.
*
* Das ist dieselbe Rechnung, die der nächtliche Lauf anstellt — hier nur ohne
* Worker, damit eine frisch aufgesetzte Wehr sofort ein gefülltes
* Fristen-Dashboard hat.
*/
async function wartungstermineAnlegen(
organisationId: string,
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
) {
let angelegt = 0;
for (const vorlage of WARTUNGSVORLAGEN) {
const eintrag = vorlagen.get(vorlage.name);
if (!eintrag) continue;
const geraete = await prisma.geraet.findMany({
where: {
organisationId,
geloeschtAm: null,
geraetetyp: { name: vorlage.geraetetyp },
},
select: { id: true, letztePruefung: true, indienststellung: true },
});
for (const geraet of geraete) {
const basis = geraet.letztePruefung ?? geraet.indienststellung;
if (!basis) continue;
const faelligAm = plusMonate(basis, vorlage.intervallMonate);
const ergebnis = await prisma.wartungstermin.createMany({
data: [
{
organisationId,
geraetId: geraet.id,
vorlageId: eintrag.id,
faelligAm,
automatischErzeugt: true,
},
],
skipDuplicates: true,
});
angelegt += ergebnis.count;
}
}
log(`Wartungstermine: ${angelegt}`);
}
/**
* Ein abgeschlossenes Protokoll samt Mangel.
*
* Ohne ein Beispiel bleibt die Protokollansicht leer, und niemand sieht, wie
* ein fertiger Nachweis aussieht. Das PDF fehlt bewusst: es entsteht im
* Worker, sobald einer läuft.
*/
async function beispielPruefungAnlegen(
organisationId: string,
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
) {
const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung");
if (!vorlage) return;
const vorhanden = await prisma.pruefung.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Beispielprüfung: bereits vorhanden");
return;
}
const geraet = await prisma.geraet.findFirst({
where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null },
select: { id: true, inventarnummer: true },
});
if (!geraet) return;
const pruefer = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } },
},
select: { id: true, vorname: true, nachname: true },
});
const geprueftAm = plusMonate(new Date(), -2);
const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart";
const punkte = await prisma.checklistenpunkt.findMany({
where: { vorlageId: vorlage.id },
orderBy: { sortierung: "asc" },
});
// Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der
// Trigger aus Migration 20260903000300 lässt an einer bereits
// abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und
// das ist auch für den Seed richtig so.
const pruefung = await prisma.pruefung.create({
data: {
organisationId,
geraetId: geraet.id,
vorlageId: vorlage.id,
art: PruefungArt.INTERN,
status: PruefungStatus.ENTWURF,
ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN,
prueferId: pruefer?.id ?? null,
prueferName,
durchgefuehrtAm: geprueftAm,
begonnenUm: geprueftAm,
dauerMinuten: 12,
naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate),
protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`,
abgeschlossenAm: geprueftAm,
bemerkung:
"Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.",
ergebnisse: {
create: punkte.map((punkt, index) => ({
organisationId,
punktId: punkt.id,
jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null,
text:
punkt.typ === ChecklistenpunktTyp.TEXT
? "Leine selbst ohne Befund, Beutel beschädigt."
: null,
inOrdnung: index !== 2,
bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null,
})),
},
signaturen: {
create: [
{
organisationId,
rolle: SignaturRolle.PRUEFER,
personId: pruefer?.id ?? null,
name: prueferName,
unterzeichnetAm: geprueftAm,
},
],
},
},
});
await prisma.pruefung.update({
where: { id: pruefung.id },
data: { status: PruefungStatus.ABGESCHLOSSEN },
});
await prisma.mangel.create({
data: {
organisationId,
geraetId: geraet.id,
pruefungId: pruefung.id,
melderId: pruefer?.id ?? null,
melderName: prueferName,
beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.",
prioritaet: MangelPrioritaet.NIEDRIG,
status: MangelStatus.ZUGEWIESEN,
zugewiesenAnId: pruefer?.id ?? null,
},
});
log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`);
}
/** Ein Rückruf, damit die Ansicht nicht leer ist. */
async function rueckrufAnlegen(organisationId: string) {
const vorhanden = await prisma.rueckruf.count({ where: { organisationId } });
if (vorhanden > 0) return;
const typ = await prisma.geraetetyp.findFirst({
where: { organisationId, name: "Atemschutzmaske FPS 7000" },
select: { id: true },
});
await prisma.rueckruf.create({
data: {
organisationId,
hersteller: "Dräger",
titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000",
referenz: "SI-2026-03",
beschreibung:
"Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. "
+ "Betroffen sind Masken mit Herstellung vor 2020.",
massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.",
veroeffentlichtAm: plusMonate(new Date(), -1),
sperrtGeraete: false,
typen: typ
? {
create: [
{
organisationId,
geraetetypId: typ.id,
herstellungBis: datum(2019, 12, 31),
},
],
}
: undefined,
},
});
log("Rückruf: 1 Beispiel");
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -1113,6 +1533,12 @@ async function main(): Promise<void> {
const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
await qualifikationenAnlegen(organisation.id, qualiTypen);
// Phase 2 — Wartung und Prüfung
const wartungsvorlagen = await wartungsvorlagenAnlegen(organisation.id, typen);
await wartungstermineAnlegen(organisation.id, wartungsvorlagen);
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
await rueckrufAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");