Files
FireAdministration/prisma/schema.prisma
T
Florian WildeandClaude Opus 5 e503aafc18 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>
2026-09-03 12:07:11 +02:00

1406 lines
51 KiB
Plaintext

// =============================================================================
// FLORIAN — Datenmodell, Phase 0 (Fundament)
//
// Grundregeln, die für JEDES Modell gelten:
// 1. Mandantentrennung: jede Tabelle trägt organisationId. Postgres Row Level
// Security setzt das durch (siehe migrations/.../rls). Ausnahme ist der
// systemweite Berechtigungskatalog, siehe Kommentar dort.
// 2. Jede schreibende Operation erzeugt einen AuditLog-Eintrag (lib/audit.ts).
// 3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht:
// geloeschtAm setzen statt DELETE.
// 4. Alle Zeitstempel in UTC speichern, Anzeige in Europe/Berlin.
//
// Bezeichner sind englisch, wo es Technik ist, und deutsch, wo es Fachlichkeit
// ist — so wie es in der Wehr auch gesprochen wird.
// =============================================================================
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL")
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")]
}
// =============================================================================
// Aufzählungen
// =============================================================================
/// Rollen im Sinne der Feuerwehrorganisation. Die Berechtigungen dahinter
/// stehen in lib/authz.ts und werden beim Seed in die Datenbank gespiegelt.
enum RolleSchluessel {
SUPERADMIN
KOMMANDANT
GERAETEWART
ATEMSCHUTZGERAETEWART
KASSIER
SCHRIFTFUEHRER
AUSBILDER
MITGLIED
KIOSK
}
/// Art des Ausweises, mit dem sich jemand am Terminal im Gerätehaus anmeldet.
enum AusweisTokenTyp {
PIN
RFID
}
/// Wie sich jemand angemeldet hat — landet im Audit-Log.
enum AnmeldeArt {
LOKAL
OIDC
KIOSK_PIN
KIOSK_RFID
SYSTEM
}
/// Aktionen im Audit-Log.
enum AuditAktion {
ERSTELLT
GEAENDERT
GELOESCHT
WIEDERHERGESTELLT
ANGEMELDET
ABGEMELDET
ANMELDUNG_FEHLGESCHLAGEN
EXPORTIERT
DATEI_HOCHGELADEN
DATEI_ABGERUFEN
BERECHTIGUNG_GEAENDERT
}
/// Wer die Aktion ausgelöst hat.
enum AkteurTyp {
PERSON
SYSTEM
WORKER
KIOSK
API
}
/// Betriebsstatus eines Geräts nach DGUV-Logik.
enum GeraetStatus {
EINSATZBEREIT
PRUEFUNG_FAELLIG
GESPERRT
AUSGESONDERT
IN_REPARATUR
}
/// Grobe Fahrzeugkategorie. Die vollständige DIN-Typisierung kommt in Phase 1.
enum FahrzeugArt {
LOESCHFAHRZEUG
DREHLEITER
RUESTWAGEN
GERAETEWAGEN
MANNSCHAFTSTRANSPORT
EINSATZLEITWAGEN
WECHSELLADER
ANHAENGER
SONSTIGES
}
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl).
enum Dienstgrad {
FEUERWEHRANWAERTER
FEUERWEHRMANN
OBERFEUERWEHRMANN
HAUPTFEUERWEHRMANN
LOESCHMEISTER
OBERLOESCHMEISTER
HAUPTLOESCHMEISTER
BRANDMEISTER
OBERBRANDMEISTER
HAUPTBRANDMEISTER
KEIN
}
// =============================================================================
// Mandant
// =============================================================================
/// Eine Feuerwehr. Oberste Klammer — an ihr hängt alles andere.
model Organisation {
id String @id @default(uuid()) @db.Uuid
/// Kurzform für URLs und Konfiguration, z. B. "ff-musterhausen".
slug String @unique @db.VarChar(64)
name String @db.VarChar(200)
kurzname String @db.VarChar(60)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
landkreis String? @db.VarChar(120)
bundesland String @default("Bayern") @db.VarChar(60)
telefon String? @db.VarChar(40)
email String? @db.VarChar(200)
/// Kennung im Einsatzleitsystem der Integrierten Leitstelle.
ilsKennung String? @db.VarChar(40)
/// Funkrufname-Präfix der Wehr, z. B. "Florian Musterhausen".
funkPraefix String? @db.VarChar(80)
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[]
qualifikationstypen Qualifikationstyp[]
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")
}
// =============================================================================
// Personen und Rechte
// =============================================================================
/// Ein Mitglied der Wehr. Gleichzeitig der Anmeldebenutzer — es gibt bewusst
/// keine getrennte "User"-Tabelle, damit Dienstgrad, Qualifikation und Zugang
/// nicht auseinanderlaufen.
model Person {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
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)
email String? @db.VarChar(200)
telefon String? @db.VarChar(40)
mobil String? @db.VarChar(40)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
eintrittsdatum DateTime? @db.Date
austrittsdatum DateTime? @db.Date
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
abteilung String? @db.VarChar(60)
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
passwortHash String? @db.VarChar(120)
passwortGeaendertAm DateTime? @db.Timestamptz(3)
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto.
oidcSubject String? @db.VarChar(255)
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
oidcGruppen String[] @default([])
letzteAnmeldung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
aktiv Boolean @default(true)
/// Profilbild. Liegt wie alles andere in MinIO.
avatarDateiId String? @db.Uuid
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)
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])
@@unique([organisationId, oidcSubject])
@@unique([organisationId, mitgliedsnummer])
@@index([organisationId, nachname, vorname])
@@index([organisationId, aktiv])
@@index([oidcSubject])
@@map("person")
}
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
/// Rollen kann der Kommandant ergänzen.
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)
/// Systemrollen dürfen nicht gelöscht werden.
systemRolle 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)
personen PersonRolle[]
rechte RolleBerechtigung[]
@@unique([organisationId, schluessel])
@@index([organisationId])
@@map("rolle")
}
/// Zuweisung Person zu Rolle. Eine Person kann mehrere Rollen tragen — der
/// Gerätewart ist meist auch aktives Mitglied.
model PersonRolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
rolleId 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)
/// 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)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("PersonRollenInhaber", fields: [personId], references: [id], onDelete: Cascade)
rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade)
zugewiesenVon Person? @relation("PersonRollenVergeber", fields: [zugewiesenVonId], references: [id], onDelete: SetNull)
@@unique([personId, rolleId])
@@index([organisationId, personId])
@@index([organisationId, rolleId])
@@map("person_rolle")
}
/// Systemweiter Katalog der Einzelrechte, z. B. "geraet.bearbeiten".
///
/// BEWUSSTE AUSNAHME von der organisationId-Regel: das hier sind keine
/// Mandantendaten, sondern eine Liste dessen, was die Software überhaupt kann.
/// Der Katalog wird vom Seed aus lib/authz.ts befüllt und ist für alle Wehren
/// identisch. Mandantenbezogen wird es erst durch RolleBerechtigung, und die
/// 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)
/// Fachbereich für die Gruppierung in der Oberfläche.
bereich String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
beschreibung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
rollen RolleBerechtigung[]
@@index([bereich])
@@map("berechtigung")
}
/// Welche Rolle welches Recht hat — je Wehr getrennt, damit eine Wehr dem
/// Gerätewart mehr erlauben kann als eine andere.
model RolleBerechtigung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rolleId String @db.Uuid
berechtigungId String @db.Uuid
/// false setzt ein Recht gezielt ab, das die Rolle sonst hätte.
gewaehrt Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade)
berechtigung Berechtigung @relation(fields: [berechtigungId], references: [id], onDelete: Cascade)
@@unique([rolleId, berechtigungId])
@@index([organisationId, rolleId])
@@map("rolle_berechtigung")
}
/// Anmeldemittel für das Terminal im Gerätehaus: PIN oder RFID-Karte.
/// Gespeichert wird immer nur ein Hash, nie die PIN oder die UID im Klartext.
model AusweisToken {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
typ AusweisTokenTyp
/// bcrypt-Hash der PIN bzw. der RFID-UID.
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)
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)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
/// Je Person und Art höchstens ein aktives Mittel dieser Bezeichnung.
@@index([organisationId, personId])
@@index([organisationId, typ, aktiv])
@@map("ausweis_token")
}
// =============================================================================
// Nachvollziehbarkeit
// =============================================================================
/// Append-only. Es gibt keine UPDATE- oder DELETE-Policy für die Anwendung;
/// zusätzlich verhindert ein Trigger Änderungen auch für den Eigentümer.
model AuditLog {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
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)
anmeldeArt AnmeldeArt?
aktion AuditAktion
/// Modellname, z. B. "Geraet".
entitaet String @db.VarChar(80)
entitaetId String? @db.Uuid
/// Menschenlesbare Kurzfassung für die Oberfläche.
beschreibung String? @db.VarChar(500)
vorher Json? @db.JsonB
nachher Json? @db.JsonB
/// Zusatzangaben: Anfrage-ID, Endpunkt, betroffene Felder.
kontext Json? @db.JsonB
ipAdresse String? @db.Inet
userAgent String? @db.VarChar(400)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
akteur Person? @relation(fields: [akteurPersonId], references: [id], onDelete: SetNull)
@@index([organisationId, zeitpunkt(sort: Desc)])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, akteurPersonId])
@@map("audit_log")
}
// =============================================================================
// Dateien
// =============================================================================
/// Metadaten zu einem Objekt in MinIO. Der Inhalt liegt NIE im Dateisystem und
/// nie in der Datenbank — hier steht nur, wo er zu finden ist.
model Datei {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
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)
groesseBytes BigInt
/// SHA-256 des Inhalts. Erkennt Dubletten und belegt Unverändertheit.
pruefsumme String? @db.VarChar(64)
/// Woran die Datei hängt (z. B. "Geraet") und an welchem Datensatz.
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
/// 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)
/// 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")
// 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])
@@index([organisationId, kategorie])
@@map("datei")
}
// =============================================================================
// Phase 1 — Stammdaten
//
// Ordnungsprinzip:
// Standort wo etwas steht (Gerätehaus, Lager, Außenstelle)
// Fahrzeug ein Einsatzfahrzeug, es hat Geräteräume
// Geraeteraum ein Fach am Fahrzeug (G1 … G6, Dachkasten, Mannschaftsraum)
// Geraetetyp die Bauart samt Prüfvorgabe — "Pressluftatmer PA 94"
// Geraet das einzelne Stück mit Inventarnummer
//
// Ein Gerät liegt ENTWEDER an einem Standort ODER verlastet in einem
// Geräteraum eines Fahrzeugs. Beides gleichzeitig oder gar nichts wäre eine
// Falschangabe; eine Prüfbedingung in der Datenbank verhindert das.
// =============================================================================
/// Art eines Standorts.
enum StandortArt {
GERAETEHAUS
LAGER
AUSSENSTELLE
WERKSTATT
SONSTIGES
}
/// Fachliche Einordnung eines Gerätetyps — bestimmt die Gruppierung in Listen.
enum GeraeteKategorie {
ATEMSCHUTZ
BELEUCHTUNG
BRANDBEKAEMPFUNG
ELEKTRO
FUNK
HYGIENE
MEDIZIN
MESSTECHNIK
PERSOENLICHE_SCHUTZAUSRUESTUNG
RETTUNG
SCHLAUCHMATERIAL
TECHNISCHE_HILFELEISTUNG
UMWELTSCHUTZ
WASSERFOERDERUNG
SONSTIGES
}
/// Lebensdauerkategorie. Diese Gruppen haben eine maximale Nutzungsdauer ab
/// Herstellungs- oder Indienststellungsdatum, unabhängig vom Zustand.
enum LebensdauerKategorie {
KEINE
HELM
SCHUTZKLEIDUNG
HANDSCHUHE
STIEFEL
SEIL_GURT
PRESSLUFTATMER
ATEMANSCHLUSS
DRUCKBEHAELTER
SCHLAUCH
LEITER
SONSTIGE
}
/// Woran sich die maximale Nutzungsdauer bemisst.
enum LebensdauerBezug {
HERSTELLUNG
INDIENSTSTELLUNG
}
/// Art einer Qualifikation.
enum QualifikationsArt {
LEHRGANG
BEFAEHIGUNG
UNTERSUCHUNG
FUEHRERSCHEIN
UNTERWEISUNG
SONSTIGES
}
// -----------------------------------------------------------------------------
/// Ein Ort, an dem Fahrzeuge stehen und Geräte lagern.
model Standort {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(160)
/// Kurzzeichen für Listen und Etiketten, z. B. "GH" oder "LAG".
kuerzel String? @db.VarChar(20)
art StandortArt @default(GERAETEHAUS)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
/// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht,
/// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage.
/// Ab Phase 6 zeigt die Karte diese Punkte an.
koordinaten Unsupported("geography(Point, 4326)")?
bemerkung 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)
fahrzeuge Fahrzeug[]
geraete Geraet[]
@@unique([organisationId, name])
@@index([organisationId, aktiv])
@@map("standort")
}
/// Ein Einsatzfahrzeug.
model Fahrzeug {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Funkrufname, z. B. "Florian Musterhausen 40/1".
funkrufname String @db.VarChar(120)
bezeichnung String @db.VarChar(120)
art FahrzeugArt @default(LOESCHFAHRZEUG)
/// Typkurzzeichen nach DIN, z. B. "LF 20", "TSF-W", "DLA(K) 23/12".
dinTyp String? @db.VarChar(40)
kennzeichen String? @db.VarChar(20)
hersteller String? @db.VarChar(120)
aufbauhersteller String? @db.VarChar(120)
fahrgestellnummer String? @db.VarChar(30)
baujahr Int?
erstzulassung DateTime? @db.Date
indienststellung DateTime? @db.Date
/// Sitzplätze für die Stärkeberechnung ab Phase 3.
sitzplaetze Int?
/// Löschwasser in Litern.
tankinhalt Int?
/// Zulässige Gesamtmasse in kg.
gesamtmasse Int?
kilometerstand Int?
/// Nächste Hauptuntersuchung.
tuevBis DateTime? @db.Date
/// Nächste Sicherheitsprüfung nach UVV.
uvvBis DateTime? @db.Date
naechsteWartung DateTime? @db.Date
standortId String? @db.Uuid
aktiv Boolean @default(true)
bemerkung String? @db.VarChar(1000)
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)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
geraeteraeume Geraeteraum[]
geraete Geraet[]
@@unique([organisationId, funkrufname])
@@unique([organisationId, kennzeichen])
@@index([organisationId, aktiv])
@@index([organisationId, tuevBis])
@@map("fahrzeug")
}
/// Ein Fach am Fahrzeug. Die Nummerierung folgt der Beladeordnung: G1 bis G6
/// an den Seiten, dazu Dachkasten, Heck und Mannschaftsraum.
model Geraeteraum {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fahrzeugId String @db.Uuid
/// Kurzzeichen wie "G1", "GR", "Dach".
bezeichnung String @db.VarChar(40)
beschreibung String? @db.VarChar(200)
/// Reihenfolge in der Anzeige — entspricht der Beladeordnung.
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)
fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Cascade)
geraete Geraet[]
@@unique([fahrzeugId, bezeichnung])
@@index([organisationId, fahrzeugId])
@@map("geraeteraum")
}
/// Die Bauart eines Geräts samt Prüf- und Lebensdauervorgabe. Was hier steht,
/// gilt als Vorgabe für jedes Gerät dieses Typs; einzelne Geräte dürfen davon
/// abweichen, wenn der Hersteller etwas anderes vorschreibt.
model Geraetetyp {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(160)
kategorie GeraeteKategorie @default(SONSTIGES)
hersteller String? @db.VarChar(120)
modell String? @db.VarChar(120)
/// Vorgabe für das Prüfintervall in Monaten.
pruefintervallMonate Int?
/// Fundstelle der Prüfvorschrift, z. B. "DGUV Vorschrift 49" oder
/// "DIN 14800-16".
pruefvorschrift String? @db.VarChar(200)
lebensdauerKategorie LebensdauerKategorie @default(KEINE)
/// Maximale Nutzungsdauer in Jahren.
lebensdauerJahre Int?
lebensdauerBezug LebensdauerBezug @default(INDIENSTSTELLUNG)
/// Wie viele Monate vor Ablauf gewarnt wird.
vorwarnzeitMonate Int @default(3)
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)
geraete Geraet[]
// Phase 2
wartungsvorlagen Wartungsvorlage[]
rueckrufTypen RueckrufGeraetetyp[]
@@unique([organisationId, name])
@@index([organisationId, kategorie])
@@index([organisationId, aktiv])
@@map("geraetetyp")
}
/// Ein einzelnes Gerät mit eigener Inventarnummer.
model Geraet {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
inventarnummer String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
geraetetypId String? @db.Uuid
hersteller String? @db.VarChar(120)
modell String? @db.VarChar(120)
seriennummer String? @db.VarChar(120)
herstellungsdatum DateTime? @db.Date
indienststellung DateTime? @db.Date
anschaffungspreis Decimal? @db.Decimal(12, 2)
status GeraetStatus @default(EINSATZBEREIT)
/// Prüfintervall in Monaten. Leer bedeutet: Vorgabe des Gerätetyps gilt.
pruefintervallMonate Int?
letztePruefung DateTime? @db.Date
naechstePruefung DateTime? @db.Date
/// Lebensdauer. Leer bedeutet ebenfalls: Vorgabe des Gerätetyps gilt.
lebensdauerKategorie LebensdauerKategorie?
lebensdauerJahre Int?
/// Errechnetes Ende der Nutzungsdauer, vom Server gesetzt.
lebensdauerBis DateTime? @db.Date
/// ENTWEDER standortId ODER fahrzeugId + geraeteraumId.
/// Eine Prüfbedingung in der Datenbank setzt das durch.
standortId String? @db.Uuid
fahrzeugId String? @db.Uuid
geraeteraumId String? @db.Uuid
/// Freitext für die genaue Stelle im Raum: "Regal 3, Fach B".
lagerort String? @db.VarChar(160)
ausgesondertAm DateTime? @db.Date
ausgesondertGrund String? @db.VarChar(500)
bemerkung String? @db.VarChar(1000)
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)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
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])
@@index([organisationId, lebensdauerBis])
@@index([organisationId, fahrzeugId])
@@index([organisationId, standortId])
@@index([organisationId, geraetetypId])
@@index([organisationId, seriennummer])
@@map("geraet")
}
/// Die Art einer Qualifikation — Lehrgang, Untersuchung, Führerschein.
model Qualifikationstyp {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(160)
/// Kurzzeichen für Listen, z. B. "MA", "AGT", "G26.3", "C1".
kuerzel String? @db.VarChar(20)
art QualifikationsArt @default(LEHRGANG)
/// Gültigkeit in Monaten. Leer bedeutet: gilt unbefristet.
gueltigkeitMonate Int?
/// Wie viele Tage vor Ablauf gewarnt wird.
vorwarnzeitTage Int @default(90)
/// Voraussetzung für den Einsatzdienst — wird im Dashboard hervorgehoben.
einsatzrelevant Boolean @default(false)
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)
qualifikationen Qualifikation[]
@@unique([organisationId, name])
@@index([organisationId, aktiv])
@@map("qualifikationstyp")
}
/// Eine erworbene Qualifikation einer Person.
model Qualifikation {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
typId String @db.Uuid
erworbenAm DateTime @db.Date
/// Ablaufdatum. Wird beim Speichern aus gueltigkeitMonate errechnet, kann
/// aber überschrieben werden — auf einer Urkunde steht manchmal etwas
/// anderes als die Regel hergibt.
gueltigBis DateTime? @db.Date
ausstellendeStelle String? @db.VarChar(200)
/// Lehrgangs- oder Urkundennummer.
nachweisnummer String? @db.VarChar(80)
/// Eingescannte Urkunde, liegt in MinIO.
urkundeDateiId String? @db.Uuid
bestaetigtVonId String? @db.Uuid
bestaetigtAm DateTime? @db.Timestamptz(3)
bemerkung String? @db.VarChar(1000)
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)
person Person @relation("QualifikationInhaber", fields: [personId], references: [id], onDelete: Cascade)
typ Qualifikationstyp @relation(fields: [typId], references: [id], onDelete: Restrict)
bestaetigtVon Person? @relation("QualifikationBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, personId])
@@index([organisationId, typId])
@@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")
}