Files
FireAdministration/prisma/schema.prisma
T
Florian WildeandClaude Opus 5 4e2daaafd4 Atemschutzueberwachung nach FwDV 7 ergaenzen
Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht
steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt —
abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten.

Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein
Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus.
Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine
Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht
verlieren laesst. Server und Browser rechnen mit denselben Funktionen.

Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein
gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das
Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist
damit die vorsichtigere Annahme.

Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf
druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen
Eintrag richtiggestellt, nicht durch Aendern.

Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine
Richtung, und so bleibt das Deployment bei "next start".

Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette
weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille
von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag
"Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1.

68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 23:19:05 +02:00

2576 lines
94 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[]
// Phase 3 — Einsätze und Übungen
einsaetze Einsatz[]
einsatzfahrzeuge Einsatzfahrzeug[]
einsatzkraefte Einsatzkraft[]
materialverbrauch Materialverbrauch[]
lagemeldungen Lagemeldung[]
portalzugaenge PortalZugang[]
uebungen Uebung[]
uebungsteilnahmen Uebungsteilnahme[]
// Phase 4 — Terminal im Gerätehaus
fahrten Fahrt[]
anwesenheiten Anwesenheit[]
waescheabgaben Waescheabgabe[]
inventurgaenge Inventurgang[]
inventurposten Inventurposten[]
kioskvorgaenge KioskVorgang[]
syncKonflikte SyncKonflikt[]
// Phase 5 — Atemschutz nach FwDV 7
atemschutzeinsaetze Atemschutzeinsatz[]
atemschutztrupps Atemschutztrupp[]
truppmitglieder Truppmitglied[]
druckmessungen Druckmessung[]
atemschutzereignisse AtemschutzEreignis[]
g26Untersuchungen G26Untersuchung[]
belastungsuebungen Belastungsuebung[]
@@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")
// Phase 3 — Einsätze und Übungen
geleiteteEinsaetze Einsatz[] @relation("EinsatzLeitung")
freigegebeneBerichte Einsatz[] @relation("EinsatzFreigabe")
einsatzteilnahmen Einsatzkraft[] @relation("EinsatzTeilnahme")
lagemeldungen Lagemeldung[] @relation("LagemeldungVerfasser")
portalzugaenge PortalZugang[] @relation("PortalzugangErsteller")
geleiteteUebungen Uebung[] @relation("UebungLeitung")
uebungsteilnahmen Uebungsteilnahme[] @relation("UebungTeilnahme")
// Phase 4 — Terminal im Gerätehaus
fahrten Fahrt[] @relation("FahrtFahrer")
anwesenheiten Anwesenheit[] @relation("AnwesenheitPerson")
waescheabgaben Waescheabgabe[] @relation("WaescheTraeger")
angenommeneWaesche Waescheabgabe[] @relation("WaescheAnnahme")
geleiteteInventuren Inventurgang[] @relation("InventurLeitung")
inventurposten Inventurposten[] @relation("InventurErfasser")
kioskvorgaenge KioskVorgang[] @relation("KioskVorgangPerson")
verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher")
erledigteKonflikte SyncKonflikt[] @relation("KonfliktErledigung")
// Phase 5 — Atemschutz nach FwDV 7
ueberwachteEinsaetze Atemschutzeinsatz[] @relation("AtemschutzUeberwacher")
truppeinsaetze Truppmitglied[] @relation("TruppMitgliedPerson")
druckmessungen Druckmessung[] @relation("DruckmessungPerson")
erfassteDruckmessungen Druckmessung[] @relation("DruckmessungErfasser")
atemschutzereignisse AtemschutzEreignis[] @relation("AtemschutzEreignisPerson")
g26Untersuchungen G26Untersuchung[] @relation("G26Person")
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson")
/// 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")
// Phase 3
alsEinsatzbericht Einsatz[] @relation("EinsatzBericht")
alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto")
alsUebungsbericht Uebung[] @relation("UebungBericht")
// Phase 5
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
@@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[]
inventurgaenge Inventurgang[]
@@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[]
// Phase 3
einsaetze Einsatzfahrzeug[]
einsatzkraefte Einsatzkraft[]
// Phase 4
fahrten Fahrt[]
inventurgaenge Inventurgang[]
@@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[]
materialverbrauch Materialverbrauch[]
// Phase 4
waescheabgaben Waescheabgabe[]
inventurposten Inventurposten[]
// Phase 5
alsPressluftatmer Truppmitglied[] @relation("TruppPressluftatmer")
@@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")
}
// =============================================================================
// Phase 3 — Einsätze und Übungen
//
// Ordnungsprinzip:
// Einsatz der Vorgang selbst, mit Ort, Zeitstempeln und Bericht
// Einsatzfahrzeug welches Fahrzeug wann ausgerückt und eingerückt ist
// Einsatzkraft wer mitgefahren ist, in welcher Funktion, wie lange
// Materialverbrauch was dabei verbraucht oder beschädigt wurde
// Lagemeldung was währenddessen gemeldet wurde, mit Foto
// PortalZugang ein zeitlich begrenzter Link für eine Fremdwehr
// Uebung dasselbe für Übungen, mit Thema und Lernzielen
//
// Drei Festlegungen, die den Aufbau erklären:
//
// 1. Die Zeitstempel sind der Kern. Alarmierung, Ausrücken, Eintreffen,
// Einsatzende, Einrücken, Einsatzbereitschaft — daraus rechnet sich die
// Ausrückzeit, die Anfahrtszeit und die Einsatzdauer. Sie stehen deshalb
// einzeln und nicht als Sammelfeld.
//
// 2. Koordinaten liegen doppelt: als Dezimalgrad, die Prisma lesen kann, und
// als PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides
// zusammen — sonst müsste jede Schreiboperation Roh-SQL sein.
//
// 3. Ein freigegebener Einsatzbericht ist unveränderbar, dieselbe Logik wie
// beim Prüfprotokoll: er geht an die Gemeinde und an die Versicherung.
// =============================================================================
/// Einsatzkategorie nach der bayerischen Feuerwehrstatistik. Danach meldet die
/// Wehr ihre Zahlen an die Gemeinde und den Landkreis.
enum EinsatzKategorie {
BRAND_KLEIN
BRAND_MITTEL
BRAND_GROSS
TECHNISCHE_HILFE_KLEIN
TECHNISCHE_HILFE_MITTEL
TECHNISCHE_HILFE_GROSS
GEFAHRGUT
/// Unterstützung des Rettungsdienstes, z. B. Türöffnung, Tragehilfe.
RETTUNGSDIENST
/// Auslösung einer Brandmeldeanlage ohne Schadenfeuer.
BRANDMELDEANLAGE
BOESWILLIGER_ALARM
SICHERHEITSWACHE
/// Amtshilfe, Nachbarschaftshilfe, Sonstiges.
UNTERSTUETZUNG
SONSTIGES
}
/// Lebenszyklus eines Einsatzes.
enum EinsatzStatus {
/// Schnellerfassung: Stichwort und Fahrzeuge stehen, der Rest fehlt noch.
ENTWURF
/// Läuft gerade — der Alarmmonitor und das Portal zeigen ihn an.
LAUFEND
/// Nachbereitet und abgeschlossen.
ABGESCHLOSSEN
/// Bericht freigegeben und festgeschrieben.
FREIGEGEBEN
}
/// Wie alarmiert wurde. Für die Auswertung, ob die Alarmierungswege greifen.
enum Alarmierungsart {
SIRENE
MELDEEMPFAENGER
TELEFON
APP
PERSOENLICH
SONSTIGES
}
/// Funktion einer Einsatzkraft. Aus ihr ergibt sich die Stärke nach FwDV 3.
enum EinsatzFunktion {
EINSATZLEITER
ZUGFUEHRER
GRUPPENFUEHRER
STAFFELFUEHRER
TRUPPFUEHRER
MASCHINIST
MELDER
TRUPPMANN
ATEMSCHUTZTRAEGER
SICHERHEITSTRUPP
SANITAETER
SONSTIGES
}
/// Art einer Übung.
enum UebungsArt {
PRAXIS
THEORIE
ATEMSCHUTZ
MASCHINIST
FUNK
ERSTE_HILFE
/// Gemeinsam mit Nachbarwehren, THW oder Rettungsdienst.
GEMEINSCHAFTSUEBUNG
SONSTIGES
}
/// Woher eine Lagemeldung kommt.
enum LagemeldungQuelle {
/// Aus der Anwendung, von einer angemeldeten Person.
INTERN
/// Über den Portal-Link, von einer Fremdwehr ohne Konto.
PORTAL
}
// -----------------------------------------------------------------------------
/// Ein Einsatz.
model Einsatz {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN".
einsatznummer String @db.VarChar(40)
/// Einsatzstichwort der Leitstelle, z. B. "B 3 Wohnhausbrand".
stichwort String @db.VarChar(160)
kategorie EinsatzKategorie @default(SONSTIGES)
status EinsatzStatus @default(ENTWURF)
/// Einsatznummer der Integrierten Leitstelle — die Klammer zu deren Akte.
ilsNummer String? @db.VarChar(60)
alarmierungsart Alarmierungsart?
// Ort
strasse String? @db.VarChar(200)
hausnummer String? @db.VarChar(20)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
ortsteil String? @db.VarChar(120)
/// Objektbezeichnung, z. B. "Grundschule", "Autobahn A9, km 312".
objekt String? @db.VarChar(200)
/// Koordinaten als Dezimalgrad — von Prisma lesbar.
breitengrad Decimal? @db.Decimal(9, 6)
laengengrad Decimal? @db.Decimal(9, 6)
/// Dieselben Koordinaten als PostGIS-Punkt, von einem Trigger gepflegt.
/// Ab Phase 6 zeichnet die Karte daraus.
koordinaten Unsupported("geography(Point, 4326)")?
/// Woher sie stammen: "nominatim", "manuell", "leitstelle".
koordinatenQuelle String? @db.VarChar(40)
// Zeitstempel — der Kern jeder Einsatzauswertung
alarmiertUm DateTime? @db.Timestamptz(3)
ausgeruecktUm DateTime? @db.Timestamptz(3)
eingetroffenUm DateTime? @db.Timestamptz(3)
einsatzendeUm DateTime? @db.Timestamptz(3)
eingeruecktUm DateTime? @db.Timestamptz(3)
einsatzbereitUm DateTime? @db.Timestamptz(3)
einsatzleiterId String? @db.Uuid
beschreibung String? @db.VarChar(4000)
bericht String?
wetter String? @db.VarChar(200)
temperatur Int?
/// Wer sonst vor Ort war: Polizei, Rettungsdienst, THW, Nachbarwehren.
weitereKraefte String? @db.VarChar(500)
/// Zahlen für die Statistik der Gemeinde.
geretteteePersonen Int @default(0)
verletztePersonen Int @default(0)
totePersonen Int @default(0)
sachschaden Decimal? @db.Decimal(12, 2)
/// Kostenersatzpflichtiger Einsatz (Art. 28 BayFwG).
kostenpflichtig Boolean @default(false)
/// Der erzeugte Einsatzbericht als PDF.
berichtDateiId String? @db.Uuid
freigegebenAm DateTime? @db.Timestamptz(3)
freigegebenVonId 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)
einsatzleiter Person? @relation("EinsatzLeitung", fields: [einsatzleiterId], references: [id], onDelete: SetNull)
freigegebenVon Person? @relation("EinsatzFreigabe", fields: [freigegebenVonId], references: [id], onDelete: SetNull)
berichtDatei Datei? @relation("EinsatzBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
fahrzeuge Einsatzfahrzeug[]
kraefte Einsatzkraft[]
material Materialverbrauch[]
lagemeldungen Lagemeldung[]
portalzugaenge PortalZugang[]
fahrten Fahrt[]
anwesenheiten Anwesenheit[]
// Phase 5
atemschutz Atemschutzeinsatz[]
@@unique([organisationId, einsatznummer])
@@index([organisationId, alarmiertUm(sort: Desc)])
@@index([organisationId, status])
@@index([organisationId, kategorie])
@@map("einsatz")
}
/// Ein Fahrzeug in einem Einsatz, mit eigenen Zeiten: nicht jedes Fahrzeug
/// rückt gleichzeitig aus, und die Nachalarmierung soll sichtbar bleiben.
model Einsatzfahrzeug {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
einsatzId String @db.Uuid
fahrzeugId String @db.Uuid
ausgeruecktUm DateTime? @db.Timestamptz(3)
eingetroffenUm DateTime? @db.Timestamptz(3)
eingeruecktUm DateTime? @db.Timestamptz(3)
kilometerAbfahrt Int?
kilometerRueckkehr Int?
/// Verbrauchtes Löschwasser in Litern.
wasserabgabe Int?
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)
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict)
@@unique([einsatzId, fahrzeugId])
@@index([organisationId, einsatzId])
@@index([organisationId, fahrzeugId])
@@map("einsatzfahrzeug")
}
/// Wer mitgefahren ist. Grundlage für die Stärkemeldung nach FwDV 3 und für
/// den Beteiligungsnachweis je Person.
model Einsatzkraft {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
einsatzId String @db.Uuid
personId String @db.Uuid
funktion EinsatzFunktion @default(TRUPPMANN)
/// Auf welchem Fahrzeug — leer, wenn jemand selbst angefahren ist.
fahrzeugId String? @db.Uuid
vonUm DateTime? @db.Timestamptz(3)
bisUm DateTime? @db.Timestamptz(3)
/// War unter Atemschutz im Einsatz. Zählt auf die Nachweise nach FwDV 7.
atemschutz Boolean @default(false)
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)
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
person Person @relation("EinsatzTeilnahme", fields: [personId], references: [id], onDelete: Cascade)
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
@@unique([einsatzId, personId])
@@index([organisationId, einsatzId])
@@index([organisationId, personId])
@@map("einsatzkraft")
}
/// Was verbraucht oder beschädigt wurde — Schaummittel, Bindemittel, ein
/// zerschnittener Schlauch. Grundlage für Nachbeschaffung und Kostenersatz.
model Materialverbrauch {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
einsatzId String @db.Uuid
/// Verweis auf ein erfasstes Gerät, wo es eines gibt.
geraetId String? @db.Uuid
bezeichnung String @db.VarChar(200)
menge Decimal @db.Decimal(10, 2)
einheit String @db.VarChar(20)
kosten Decimal? @db.Decimal(10, 2)
/// Nicht verbraucht, sondern kaputtgegangen — dann folgt ein Mangel.
beschaedigt Boolean @default(false)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull)
@@index([organisationId, einsatzId])
@@map("materialverbrauch")
}
/// Eine Meldung während des Einsatzes. Aus der Anwendung oder über den
/// Portal-Link einer Fremdwehr.
model Lagemeldung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
einsatzId String @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
text String @db.VarChar(2000)
quelle LagemeldungQuelle @default(INTERN)
verfasserId String? @db.Uuid
/// Klarname oder Wehrname — bei Portalmeldungen die einzige Angabe.
verfasserName String? @db.VarChar(200)
fotoDateiId String? @db.Uuid
/// Hebt die Meldung im Verlauf hervor: Menschenrettung, Nachforderung,
/// Lageänderung.
wichtig Boolean @default(false)
/// Über welchen Portalzugang die Meldung kam.
portalZugangId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
verfasser Person? @relation("LagemeldungVerfasser", fields: [verfasserId], references: [id], onDelete: SetNull)
foto Datei? @relation("LagemeldungFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
portalZugang PortalZugang? @relation(fields: [portalZugangId], references: [id], onDelete: SetNull)
@@index([organisationId, einsatzId, zeitpunkt])
@@map("lagemeldung")
}
/// Ein zeitlich begrenzter Zugang zum Einsatzportal für eine Fremdwehr.
///
/// Der Link enthält die Kennung und ein Geheimnis; gespeichert wird nur dessen
/// Hash. Er gilt bis gueltigBis, lässt sich jederzeit widerrufen und zählt
/// jeden Aufruf mit — ein Link, der nach dem Einsatz noch benutzt wird, fällt
/// so auf.
model PortalZugang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
einsatzId String @db.Uuid
/// Für wen der Link ist, z. B. "FF Nachbarhausen".
bezeichnung String @db.VarChar(160)
/// bcrypt-Hash des Geheimnisses aus dem Link.
tokenHash String @db.VarChar(120)
gueltigBis DateTime @db.Timestamptz(3)
widerrufenAm DateTime? @db.Timestamptz(3)
aufrufe Int @default(0)
letzterAufruf DateTime? @db.Timestamptz(3)
/// Zuletzt sehende IP — für den Fall, dass ein Link weitergegeben wurde.
letzteIp String? @db.Inet
erstelltVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
erstelltVon Person? @relation("PortalzugangErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
lagemeldungen Lagemeldung[]
@@index([organisationId, einsatzId])
@@index([gueltigBis])
@@map("portal_zugang")
}
/// Eine Übung. Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und
/// Lernzielen statt Schadenlage — und ohne Portal.
model Uebung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN".
nummer String @db.VarChar(40)
thema String @db.VarChar(200)
art UebungsArt @default(PRAXIS)
datum DateTime @db.Date
beginnUm DateTime? @db.Timestamptz(3)
endeUm DateTime? @db.Timestamptz(3)
ort String? @db.VarChar(200)
leiterId String? @db.Uuid
/// Was gelernt werden soll — die Lernziele der Ausbildung.
lernziele String? @db.VarChar(2000)
beschreibung String? @db.VarChar(4000)
bericht String?
berichtDateiId 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)
leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
teilnahmen Uebungsteilnahme[]
fahrten Fahrt[]
anwesenheiten Anwesenheit[]
// Phase 5
atemschutz Atemschutzeinsatz[]
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungUebung")
@@unique([organisationId, nummer])
@@index([organisationId, datum(sort: Desc)])
@@index([organisationId, art])
@@map("uebung")
}
/// Wer an einer Übung teilgenommen hat. Der Nachweis, den eine Wehr für die
/// Beförderung und für die Statistik braucht.
model Uebungsteilnahme {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
uebungId String @db.Uuid
personId String @db.Uuid
funktion EinsatzFunktion?
vonUm DateTime? @db.Timestamptz(3)
bisUm DateTime? @db.Timestamptz(3)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
uebung Uebung @relation(fields: [uebungId], references: [id], onDelete: Cascade)
person Person @relation("UebungTeilnahme", fields: [personId], references: [id], onDelete: Cascade)
@@unique([uebungId, personId])
@@index([organisationId, personId])
@@map("uebungsteilnahme")
}
// =============================================================================
// Phase 4 — Terminal im Gerätehaus
//
// Was am Wandtablet in der Fahrzeughalle erfasst wird:
//
// Fahrt das Fahrtenbuch — wer ist wann womit wohin gefahren
// Anwesenheit ein- und auschecken zu Dienst, Übung oder Einsatz
// Waescheabgabe Schutzkleidung zur Reinigung geben und zurückbekommen
// Inventurgang Bestand am Regal scannen, Fehlendes finden
// KioskVorgang Idempotenz: was das Terminal offline erfasst hat
// SyncKonflikt was sich beim Nachtragen nicht auflösen ließ
//
// Die beiden letzten sind der Kern des Offline-Betriebs. Ein Tablet in einer
// Halle aus Stahlbeton hat regelmäßig kein Netz; erfasst wird trotzdem, und
// nachgetragen wird, sobald es wieder geht. Damit ein zweimal abgeschickter
// Vorgang nicht zweimal ankommt, trägt jeder eine im Browser erzeugte Kennung.
// =============================================================================
/// Wozu eine Fahrt unternommen wurde.
enum FahrtZweck {
EINSATZ
UEBUNG
AUSBILDUNG
WERKSTATT
BESCHAFFUNG
/// Probefahrt, Überführung, Fahrsicherheitstraining.
SONSTIGES
}
/// Weg der Schutzkleidung durch die Reinigung.
enum WaescheStatus {
ABGEGEBEN
IN_REINIGUNG
ZURUECK
/// Nicht wieder aufgetaucht — muss ersetzt werden.
VERSCHOLLEN
}
/// Wozu jemand am Terminal eincheckt.
enum AnwesenheitAnlass {
DIENST
UEBUNG
EINSATZ
ARBEITSDIENST
SONSTIGES
}
/// Stand eines Inventurgangs.
enum InventurStatus {
LAEUFT
ABGESCHLOSSEN
ABGEBROCHEN
}
/// Was ein offline erfasster Vorgang bewirken soll.
enum KioskVorgangArt {
MANGEL_MELDEN
FAHRT_BEGINNEN
FAHRT_BEENDEN
ANWESENHEIT_KOMMEN
ANWESENHEIT_GEHEN
WAESCHE_ABGEBEN
INVENTUR_SCANNEN
PRUEFUNG_ERFASSEN
}
/// Warum sich ein nachgetragener Vorgang nicht anwenden ließ.
enum KonfliktGrund {
/// Der Datensatz, auf den er sich bezieht, gibt es nicht mehr.
ZIEL_FEHLT
/// Der Datensatz wurde zwischenzeitlich festgeschrieben.
ZIEL_GESPERRT
/// Zwei Terminals haben dasselbe erfasst.
DOPPELT
/// Die Angaben passen nicht mehr zum Stand auf dem Server.
VERALTET
SONSTIGES
}
// -----------------------------------------------------------------------------
/// Eine Fahrt im Fahrtenbuch.
///
/// Nach § 6 StVO-Nachweispflicht und nach der Dienstanweisung der meisten
/// Wehren muss nachvollziehbar sein, wer wann mit welchem Fahrzeug unterwegs
/// war. Am Terminal ist das ein Vorgang von zwanzig Sekunden: Fahrzeug wählen,
/// Kilometerstand eintippen, Zweck antippen.
model Fahrt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fahrzeugId String @db.Uuid
fahrerId String @db.Uuid
zweck FahrtZweck @default(SONSTIGES)
/// Wenn die Fahrt zu einem Einsatz oder einer Übung gehört.
einsatzId String? @db.Uuid
uebungId String? @db.Uuid
abfahrtUm DateTime @default(now()) @db.Timestamptz(3)
rueckkehrUm DateTime? @db.Timestamptz(3)
kilometerAbfahrt Int
kilometerRueckkehr Int?
ziel String? @db.VarChar(200)
/// Getankte Menge in Litern, wenn unterwegs getankt wurde.
tankmenge Decimal? @db.Decimal(8, 2)
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)
fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict)
fahrer Person @relation("FahrtFahrer", fields: [fahrerId], references: [id], onDelete: Restrict)
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull)
@@index([organisationId, fahrzeugId, abfahrtUm(sort: Desc)])
@@index([organisationId, fahrerId])
@@map("fahrt")
}
/// Anwesenheit am Gerätehaus.
///
/// Wer eincheckt, ist da — für die Übungsteilnahme, für den Arbeitsdienst und
/// im Einsatzfall dafür, dass die Einsatzleitung weiß, wer im Haus ist.
model Anwesenheit {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
anlass AnwesenheitAnlass @default(DIENST)
angemeldetUm DateTime @default(now()) @db.Timestamptz(3)
abgemeldetUm DateTime? @db.Timestamptz(3)
einsatzId String? @db.Uuid
uebungId String? @db.Uuid
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)
person Person @relation("AnwesenheitPerson", fields: [personId], references: [id], onDelete: Cascade)
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull)
@@index([organisationId, personId, angemeldetUm(sort: Desc)])
@@index([organisationId, abgemeldetUm])
@@map("anwesenheit")
}
/// Schutzkleidung in der Reinigung.
///
/// Wer seine Einsatzjacke abgibt, steht ohne da — deshalb hängt an der Abgabe
/// auch die Frage, ob Ersatz vorhanden ist. Erfasst wird am Terminal beim
/// Einwurf in den Wäschekorb.
model Waescheabgabe {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
/// Wenn das Kleidungsstück als Gerät erfasst ist.
geraetId String? @db.Uuid
bezeichnung String @db.VarChar(200)
menge Int @default(1)
status WaescheStatus @default(ABGEGEBEN)
/// Grund: Einsatzverschmutzung, Kontamination, Routine.
grund String? @db.VarChar(200)
abgegebenAm DateTime @default(now()) @db.Timestamptz(3)
zurueckAm DateTime? @db.Timestamptz(3)
/// Wer die Rückgabe bestätigt hat.
angenommenVonId String? @db.Uuid
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)
person Person @relation("WaescheTraeger", fields: [personId], references: [id], onDelete: Cascade)
geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull)
angenommenVon Person? @relation("WaescheAnnahme", fields: [angenommenVonId], references: [id], onDelete: SetNull)
@@index([organisationId, status])
@@index([organisationId, personId])
@@map("waescheabgabe")
}
/// Ein Inventurgang.
///
/// Der Gerätewart geht mit dem Tablet durch die Halle und scannt, was da ist.
/// Am Ende steht die Liste dessen, was fehlt — das ist der Zweck der Sache.
model Inventurgang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(160)
status InventurStatus @default(LAEUFT)
/// Worauf sich der Gang bezieht — ein Standort oder ein Fahrzeug.
standortId String? @db.Uuid
fahrzeugId String? @db.Uuid
begonnenAm DateTime @default(now()) @db.Timestamptz(3)
abgeschlossenAm DateTime? @db.Timestamptz(3)
begonnenVonId String? @db.Uuid
bemerkung String? @db.VarChar(1000)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
begonnenVon Person? @relation("InventurLeitung", fields: [begonnenVonId], references: [id], onDelete: SetNull)
posten Inventurposten[]
@@index([organisationId, status])
@@map("inventurgang")
}
/// Ein gescanntes Gerät innerhalb eines Inventurgangs.
model Inventurposten {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
inventurId String @db.Uuid
geraetId String @db.Uuid
gescanntAm DateTime @default(now()) @db.Timestamptz(3)
/// Am erwarteten Ort vorgefunden.
gefunden Boolean @default(true)
/// Wo es tatsächlich lag, wenn es nicht dort war, wo es sein sollte.
vorgefundenAn String? @db.VarChar(200)
bemerkung String? @db.VarChar(500)
erfasstVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
inventur Inventurgang @relation(fields: [inventurId], references: [id], onDelete: Cascade)
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
erfasstVon Person? @relation("InventurErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
@@unique([inventurId, geraetId])
@@index([organisationId, inventurId])
@@map("inventurposten")
}
/// Ein vom Terminal übermittelter Vorgang.
///
/// Der Schlüssel zum Offline-Betrieb: die Kennung erzeugt der Browser, bevor
/// er den Vorgang in seine Warteschlange legt. Kommt derselbe Vorgang nach
/// einem abgebrochenen Sendeversuch ein zweites Mal an, erkennt der Server ihn
/// an dieser Kennung wieder und antwortet mit dem Ergebnis von damals, statt
/// alles noch einmal anzulegen.
///
/// Deshalb ist die Kennung der Primärschlüssel und wird nicht vergeben,
/// sondern angenommen.
model KioskVorgang {
/// Vom Browser erzeugt (crypto.randomUUID) — nicht vom Server.
id String @id @db.Uuid
organisationId String @db.Uuid
art KioskVorgangArt
personId String? @db.Uuid
/// Wann der Vorgang am Terminal erfasst wurde — kann deutlich vor dem
/// Eingang liegen.
erfasstAm DateTime @db.Timestamptz(3)
empfangenAm DateTime @default(now()) @db.Timestamptz(3)
/// Was der Vorgang bewirkt hat: die Kennung des angelegten Datensatzes.
ergebnisId String? @db.Uuid
/// Die Nutzlast, wie sie das Terminal geschickt hat — für die Fehlersuche
/// und für den Fall, dass ein Vorgang von Hand nachgezogen werden muss.
nutzlast Json? @db.JsonB
erfolgreich Boolean @default(true)
meldung String? @db.VarChar(500)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person? @relation("KioskVorgangPerson", fields: [personId], references: [id], onDelete: SetNull)
@@index([organisationId, empfangenAm(sort: Desc)])
@@index([organisationId, art])
@@map("kiosk_vorgang")
}
/// Ein Vorgang, der sich beim Nachtragen nicht anwenden ließ.
///
/// Die Regel dahinter: bei Stammdaten gewinnt der Server, bei neuen
/// Datensätzen der Client. Was in keine der beiden Schubladen passt — ein
/// Mangel zu einem inzwischen ausgesonderten Gerät, eine Prüfung zu einem
/// festgeschriebenen Protokoll — landet hier und wartet auf einen Menschen.
model SyncKonflikt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Die Kennung des Vorgangs, der nicht durchging.
vorgangId String @db.Uuid
art KioskVorgangArt
grund KonfliktGrund @default(SONSTIGES)
beschreibung String @db.VarChar(500)
nutzlast Json? @db.JsonB
personId String? @db.Uuid
entstandenAm DateTime @default(now()) @db.Timestamptz(3)
erledigtAm DateTime? @db.Timestamptz(3)
erledigtVonId String? @db.Uuid
erledigung String? @db.VarChar(500)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person? @relation("KonfliktVerursacher", fields: [personId], references: [id], onDelete: SetNull)
erledigtVon Person? @relation("KonfliktErledigung", fields: [erledigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, erledigtAm])
@@map("sync_konflikt")
}
// =============================================================================
// Phase 5 — Atemschutz nach FwDV 7
//
// Das sicherheitsrelevanteste Modul des Projekts. Zwei Dinge bestimmen den
// Aufbau:
//
// 1. **Die Zeit gehoert dem Server.** Jeder Zeitpunkt steht als Zeitstempel
// in der Datenbank, nie als heruntergezaehlte Restzeit. Der Browser
// rechnet daraus nur die Anzeige. Ein Neuladen, ein Verbindungsabbruch
// oder ein zweiter Bildschirm aendern daran nichts — es gibt keinen
// Timer, der verloren gehen koennte.
// 2. **Das Protokoll ist lueckenlos.** Jede Druckabfrage, jeder
// Statuswechsel und jedes Mayday steht als eigene Zeile mit Zeitpunkt.
// Gelöscht wird nichts; die Datenbank verweigert es (siehe Migration).
//
// Ueber allem steht der Hinweis, dass die analoge Ueberwachung fuehrend
// bleibt. Er ist in der Anwendung fest verdrahtet und nur ueber eine
// Konfigurationsoption abschaltbar, die eine dokumentierte Freigabe des
// Kommandanten voraussetzt.
// =============================================================================
/// Zustand eines Trupps. Die Reihenfolge ist der uebliche Verlauf.
enum TruppStatus {
/// Angelegt, Eingangsdruck erfasst, noch nicht unter Atemschutz.
BEREIT
/// Unter Atemschutz im Einsatz.
IM_EINSATZ
/// Auf dem Rueckweg — ab hier zaehlt die Zeit doppelt.
RUECKWEG
/// Draussen, Ausgangsdruck erfasst. Endzustand.
DRAUSSEN
}
/// Wann ein Druck gemessen wurde.
enum DruckArt {
/// Vor dem Anlegen, am Geraet abgelesen.
EINGANG
/// Beim Erreichen des Einsatzziels. Aus dem Verbrauch bis hierher errechnet
/// sich der Rueckzugsdruck — der Weg zurueck ist so lang wie der Weg hin.
ZIEL
/// Waehrend des Einsatzes, ueblicherweise alle zehn Minuten.
ZWISCHEN
/// Nach dem Herauskommen.
AUSGANG
}
/// Was im Protokoll steht.
enum EreignisArt {
TRUPP_ANGELEGT
EINSTIEG
DRUCKABFRAGE
RUECKZUG_ANGEORDNET
RUECKZUG_ANGETRETEN
HERAUSGEKOMMEN
MAYDAY
MAYDAY_AUFGEHOBEN
HINWEIS
}
/// Ergebnis einer Untersuchung nach G26.3.
enum G26Ergebnis {
TAUGLICH
BEFRISTET_TAUGLICH
NACHUNTERSUCHUNG
NICHT_TAUGLICH
}
/// Art der jaehrlichen Uebung nach FwDV 7.
enum UebungsartAtemschutz {
/// Belastungsuebung in der Atemschutzuebungsstrecke.
UEBUNGSSTRECKE
/// Einsatzuebung unter Atemschutz.
EINSATZUEBUNG
/// Unterweisung, theoretisch.
UNTERWEISUNG
}
/// Eine Atemschutzueberwachung.
///
/// Haengt an einem Einsatz oder einer Uebung — oder an keinem von beidem, denn
/// im Ernstfall wird zuerst ueberwacht und spaeter zugeordnet.
model Atemschutzeinsatz {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Wozu die Ueberwachung gehoert. Beides optional, siehe oben.
einsatzId String? @db.Uuid
uebungId String? @db.Uuid
/// Wer die Ueberwachung fuehrt. Nach FwDV 7 eine benannte Person.
ueberwacherId String @db.Uuid
bezeichnung String @db.VarChar(200)
beginnUm DateTime @default(now()) @db.Timestamptz(3)
endeUm DateTime? @db.Timestamptz(3)
/// Intervall der Druckabfrage in Sekunden. Vorgabe kommt aus der
/// Konfiguration, laesst sich je Ueberwachung anpassen.
abfrageIntervall Int @default(600)
/// Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar.
/// Der Kommandant legt ihn fest; die Formel steht in lib/atemschutz.
sicherheitszuschlag Int @default(10)
/// Ansprechdruck der Warneinrichtung des verwendeten Geraets, in bar.
/// Steht je Ueberwachung im Datensatz, weil eine Wehr Geraete
/// unterschiedlicher Baujahre im Bestand hat.
warndruck Int @default(55)
bemerkung String? @db.VarChar(2000)
/// Das abgeschlossene Protokoll als PDF, liegt in MinIO.
protokollDateiId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull)
ueberwacher Person @relation("AtemschutzUeberwacher", fields: [ueberwacherId], references: [id], onDelete: Restrict)
protokollDatei Datei? @relation("AtemschutzProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull)
trupps Atemschutztrupp[]
ereignisse AtemschutzEreignis[]
@@index([organisationId, endeUm])
@@index([organisationId, beginnUm(sort: Desc)])
@@map("atemschutzeinsatz")
}
/// Ein Trupp unter Atemschutz.
///
/// Die Zeiten stehen als Zeitstempel, nicht als Dauer: `einstiegUm` ist der
/// Moment, ab dem die Uhr laeuft. Wie lange ein Trupp drin ist, rechnet jede
/// Ansicht aus der aktuellen Zeit selbst aus — deshalb ueberlebt die Anzeige
/// jeden Neustart.
model Atemschutztrupp {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
atemschutzeinsatzId String @db.Uuid
funkrufname String @db.VarChar(60)
auftrag String? @db.VarChar(500)
status TruppStatus @default(BEREIT)
angelegtUm DateTime @default(now()) @db.Timestamptz(3)
einstiegUm DateTime? @db.Timestamptz(3)
rueckzugUm DateTime? @db.Timestamptz(3)
drausenUm DateTime? @db.Timestamptz(3)
/// Errechnet beim Einstieg aus den Eingangsdruecken. Steht fest im Datensatz,
/// damit spaeter nachvollziehbar ist, mit welchem Wert gearbeitet wurde —
/// auch wenn sich die Formel einmal aendert.
rueckzugsdruck Int?
/// Laeuft ein Notfall? Zeitpunkt statt Kennzeichen, damit die Dauer im
/// Protokoll steht.
maydayUm DateTime? @db.Timestamptz(3)
maydayAufgehobenUm DateTime? @db.Timestamptz(3)
bemerkung String? @db.VarChar(1000)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade)
mitglieder Truppmitglied[]
druckmessungen Druckmessung[]
ereignisse AtemschutzEreignis[]
@@index([organisationId, atemschutzeinsatzId])
@@index([organisationId, status])
@@map("atemschutztrupp")
}
/// Wer im Trupp ist.
///
/// Ueblich sind zwei, erlaubt sind drei — die Zuordnung steht hier und nicht
/// als zwei Spalten am Trupp, weil sonst der dritte Mann nicht abbildbar waere.
model Truppmitglied {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
truppId String @db.Uuid
personId String @db.Uuid
/// true beim Truppfuehrer. Genau einer je Trupp.
truppfuehrer Boolean @default(false)
/// Kennung des Pressluftatmers, falls erfasst — hilft beim Zuordnen der
/// Flaschenfuellung hinterher.
geraetId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade)
person Person @relation("TruppMitgliedPerson", fields: [personId], references: [id], onDelete: Restrict)
geraet Geraet? @relation("TruppPressluftatmer", fields: [geraetId], references: [id], onDelete: SetNull)
@@unique([truppId, personId])
@@index([organisationId, personId])
@@map("truppmitglied")
}
/// Eine Druckmessung.
///
/// Append-only: einmal erfasst, bleibt sie stehen. Ein Tippfehler wird durch
/// eine neue Messung richtiggestellt, nicht durch Ueberschreiben — im
/// Zweifelsfall zaehlt, was zum Zeitpunkt X abgelesen wurde.
model Druckmessung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
truppId String @db.Uuid
personId String @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
/// Druck in bar. Ganzzahlig — feiner liest niemand ab.
druck Int
art DruckArt @default(ZWISCHEN)
erfasstVonId String? @db.Uuid
bemerkung String? @db.VarChar(300)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade)
person Person @relation("DruckmessungPerson", fields: [personId], references: [id], onDelete: Restrict)
erfasstVon Person? @relation("DruckmessungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
@@index([organisationId, truppId, zeitpunkt])
@@map("druckmessung")
}
/// Das Protokoll.
///
/// Jede Zeile ein Ereignis mit Zeitpunkt. Zusammen mit den Druckmessungen ist
/// das der lueckenlose Nachweis, den FwDV 7 verlangt — und der Grund, warum
/// hier nichts geaendert und nichts geloescht werden kann.
model AtemschutzEreignis {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
atemschutzeinsatzId String @db.Uuid
truppId String? @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
art EreignisArt
text String @db.VarChar(500)
/// Wer es ausgeloest hat.
personId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade)
trupp Atemschutztrupp? @relation(fields: [truppId], references: [id], onDelete: Cascade)
person Person? @relation("AtemschutzEreignisPerson", fields: [personId], references: [id], onDelete: SetNull)
@@index([organisationId, atemschutzeinsatzId, zeitpunkt])
@@map("atemschutz_ereignis")
}
/// Untersuchung nach G26.3.
///
/// Steht bewusst nicht als Qualifikation aus Phase 1 da: eine G26 hat ein
/// Ergebnis, einen Arzt und eine Frist, die sich nach dem Alter richtet —
/// und sie ist die harte Voraussetzung dafuer, ueberhaupt eingesetzt zu
/// werden.
model G26Untersuchung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
untersuchtAm DateTime @db.Date
gueltigBis DateTime @db.Date
ergebnis G26Ergebnis @default(TAUGLICH)
arzt String? @db.VarChar(200)
bemerkung String? @db.VarChar(1000)
/// Eingescannte Bescheinigung, liegt in MinIO.
nachweisDateiId 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)
person Person @relation("G26Person", fields: [personId], references: [id], onDelete: Cascade)
nachweis Datei? @relation("G26Nachweis", fields: [nachweisDateiId], references: [id], onDelete: SetNull)
@@index([organisationId, personId, gueltigBis(sort: Desc)])
@@index([organisationId, gueltigBis])
@@map("g26_untersuchung")
}
/// Belastungsuebung, Einsatzuebung oder Unterweisung.
///
/// FwDV 7 verlangt beides jaehrlich. Wer eines davon versaeumt, ist nicht
/// einsetzbar — auch mit gueltiger G26 nicht.
model Belastungsuebung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
datum DateTime @db.Date
art UebungsartAtemschutz @default(UEBUNGSSTRECKE)
ort String? @db.VarChar(200)
dauerMinuten Int?
/// Wenn die Uebung im System als Uebung erfasst ist.
uebungId String? @db.Uuid
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("BelastungsuebungPerson", fields: [personId], references: [id], onDelete: Cascade)
uebung Uebung? @relation("BelastungsuebungUebung", fields: [uebungId], references: [id], onDelete: SetNull)
@@index([organisationId, personId, datum(sort: Desc)])
@@map("belastungsuebung")
}