Files

4171 lines
152 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// 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"), vector]
}
// =============================================================================
// 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).
/// Wie jemand zu einer Wehr gehört.
///
/// MITGLIED ist der Regelfall: das Konto hängt an einer Personenakte in dieser
/// Wehr und bringt deren Rollen mit.
///
/// AUFSICHT ist die Kreisebene — Kreisbrandrat, Kreisbrandinspektion. Sie
/// gehört der Wehr nicht an, hat dort also auch keine Personenakte, und darf
/// ausschließlich lesen. Ein Kreisbrandinspektor, der zugleich Mitglied seiner
/// Heimatwehr ist, hat schlicht zwei Mitgliedschaften: dort MITGLIED, in den
/// betreuten Wehren AUFSICHT.
enum MitgliedschaftArt {
MITGLIED
AUFSICHT
}
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[]
mitgliedschaften Mitgliedschaft[]
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[]
// Phase 6 — Kartenmodule
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
// Phase 7 — Benachrichtigungen und Termine
benachrichtigungsvorlages Benachrichtigungsvorlage[]
zustellkanals Zustellkanal[]
benachrichtigungs Benachrichtigung[]
termins Termin[]
terminanmeldungs Terminanmeldung[]
kalenderzugangs Kalenderzugang[]
// Phase 8 — Lokale KI
kiVorgaenge KiVorgang[]
wikiseiten Wikiseite[]
wikiabschnitte Wikiabschnitt[]
wikiversionen Wikiversion[]
diktate Diktat[]
belegentwuerfe Belegentwurf[]
// Phase 9 — Kassenverwaltung
kassenkonten Kassenkonto[]
buchungskategorien Buchungskategorie[]
buchungen Buchung[]
beitragssaetze Beitragssatz[]
beitraege Beitrag[]
rechnungen Rechnung[]
rechnungspositionen Rechnungsposition[]
kassenabschluesse Kassenabschluss[]
abschlussposten Abschlussposten[]
@@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)
/// Die Anmeldedaten liegen seit Phase 10 am `Benutzer`, nicht mehr hier.
///
/// Der Grund ist die Doppelmitgliedschaft: wer in zwei Wehren geführt wird,
/// hat dort zwei Akten — aber ein Passwort. Lägen Hash und OIDC-Subject
/// weiterhin an der Akte, gäbe es zwei davon, die auseinanderlaufen können,
/// und die Anmeldung per E-Mail wäre mehrdeutig.
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)
mitgliedschaft Mitgliedschaft? @relation("MitgliedschaftPerson")
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")
// Phase 6 — Kartenmodule
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
// Phase 7 — Benachrichtigungen und Termine
benachrichtigungen Benachrichtigung[] @relation("BenachrichtigungEmpfaenger")
zustellkanaele Zustellkanal[] @relation("ZustellkanalPerson")
geleiteteTermine Termin[] @relation("TerminLeitung")
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
// Phase 8 — Lokale KI
angestosseneKiVorgaenge KiVorgang[] @relation("KiVorgangErzeuger")
entschiedeneKiVorgaenge KiVorgang[] @relation("KiVorgangEntscheider")
verfassteWikiseiten Wikiseite[] @relation("WikiseiteAutor")
wikiversionen Wikiversion[] @relation("WikiversionAutor")
diktate Diktat[] @relation("DiktatErzeuger")
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
// Phase 9 — Kassenverwaltung
buchungen Buchung[] @relation("BuchungPerson")
erfassteBuchungen Buchung[] @relation("BuchungErfasser")
beitraege Beitrag[] @relation("BeitragPerson")
gepruefteAbschluesse Kassenabschluss[] @relation("AbschlussPruefer")
zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer")
erstellteRechnungen Rechnung[] @relation("RechnungErsteller")
/// Die E-Mail ist je Wehr eindeutig, nicht global: dieselbe Person kann in
/// zwei Wehren eine Akte haben. Angemeldet wird sich nicht damit, sondern
/// mit der E-Mail des `Benutzer` — die ist global eindeutig.
@@unique([organisationId, email])
@@unique([organisationId, mitgliedsnummer])
@@index([organisationId, nachname, vorname])
@@index([organisationId, aktiv])
@@map("person")
}
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
/// Rollen kann der Kommandant ergänzen.
/// Ein Anmeldekonto.
///
/// **Die einzige Tabelle neben `Berechtigung` ohne `organisationId`** — und
/// zwar notwendigerweise: sie ist der Angelpunkt, an dem ein Mensch zu
/// mehreren Wehren gehören kann. Ein Konto mit Mandantenkennung könnte genau
/// das nicht.
///
/// Sie ist deshalb auch nicht über Row Level Security geschützt, sondern über
/// Rechte: die Anwendungsrolle darf sie nur lesen und die Anmeldefelder
/// fortschreiben, angelegt und verändert werden Konten über den
/// Systemzugang. Was ein Konto in einer Wehr sehen darf, entscheidet nicht es
/// selbst, sondern seine `Mitgliedschaft` — und die trägt `organisationId`
/// wie jede andere Tabelle.
model Benutzer {
id String @id @default(uuid()) @db.Uuid
/// Global eindeutig. Das ist der Unterschied zur E-Mail an der Personenakte:
/// die ist eine Kontaktangabe je Wehr, diese hier ist die Anmeldung.
email String @unique @db.VarChar(200)
/// 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.
oidcSubject String? @unique @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)
/// Verwaltet die Installation: Wehren anlegen und in jede hineinschalten.
///
/// Bewusst ein Merkmal des Kontos und keine Rolle innerhalb einer Wehr. Eine
/// Rolle gilt je Organisation — wer Wehren anlegt, steht aber über allen,
/// und ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre
/// genau die Art Hintertür, die die Mandantentrennung aushöhlt.
istVerwalter Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
mitgliedschaften Mitgliedschaft[]
@@index([oidcSubject])
@@map("benutzer")
}
/// Die Zugehörigkeit eines Kontos zu einer Wehr.
///
/// Sie ist das, was eine Sitzung ausmacht: erst die Wahl einer Mitgliedschaft
/// entscheidet, welche Wehr in `app.current_organisation` steht und welche
/// Rollen gelten. Wer nur eine hat — die allermeisten —, merkt davon nichts.
model Mitgliedschaft {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
benutzerId String @db.Uuid
/// Die Personenakte in dieser Wehr. Bei AUFSICHT leer: die Kreisebene ist
/// dort nicht Mitglied und soll auch in keiner Mitgliederliste auftauchen.
personId String? @db.Uuid
art MitgliedschaftArt @default(MITGLIED)
aktiv Boolean @default(true)
/// Wer sie eingerichtet hat. Bei der Umstellung aus Phase 10 leer.
vergebenVonId 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)
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
person Person? @relation("MitgliedschaftPerson", fields: [personId], references: [id], onDelete: Cascade)
/// Ein Konto gehört einer Wehr höchstens einmal an.
@@unique([benutzerId, organisationId])
/// Und eine Personenakte hängt an höchstens einem Konto — sonst wäre nicht
/// entscheidbar, wer unter ihrem Namen gehandelt hat.
@@unique([personId])
@@index([organisationId, art])
@@map("mitgliedschaft")
}
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")
// Phase 6
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
// Phase 8
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
// Phase 9
alsBuchungsbeleg Buchung[] @relation("BuchungBeleg")
alsRechnungsPdf Rechnung[] @relation("RechnungPdf")
alsKassenpruefbericht Kassenabschluss[] @relation("AbschlussBericht")
@@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 doppelt: als Dezimalgrad für die Anwendung und als
/// PostGIS-Punkt für die Karte. Prisma kann den PostGIS-Typ nicht lesen —
/// ohne die Grade daneben wäre jede Anzeige eine Rohabfrage. Ein Trigger
/// hält beides im Gleichlauf (Phase 6).
breitengrad Decimal? @db.Decimal(9, 6)
laengengrad Decimal? @db.Decimal(9, 6)
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[]
// Phase 6
lagekarte Lagekarte?
wasserfoerderungen Wasserfoerderung[]
@@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")
}
// =============================================================================
// Phase 6 — Kartenmodule
//
// Alles, was einen Ort hat: Hydranten, Objekte mit Feuerwehrplänen, die
// Lagekarte eines Einsatzes und die geplante Wasserförderstrecke.
//
// Koordinaten liegen überall doppelt — als Dezimalgrad und als PostGIS-Punkt.
// Der Grund ist derselbe wie beim Einsatz aus Phase 3: Prisma kann den
// PostGIS-Typ nicht lesen, und ohne die Grade daneben wäre jede Anzeige eine
// Rohabfrage. Ein Trigger hält beides zusammen; die Umkreissuche läuft über
// den PostGIS-Punkt, weil nur der einen Index hat, der Entfernungen kann.
// =============================================================================
/// Woher das Löschwasser kommt.
enum HydrantTyp {
/// Der Regelfall im Ort: Deckel auf der Straße, Standrohr drauf.
UNTERFLUR
/// Sichtbare Säule. Im Winter schneller zu finden.
UEBERFLUR
/// Vorbereitete Entnahmestelle an einem Gewässer.
SAUGSTELLE
/// Löschwasserbehälter, meist unterirdisch.
ZISTERNE
/// Bach, Weiher, Fluss — mit allem, was daran hängt (Zufahrt, Jahreszeit).
OFFENES_GEWAESSER
SONSTIGES
}
/// Ob man sich darauf verlassen kann.
enum HydrantStatus {
EINSATZBEREIT
/// Nutzbar, aber mit Einschränkung — geringe Leistung, schwer zugänglich.
EINGESCHRAENKT
DEFEKT
/// Noch nie geprüft oder aus einem Import ohne Angabe übernommen.
UNBEKANNT
}
/// Grobe Einordnung eines Objekts. Bestimmt, was im Einsatz zuerst zählt.
enum ObjektKategorie {
SCHULE
KINDERGARTEN
PFLEGEHEIM
KRANKENHAUS
VERSAMMLUNGSSTAETTE
HOCHHAUS
INDUSTRIE
LANDWIRTSCHAFT
GEFAHRSTOFFLAGER
SONDERBAU
SONSTIGES
}
/// Blattarten eines Feuerwehrplans nach DIN 14095.
enum PlanArt {
UEBERSICHTSPLAN
GESCHOSSPLAN
UMGEBUNGSPLAN
SONDERPLAN
/// Alles, was zusätzlich dazugehört: Betriebsanweisung, Stoffliste.
ANLAGE
}
/// Was auf der Lagekarte liegt.
enum LageelementArt {
/// Taktisches Zeichen nach FwDV 102, siehe lib/karte/taktische-zeichen.ts.
ZEICHEN
/// Linienzug — Schlauchleitung, Absperrung, Anfahrtsweg.
LINIE
/// Geschlossene Fläche — Bereitstellungsraum, Gefahrenbereich.
FLAECHE
/// Freie Beschriftung.
TEXT
}
/// Rolle eines Punktes in der Förderstrecke.
enum FoerderpunktArt {
/// Wo das Wasser herkommt.
ENTNAHME
/// Verstärkerpumpe in der Strecke.
VERSTAERKER
/// Zwischenpunkt ohne Pumpe — nur für den Streckenverlauf und die Höhe.
WEGPUNKT
/// Die Einsatzstelle.
ZIEL
}
/// Ein Hydrant oder eine andere Löschwasserstelle.
///
/// Die Frage, die dieses Modell beantworten muss, wird auf der Anfahrt
/// gestellt und lautet: **Was liegt in der Nähe, und was gibt es her?**
/// Deshalb stehen Typ, Nennweite und Durchfluss gleichrangig neben dem Ort —
/// ein Unterflurhydrant mit 80er Leitung ist etwas anderes als einer mit 150.
model Hydrant {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Nummer oder Bezeichnung, wie sie die Wehr verwendet.
bezeichnung String? @db.VarChar(120)
typ HydrantTyp @default(UNTERFLUR)
status HydrantStatus @default(UNBEKANNT)
/// Nennweite der Leitung in Millimetern (80, 100, 150 …).
nennweite Int?
/// Ergiebigkeit in Litern je Minute, aus der Prüfung oder vom Versorger.
durchfluss Int?
strasse String? @db.VarChar(200)
hausnummer String? @db.VarChar(20)
ortsteil String? @db.VarChar(120)
breitengrad Decimal @db.Decimal(9, 6)
laengengrad Decimal @db.Decimal(9, 6)
koordinaten Unsupported("geography(Point, 4326)")?
letztePruefung DateTime? @db.Date
naechstePruefung DateTime? @db.Date
/// Wer die Leitung betreibt — im Zweifel der Ansprechpartner bei Defekt.
betreiber String? @db.VarChar(200)
/// Was man vor Ort wissen muss: „hinter der Hecke", „im Winter geräumt".
hinweis String? @db.VarChar(1000)
fotoDateiId String? @db.Uuid
/// Woher der Datensatz stammt: "hand", "geojson", "stadtwerke-csv".
quelle String? @db.VarChar(40)
/// Kennung beim Versorger, damit ein zweiter Import nichts doppelt anlegt.
externeNummer String? @db.VarChar(80)
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)
foto Datei? @relation("HydrantFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
@@unique([organisationId, externeNummer])
@@index([organisationId, status])
@@index([organisationId, typ])
@@map("hydrant")
}
/// Ein Objekt mit Feuerwehrplan.
///
/// Was hier steht, wird auf der Anfahrt gelesen — oft vom Beifahrersitz aus.
/// Deshalb sind Zufahrt, Gefahren und Schlüsseldepot eigene Felder und keine
/// Fließtextnotiz: im Einsatz sucht niemand in einem Absatz.
model Objekt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(200)
kategorie ObjektKategorie @default(SONSTIGES)
strasse String? @db.VarChar(200)
hausnummer String? @db.VarChar(20)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
breitengrad Decimal? @db.Decimal(9, 6)
laengengrad Decimal? @db.Decimal(9, 6)
koordinaten Unsupported("geography(Point, 4326)")?
/// Wie man hinkommt: Tordurchfahrt, Höhenbegrenzung, Aufstellfläche.
zufahrt String? @db.VarChar(2000)
/// Was einen erwartet: Photovoltaik, Gasflaschen, Silo, Tiere.
gefahren String? @db.VarChar(2000)
/// Wo der Schlüssel liegt und wie man drankommt.
schluesseldepot String? @db.VarChar(500)
/// Löschwasser am Objekt: Wandhydranten, Sprinklerzentrale, Steigleitung.
loeschwasser String? @db.VarChar(1000)
bemerkung String? @db.VarChar(2000)
/// Anzahl der Personen, mit denen zu rechnen ist (Schule, Heim, Betrieb).
personenzahl Int?
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)
kontakte Objektkontakt[]
plaene Objektplan[]
@@index([organisationId, kategorie])
@@index([organisationId, name])
@@map("objekt")
}
/// Wen man beim Objekt anruft.
model Objektkontakt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
objektId String @db.Uuid
name String @db.VarChar(200)
funktion String? @db.VarChar(120)
telefon String? @db.VarChar(60)
mobil String? @db.VarChar(60)
/// Erreichbar rund um die Uhr? Im Einsatz die einzige Frage, die zählt.
jederzeit Boolean @default(false)
bemerkung String? @db.VarChar(500)
reihenfolge Int @default(0)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
@@index([organisationId, objektId])
@@map("objektkontakt")
}
/// Ein Blatt des Feuerwehrplans nach DIN 14095.
model Objektplan {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
objektId String @db.Uuid
dateiId String @db.Uuid
art PlanArt @default(UEBERSICHTSPLAN)
bezeichnung String @db.VarChar(200)
/// Stand des Plans — ein Feuerwehrplan von 2009 ist eine Vermutung.
standAm DateTime? @db.Date
reihenfolge Int @default(0)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
datei Datei @relation("ObjektplanDatei", fields: [dateiId], references: [id], onDelete: Restrict)
@@index([organisationId, objektId])
@@map("objektplan")
}
/// Die Lagekarte eines Einsatzes.
///
/// Eine je Einsatz. Sie ist der gemeinsame Zeichenblock: was einer einträgt,
/// sehen die anderen — verteilt über denselben Ereignisstrom wie die
/// Atemschutzüberwachung aus Phase 5.
model Lagekarte {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
einsatzId String @unique @db.Uuid
/// Kartenausschnitt, damit alle denselben Blick haben.
breitengrad Decimal? @db.Decimal(9, 6)
laengengrad Decimal? @db.Decimal(9, 6)
zoomstufe Int?
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)
elemente Lageelement[]
@@map("lagekarte")
}
/// Ein Element auf der Lagekarte.
///
/// Die Geometrie steht als JSON: ein Punkt für ein taktisches Zeichen, eine
/// Liste von Punkten für Linie und Fläche. PostGIS wäre hier zu viel —
/// gerechnet wird damit nichts, es wird nur gezeichnet.
///
/// Gelöscht wird weich: eine Lagekarte ist die Mitschrift einer Entscheidung,
/// und was jemand um 14:12 eingezeichnet hat, gehört auch dann noch dazu, wenn
/// es um 14:20 nicht mehr stimmte.
model Lageelement {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
lagekarteId String @db.Uuid
art LageelementArt @default(ZEICHEN)
/// Schlüssel aus dem Zeichensatz, etwa "loeschfahrzeug" oder "brandstelle".
zeichen String? @db.VarChar(60)
text String? @db.VarChar(300)
farbe String? @db.VarChar(20)
/// [[laenge, breite], …] in WGS 84.
geometrie Json @db.JsonB
/// Drehung eines Zeichens in Grad — ein Fahrzeug steht selten nach Norden.
drehung Int @default(0)
erstelltVonId 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)
lagekarte Lagekarte @relation(fields: [lagekarteId], references: [id], onDelete: Cascade)
erstelltVon Person? @relation("LageelementZeichner", fields: [erstelltVonId], references: [id], onDelete: SetNull)
@@index([organisationId, lagekarteId, geloeschtAm])
@@map("lageelement")
}
/// Eine geplante Wasserförderstrecke.
///
/// Die Rechnung dahinter ist Physik mit klaren Formeln (siehe
/// lib/karte/wasserfoerderung.ts) — deshalb steht hier nur, woraus gerechnet
/// wurde. Das Ergebnis wird nicht gespeichert, sondern bei jeder Anzeige neu
/// gerechnet: eine gespeicherte Zahl neben geänderten Eingaben wäre falsch,
/// ohne falsch auszusehen.
model Wasserfoerderung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
einsatzId String? @db.Uuid
bezeichnung String @db.VarChar(200)
/// Schlauchdurchmesser in Millimetern: 75 (B) oder 110 (A).
durchmesser Int @default(75)
/// Geplante Fördermenge in Litern je Minute.
foerdermenge Int @default(800)
/// Ausgangsdruck der ersten Pumpe in bar.
ausgangsdruck Decimal @default(8) @db.Decimal(4, 1)
/// Mindestdruck am Eingang jeder weiteren Pumpe in bar.
eingangsdruck Decimal @default(1.5) @db.Decimal(4, 1)
bemerkung String? @db.VarChar(2000)
geplantVonId 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)
geplantVon Person? @relation("WasserfoerderungPlaner", fields: [geplantVonId], references: [id], onDelete: SetNull)
punkte Foerderpunkt[]
@@index([organisationId, einsatzId])
@@map("wasserfoerderung")
}
/// Ein Punkt der Förderstrecke.
model Foerderpunkt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
wasserfoerderungId String @db.Uuid
reihenfolge Int
art FoerderpunktArt @default(WEGPUNKT)
bezeichnung String? @db.VarChar(200)
breitengrad Decimal @db.Decimal(9, 6)
laengengrad Decimal @db.Decimal(9, 6)
/// Höhe über Normalnull in Metern. Der Höhenunterschied kostet Druck:
/// zehn Meter Steigung sind ein bar.
hoehe Decimal? @db.Decimal(7, 2)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
wasserfoerderung Wasserfoerderung @relation(fields: [wasserfoerderungId], references: [id], onDelete: Cascade)
@@unique([wasserfoerderungId, reihenfolge])
@@index([organisationId, wasserfoerderungId])
@@map("foerderpunkt")
}
// =============================================================================
// Phase 7 — Benachrichtigungen und Termine
//
// Zwei Themen, die zusammengehören: ein Termin ist der häufigste Anlass für
// eine Benachrichtigung, und eine Benachrichtigung ohne Termin dahinter ist
// meistens eine Frist.
//
// Der Aufbau folgt einer Regel: **niemand wird ungefragt benachrichtigt.**
// Jede Person entscheidet je Ereignis und Kanal selbst. Die Wehr legt fest,
// was es überhaupt gibt (Vorlagen) und wen es betrifft (Rollen) — ob
// zugestellt wird, entscheidet die Person.
// =============================================================================
/// Wofür es eine Benachrichtigung geben kann.
enum EreignisTyp {
PRUEFUNG_FAELLIG
MANGEL_GEMELDET
QUALIFIKATION_LAEUFT_AB
TERMIN_NEU
TERMIN_ERINNERUNG
TERMIN_ABGESAGT
EINSATZBERICHT_FREIGEGEBEN
}
/// Wie zugestellt wird.
enum Kanal {
/// SMTP über den eigenen Mailserver.
MAIL
/// Telegram-Bot. Externer Dienst — nur Hinweise ohne Personenbezug.
TELEGRAM
/// ntfy, selbst gehostet. Der einzige Push-Weg ohne fremden Server.
NTFY
/// Web-Push über den Browser. Braucht VAPID-Schlüssel.
WEBPUSH
}
/// Was für ein Termin.
enum TerminArt {
DIENST
UEBUNG
LEHRGANG
ARBEITSDIENST
VERANSTALTUNG
SITZUNG
SONSTIGES
}
/// Wie oft ein Termin wiederkehrt.
enum Serienmuster {
KEINE
WOECHENTLICH
ZWEIWOECHENTLICH
MONATLICH
JAEHRLICH
}
/// Was jemand zu einem Termin gesagt hat.
enum Teilnahmestand {
ZUGESAGT
ABGESAGT
/// Angemeldet, aber die Liste war voll — rückt nach, wenn jemand absagt.
WARTELISTE
}
/// Wie es einer Benachrichtigung ergangen ist.
enum Zustellstand {
WARTET
GESENDET
FEHLGESCHLAGEN
/// Niemand hat diesen Kanal abonniert oder der Kanal ist aus.
UEBERSPRUNGEN
}
/// Eine Vorlage je Ereignis und Kanal.
///
/// Der Text enthält Platzhalter in geschweiften Klammern: `{titel}`, `{datum}`,
/// `{wehr}`. Welche es gibt, steht in lib/benachrichtigung/vorlagen.ts — und
/// die Oberfläche zeigt sie beim Bearbeiten an, damit niemand raten muss.
///
/// Fehlt eine Vorlage, greift der eingebaute Standardtext. Eine Wehr, die
/// nichts einstellt, bekommt trotzdem brauchbare Nachrichten.
model Benachrichtigungsvorlage {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
ereignis EreignisTyp
kanal Kanal
/// Nur für E-Mail. Push-Dienste kennen keinen Betreff.
betreff String? @db.VarChar(200)
text String @db.VarChar(2000)
/// Wen es angeht. Leer heißt: alle, die es abonniert haben.
rollen RolleSchluessel[]
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
@@unique([organisationId, ereignis, kanal])
@@map("benachrichtigungsvorlage")
}
/// Wohin eine Person benachrichtigt werden will.
///
/// Ein Datensatz je Person und Kanal, mit der Adresse dieses Kanals: die
/// Chat-Kennung bei Telegram, das Thema bei ntfy, das Abonnement des Browsers
/// bei Web-Push. Die E-Mail steht schon an der Person.
///
/// `ereignisse` sagt, wofür dieser Kanal benutzt wird. Leer heißt: für nichts —
/// **niemand wird ungefragt benachrichtigt.**
model Zustellkanal {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
kanal Kanal
/// Chat-Kennung, ntfy-Thema oder die Endpunktadresse des Browsers.
ziel String @db.VarChar(500)
/// Web-Push braucht zwei Schlüssel dazu.
p256dh String? @db.VarChar(200)
auth String? @db.VarChar(200)
/// Wofür dieser Kanal benutzt wird.
ereignisse EreignisTyp[]
bezeichnung String? @db.VarChar(120)
aktiv Boolean @default(true)
/// Wann zuletzt etwas darüber ankam — ein toter Kanal fällt sonst nicht auf.
letzteZustellung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
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("ZustellkanalPerson", fields: [personId], references: [id], onDelete: Cascade)
@@unique([personId, kanal, ziel])
@@index([organisationId, kanal])
@@map("zustellkanal")
}
/// Eine verschickte oder zu verschickende Benachrichtigung.
///
/// Sie steht **vor** dem Versand in der Tabelle, nicht danach: der
/// Hintergrundprozess arbeitet sie ab und vermerkt, wie es ausging. Damit ist
/// nachvollziehbar, wer wann worüber informiert wurde — und wer nicht, weil
/// sein Kanal nicht erreichbar war.
model Benachrichtigung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
ereignis EreignisTyp
kanal Kanal
personId String @db.Uuid
betreff String? @db.VarChar(200)
text String @db.VarChar(2000)
/// Wohin es ging — zum Nachvollziehen, auch wenn der Kanal später wegfällt.
ziel String @db.VarChar(500)
/// Worum es ging: "Wartungstermin", "Termin", "Mangel" …
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
stand Zustellstand @default(WARTET)
versuche Int @default(0)
gesendetAm DateTime? @db.Timestamptz(3)
fehler String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("BenachrichtigungEmpfaenger", fields: [personId], references: [id], onDelete: Cascade)
@@index([organisationId, stand])
@@index([organisationId, personId, erstelltAm(sort: Desc)])
@@map("benachrichtigung")
}
/// Ein Termin im Kalender der Wehr.
///
/// Serientermine werden **ausgeschrieben**, nicht gerechnet: jeder Abend ist
/// ein eigener Datensatz mit Verweis auf den ersten. Sonst ließe sich ein
/// einzelner Abend nicht absagen, keine Teilnehmerliste führen und keine
/// Anwesenheit abhaken — und genau das ist der Alltag.
model Termin {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
art TerminArt @default(DIENST)
titel String @db.VarChar(200)
beschreibung String? @db.VarChar(4000)
ort String? @db.VarChar(200)
beginn DateTime @db.Timestamptz(3)
ende DateTime? @db.Timestamptz(3)
ganztaegig Boolean @default(false)
/// Wer ihn leitet.
leiterId String? @db.Uuid
/// Ab wie vielen Zusagen die Warteliste beginnt. Null heißt: unbegrenzt.
teilnehmerLimit Int?
/// Bis wann man sich anmelden kann.
anmeldeschluss DateTime? @db.Timestamptz(3)
/// Erwartet die Wehr eine Rückmeldung? Sonst ist es nur ein Eintrag.
anmeldungNoetig Boolean @default(false)
/// Serie: der erste Termin trägt das Muster, alle tragen dieselbe Serienkennung.
serieId String? @db.Uuid
serienmuster Serienmuster @default(KEINE)
/// Abgesagt statt gelöscht — wer schon zugesagt hat, soll den Grund sehen.
abgesagtAm DateTime? @db.Timestamptz(3)
absageGrund String? @db.VarChar(500)
/// Wann erinnert wurde, damit es nicht zweimal passiert.
erinnertAm 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)
leiter Person? @relation("TerminLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
anmeldungen Terminanmeldung[]
@@index([organisationId, beginn])
@@index([organisationId, serieId])
@@map("termin")
}
/// Zu- oder Absage einer Person zu einem Termin.
model Terminanmeldung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
terminId String @db.Uuid
personId String @db.Uuid
stand Teilnahmestand @default(ZUGESAGT)
/// Bei einer Absage: warum. Freiwillig, aber hilfreich für die Planung.
grund String? @db.VarChar(300)
/// Beim Abhaken der Anwesenheitsliste gesetzt.
anwesend Boolean?
gemeldetAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
person Person @relation("TerminanmeldungPerson", fields: [personId], references: [id], onDelete: Cascade)
@@unique([terminId, personId])
@@index([organisationId, personId])
@@map("terminanmeldung")
}
/// Ein Kalenderabonnement je Person.
///
/// Der Kalender im Handy fragt die Adresse regelmäßig ab — ohne Anmeldung,
/// also mit einem Geheimnis in der Adresse. Wie beim Einsatzportal aus Phase 3
/// liegt davon nur der Hash in der Datenbank: wer die Tabelle liest, bekommt
/// keinen Zugang.
model Kalenderzugang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
bezeichnung String @db.VarChar(120)
/// SHA-256 des Tokens. Das Token selbst sieht man genau einmal.
tokenHash String @db.VarChar(64)
/// Nur eigene Termine oder alle der Wehr?
nurEigene Boolean @default(false)
aktiv Boolean @default(true)
letzterZugriff DateTime? @db.Timestamptz(3)
zugriffe Int @default(0)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
widerrufenAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("KalenderzugangPerson", fields: [personId], references: [id], onDelete: Cascade)
@@unique([tokenHash])
@@index([organisationId, personId])
@@map("kalenderzugang")
}
// =============================================================================
// Phase 8 — Lokale KI
//
// Der Satz, der über allen Tabellen hier steht und der ihren Aufbau bestimmt:
// **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.**
//
// Daraus folgt, dass jede KI-Ausgabe zuerst ein eigener Datensatz ist und
// nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst,
// wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf
// daneben und ist als Entwurf erkennbar. Das ist der Unterschied zwischen
// einer Hilfe und einer Fälschung.
//
// Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper
// als Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die
// das Gerätehaus verlässt. Ohne die Container fehlt die Funktion — sie wird
// nicht durch einen fremden Dienst ersetzt.
// =============================================================================
/// Wofür ein Modell befragt wurde.
enum KiArt {
/// Sprache zu Text (faster-whisper).
DIKTAT
/// Aus Stichpunkten einen Einsatzberichtsentwurf.
BERICHTSENTWURF
/// Frage ans Wiki, beantwortet aus den eigenen Seiten.
WIKI_ANTWORT
/// Quittungsfoto zu Betrag, Datum, Händler.
BELEGERFASSUNG
/// Text für die semantische Suche einbetten.
EINBETTUNG
}
/// Was aus einem Vorschlag geworden ist.
///
/// ENTWURF ist der einzige Zustand, den die Maschine allein erreicht. Alles
/// andere setzt ein Mensch — und genau deshalb steht es hier und nicht als
/// Häkchen irgendwo.
enum KiStand {
ENTWURF
UEBERNOMMEN
VERWORFEN
FEHLGESCHLAGEN
}
/// Womit eine Wiki-Seite zu tun hat.
enum WikiKategorie {
DIENSTVORSCHRIFT
AUSBILDUNG
GERAETE
EINSATZ
ORGANISATION
SONSTIGES
}
/// Jede Befragung eines Modells, mit dem, was dabei herauskam.
///
/// Die Tabelle ist der Nachweis, dass die Regel eingehalten wurde: zu jedem
/// Text, der aus einem Modell stammt, steht hier, wer ihn wann übernommen oder
/// verworfen hat. Ohne sie ließe sich hinterher nicht mehr sagen, welcher Satz
/// in einem Einsatzbericht von einem Menschen stammt.
///
/// Gespeichert wird auch, was nicht geklappt hat. Ein Modell, das dreimal am
/// Tag in eine Zeitüberschreitung läuft, ist eine Information.
model KiVorgang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
art KiArt
stand KiStand @default(ENTWURF)
/// Welches Modell — bei einem Wechsel soll nachvollziehbar bleiben, womit
/// ein alter Entwurf entstanden ist.
modell String @db.VarChar(120)
/// Worauf sich der Vorgang bezieht: "Einsatz", "Uebung", "Wikiseite".
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
/// Die Eingabe, gekürzt. Nicht das ganze Audio, nicht das ganze Foto —
/// die liegen als Datei in MinIO.
eingabe String @db.VarChar(8000)
ausgabe String? @db.VarChar(40000)
fehler String? @db.VarChar(1000)
dauerMs Int?
tokenEingabe Int?
tokenAusgabe Int?
erzeugtVonId String? @db.Uuid
/// Wer entschieden hat — und wann. Beides leer heißt: noch offen.
entschiedenVonId String? @db.Uuid
entschiedenAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
erzeugtVon Person? @relation("KiVorgangErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
entschiedenVon Person? @relation("KiVorgangEntscheider", fields: [entschiedenVonId], references: [id], onDelete: SetNull)
diktate Diktat[]
belegentwuerfe Belegentwurf[]
@@index([organisationId, art, erstelltAm(sort: Desc)])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, stand])
@@map("ki_vorgang")
}
/// Eine Seite im Wiki der Wehr.
///
/// Inhalt ist Markdown, weil es sich ohne Werkzeug lesen lässt — auch in
/// zwanzig Jahren, auch ohne FLORIAN. Dienstvorschriften und
/// Ausbildungsunterlagen sollen die Anwendung überleben, die sie verwaltet.
model Wikiseite {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
slug String @db.VarChar(160)
titel String @db.VarChar(200)
art WikiKategorie @default(SONSTIGES)
inhalt String @db.Text
/// Woher es stammt: "FwDV 7, Ausgabe 2002", "Beschluss der Kommandanten".
/// Steht unter jeder Antwort, die aus dieser Seite gebaut wird.
quelle String? @db.VarChar(300)
schlagworte String[]
/// Unveröffentlichte Seiten sind Entwürfe: sie werden nicht durchsucht und
/// nicht zum Beantworten herangezogen.
veroeffentlicht Boolean @default(true)
autorId String? @db.Uuid
/// Wann die Abschnitte zuletzt eingebettet wurden. Liegt es vor
/// `geaendertAm`, ist die Seite seit der letzten Änderung nicht mehr
/// durchsuchbar — und das steht dann auch in der Ansicht.
eingebettetAm 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)
autor Person? @relation("WikiseiteAutor", fields: [autorId], references: [id], onDelete: SetNull)
abschnitte Wikiabschnitt[]
versionen Wikiversion[]
@@unique([organisationId, slug])
@@index([organisationId, art])
@@map("wikiseite")
}
/// Ein Abschnitt einer Wiki-Seite samt seiner Einbettung.
///
/// Zerlegt wird an den Überschriften, nicht nach Zeichenzahl: ein Abschnitt
/// soll für sich verständlich sein, weil er für sich zitiert wird. Wer eine
/// Antwort bekommt, bekommt die Fundstelle dazu — und die ist dann ein
/// Kapitel, kein aus der Mitte gerissener Absatz.
///
/// `einbettung` ist für Prisma ein unbekannter Typ; gelesen und geschrieben
/// wird sie über `$queryRaw`. Das ist der übliche Weg mit pgvector und der
/// Grund, warum es in lib/ki/ eine eigene Datei dafür gibt.
model Wikiabschnitt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
reihenfolge Int
ueberschrift String? @db.VarChar(300)
text String @db.Text
/// 768 Dimensionen — das liefert nomic-embed-text, das Standardmodell für
/// Einbettungen unter Ollama. Ein anderes Modell mit anderer Länge braucht
/// eine Migration; das steht im README.
einbettung Unsupported("vector(768)")?
/// Womit eingebettet wurde. Nach einem Modellwechsel sind alte Einbettungen
/// nicht mehr vergleichbar und werden neu erzeugt.
modell String? @db.VarChar(120)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
@@unique([seiteId, reihenfolge])
@@index([organisationId])
@@map("wikiabschnitt")
}
/// Der Stand einer Seite vor einer Änderung.
///
/// Eine Dienstvorschrift, die sich unbemerkt ändert, ist schlimmer als keine.
/// Deshalb wird bei jeder Änderung der vorherige Text weggeschrieben — schlicht
/// und vollständig, nicht als Differenz.
model Wikiversion {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
nummer Int
titel String @db.VarChar(200)
inhalt String @db.Text
/// Was der Bearbeiter dazu gesagt hat.
bemerkung String? @db.VarChar(500)
autorId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
autor Person? @relation("WikiversionAutor", fields: [autorId], references: [id], onDelete: SetNull)
@@unique([seiteId, nummer])
@@index([organisationId, seiteId])
@@map("wikiversion")
}
/// Eine Aufnahme und was daraus wurde.
///
/// Gedacht für den Weg, den es heute schon gibt: der Einsatzleiter spricht auf
/// der Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Nur dass
/// jetzt schon ein Text dasteht, den jemand nur noch geradezieht.
///
/// Die Aufnahme bleibt liegen, bis sie jemand entfernt: solange das Transkript
/// noch nicht bestätigt ist, ist sie das einzige Original.
model Diktat {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Die Aufnahme in MinIO. Nie ein Pfad im Dateisystem.
audioDateiId String @db.Uuid
dauerSekunden Int?
sprache String @default("de") @db.VarChar(10)
transkript String? @db.Text
stand KiStand @default(ENTWURF)
/// Wohin der Text soll, wenn er übernommen wird.
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
kiVorgangId String? @db.Uuid
erzeugtVonId 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)
audioDatei Datei @relation("DiktatAufnahme", fields: [audioDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erzeugtVon Person? @relation("DiktatErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, entitaet, entitaetId])
@@map("diktat")
}
/// Was ein Modell auf einer Quittung gelesen hat — als Vorschlag.
///
/// Bewusst eine eigene Tabelle und keine Buchung: die Kasse kommt in Phase 9,
/// und ein Betrag, den eine Maschine aus einem Foto gelesen hat, ist keine
/// Buchung. Er ist ein ausgefülltes Formular, das jemand gegenzeichnet.
///
/// Alle Felder sind optional. Ein Modell, das den Betrag nicht findet, soll
/// nichts erfinden — dann bleibt das Feld leer und ein Mensch trägt es ein.
model Belegentwurf {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fotoDateiId String @db.Uuid
betrag Decimal? @db.Decimal(12, 2)
waehrung String @default("EUR") @db.VarChar(3)
belegdatum DateTime? @db.Date
haendler String? @db.VarChar(200)
kategorie String? @db.VarChar(80)
/// Steuersatz in Prozent, falls erkannt.
steuersatz Decimal? @db.Decimal(5, 2)
/// Was das Modell geantwortet hat, unverändert. Für den Fall, dass die
/// Auswertung daneben lag und jemand nachsehen will, woran es lag.
rohantwort Json?
stand KiStand @default(ENTWURF)
kiVorgangId String? @db.Uuid
erfasstVonId String? @db.Uuid
bestaetigtVonId String? @db.Uuid
bestaetigtAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
/// Die Buchung, die daraus geworden ist (Phase 9). Leer heißt: noch keine.
buchungId String? @db.Uuid
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
bestaetigtVon Person? @relation("BelegentwurfBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, belegdatum])
@@map("belegentwurf")
}
// =============================================================================
// Phase 9 — Kassenverwaltung
//
// Drei Festlegungen bestimmen alles Weitere. Sie stehen hier, weil sie sich
// im Schema abbilden und nicht bloß in der Anwendung durchgesetzt werden:
//
// 1. **Beträge sind DECIMAL(12,2), niemals Fließkomma.** 0,1 + 0,2 ist in
// binärer Fließkommadarstellung nicht 0,3, und eine Kasse, die sich um
// Cent verrechnet, ist keine Kasse. Zwölf Stellen reichen für zehn
// Milliarden — mehr als jede Wehr je bewegt.
//
// 2. **Eine festgeschriebene Buchung ist unveränderbar.** Korrekturen laufen
// über eine Gegenbuchung mit Verweis auf das Original. Das ist keine
// Förmlichkeit: ein Kassenbuch, in dem man rückwirkend ändern kann, ist
// vor der Kassenprüfung wertlos. Der Trigger in der Migration setzt es
// durch.
//
// 3. **Der Kassenabschluss friert seinen Zeitraum ein.** Danach nimmt die
// Buchungsmaske kein Datum daraus mehr an. Sonst hätte der geprüfte
// Bestand am Tag nach der Prüfung eine andere Zahl.
//
// Der Betrag ist immer **positiv**; die Richtung steht in `art`. Ein
// vorzeichenbehafteter Betrag wäre kürzer, aber jede Auswertung müsste dann
// darauf vertrauen, dass das Vorzeichen zur Kategorie passt — und beim ersten
// Tippfehler stimmt die Summe nicht mehr.
// =============================================================================
/// Welche Art von Konto.
enum KontoArt {
/// Die Barkasse im Gerätehaus. Wird bei der Prüfung gezählt.
BAR
/// Ein Bankkonto. Wird gegen den Auszug geprüft.
BANK
/// Rücklage für eine bestimmte Anschaffung — getrennt geführt, damit
/// niemand versehentlich das Geld für das neue Fahrzeug verbraucht.
RUECKLAGE
}
/// Geht Geld ein oder hinaus.
enum BuchungsArt {
EINNAHME
AUSGABE
}
/// Wie es um einen Beitrag steht.
enum BeitragStand {
OFFEN
BEZAHLT
/// Erlassen — bei Härtefällen, Ehrenmitgliedern, Jugendlichen.
ERLASSEN
GEMAHNT
}
/// Wie es um eine Rechnung steht.
enum RechnungStand {
ENTWURF
VERSENDET
BEZAHLT
GEMAHNT
STORNIERT
}
/// Woraus eine Rechnung an Dritte entsteht.
enum RechnungsAnlass {
/// Böswilliger oder fahrlässig ausgelöster Fehlalarm.
FEHLALARM
/// Ölspur, auslaufende Betriebsstoffe — nach Verursacherprinzip.
OELEINSATZ
/// Absicherung, Brandsicherheitswache bei einer Veranstaltung.
VERANSTALTUNG
/// Vermietung von Gerät oder Räumen.
VERMIETUNG
SONSTIGES
}
/// Ein Konto der Wehr.
model Kassenkonto {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(120)
art KontoArt @default(BAR)
/// Nur bei Bankkonten. Wird in Auswertungen nie vollständig angezeigt.
iban String? @db.VarChar(34)
bic String? @db.VarChar(11)
/// Der Bestand zu Beginn der Aufzeichnung. Alles Weitere ergibt sich aus
/// den Buchungen — er wird nie nachträglich „korrigiert".
anfangsbestand Decimal @default(0) @db.Decimal(12, 2)
bemerkung String? @db.VarChar(500)
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)
buchungen Buchung[]
posten Abschlussposten[]
@@unique([organisationId, bezeichnung])
@@index([organisationId, aktiv])
@@map("kassenkonto")
}
/// Wofür Geld ausgegeben oder eingenommen wurde.
///
/// Zweistufig: eine Oberkategorie („Fahrzeuge") mit Unterkategorien
/// („Kraftstoff", „Reparatur"). Mehr Ebenen braucht eine Wehr nicht, und jede
/// weitere macht die Auswertung unlesbar.
model Buchungskategorie {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(120)
art BuchungsArt
/// Die Oberkategorie. Leer heißt: das hier ist eine.
oberkategorieId String? @db.Uuid
/// Nummer im Kontenrahmen der Gemeinde — für den Export an die Kämmerei.
kontonummer String? @db.VarChar(20)
bemerkung String? @db.VarChar(500)
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)
oberkategorie Buchungskategorie? @relation("Kategoriebaum", fields: [oberkategorieId], references: [id], onDelete: SetNull)
unterkategorien Buchungskategorie[] @relation("Kategoriebaum")
buchungen Buchung[]
@@unique([organisationId, name])
@@index([organisationId, art])
@@map("buchungskategorie")
}
/// Eine Buchung.
///
/// Der Betrag ist positiv, die Richtung steht in `art`. Festgeschrieben heißt:
/// unveränderbar und nicht löschbar — die Datenbank hält dagegen. Wer sich
/// vertan hat, bucht dagegen; die Gegenbuchung verweist auf das Original, und
/// beide bleiben stehen.
model Buchung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
kontoId String @db.Uuid
kategorieId String @db.Uuid
/// Das Buchungsdatum, nicht der Zeitpunkt der Erfassung. Danach richtet
/// sich, in welchen Abschluss die Buchung fällt.
datum DateTime @db.Date
art BuchungsArt
betrag Decimal @db.Decimal(12, 2)
verwendungszweck String @db.VarChar(300)
belegnummer String? @db.VarChar(60)
/// Wer das Geld bekommen oder gegeben hat. Entweder eine Person der Wehr
/// **oder** ein Dritter als Text — nie beides.
personId String? @db.Uuid
lieferant String? @db.VarChar(200)
/// Bei einer Umbuchung zwischen zwei eigenen Konten das Gegenkonto.
gegenkontoId String? @db.Uuid
belegDateiId String? @db.Uuid
/// Festgeschrieben: ab hier unveränderbar.
festgeschriebenAm DateTime? @db.Timestamptz(3)
/// Die Buchung, die diese hier aufhebt — und umgekehrt.
storniertVonId String? @db.Uuid
erfasstVonId 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)
konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict)
kategorie Buchungskategorie @relation(fields: [kategorieId], references: [id], onDelete: Restrict)
person Person? @relation("BuchungPerson", fields: [personId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BuchungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
belegDatei Datei? @relation("BuchungBeleg", fields: [belegDateiId], references: [id], onDelete: SetNull)
storniertVon Buchung? @relation("Stornierung", fields: [storniertVonId], references: [id], onDelete: SetNull)
storniert Buchung[] @relation("Stornierung")
beitraege Beitrag[]
rechnungen Rechnung[]
belegentwurf Belegentwurf[]
@@index([organisationId, datum(sort: Desc)])
@@index([organisationId, kontoId, datum])
@@index([organisationId, kategorieId])
@@map("buchung")
}
/// Was ein Mitglied im Jahr zahlt.
///
/// Je Abteilung ein Satz: Aktive zahlen anderes als Jugend, Ehrenmitglieder
/// oft nichts. Der Satz gilt ab einem Jahr und wird nicht rückwirkend
/// geändert — sonst stimmen die Beiträge vergangener Jahre nicht mehr.
model Beitragssatz {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(120)
betrag Decimal @db.Decimal(12, 2)
/// Für welche Abteilung. Leer heißt: für alle, die keinen eigenen Satz haben.
abteilung String? @db.VarChar(60)
/// Ab welchem Jahr er gilt.
abJahr Int
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
beitraege Beitrag[]
@@unique([organisationId, abteilung, abJahr])
@@index([organisationId, abJahr])
@@map("beitragssatz")
}
/// Der Beitrag einer Person für ein Jahr.
model Beitrag {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
jahr Int
satzId String? @db.Uuid
betrag Decimal @db.Decimal(12, 2)
stand BeitragStand @default(OFFEN)
/// Die Buchung, mit der bezahlt wurde. Erst sie macht aus „bezahlt" eine
/// Zahl in der Kasse.
buchungId String? @db.Uuid
bezahltAm DateTime? @db.Date
/// Wie oft gemahnt wurde. Null heißt: noch nicht.
mahnstufe Int @default(0)
gemahntAm DateTime? @db.Date
/// Bei Erlass: warum. Ein erlassener Beitrag ohne Begründung ist in der
/// Kassenprüfung eine offene Frage.
erlassGrund String? @db.VarChar(300)
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("BeitragPerson", fields: [personId], references: [id], onDelete: Cascade)
satz Beitragssatz? @relation(fields: [satzId], references: [id], onDelete: SetNull)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
@@unique([personId, jahr])
@@index([organisationId, jahr, stand])
@@map("beitrag")
}
/// Eine Rechnung an Dritte.
///
/// Fehlalarm, Ölspur, Absicherung einer Veranstaltung. Nummer aus dem
/// Nummernkreis (`JJJJ-NNNN`), PDF über Gotenberg.
model Rechnung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
nummer String @db.VarChar(20)
anlass RechnungsAnlass @default(SONSTIGES)
stand RechnungStand @default(ENTWURF)
/// An wen. Bewusst als Text und nicht als Verweis: der Empfänger ist in
/// aller Regel niemand, den die Wehr dauerhaft führt.
empfaenger String @db.VarChar(200)
empfaengerZusatz String? @db.VarChar(200)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
/// Worauf sie sich bezieht.
einsatzId String? @db.Uuid
datum DateTime @db.Date
faelligAm DateTime @db.Date
/// Summe der Positionen. Wird beim Speichern gerechnet, nicht eingegeben.
betrag Decimal @db.Decimal(12, 2)
bemerkung String? @db.VarChar(1000)
/// Die Buchung des Zahlungseingangs.
buchungId String? @db.Uuid
bezahltAm DateTime? @db.Date
gemahntAm DateTime? @db.Date
mahnstufe Int @default(0)
pdfDateiId String? @db.Uuid
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)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
pdfDatei Datei? @relation("RechnungPdf", fields: [pdfDateiId], references: [id], onDelete: SetNull)
erstelltVon Person? @relation("RechnungErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
positionen Rechnungsposition[]
@@unique([organisationId, nummer])
@@index([organisationId, stand])
@@index([organisationId, datum(sort: Desc)])
@@map("rechnung")
}
/// Eine Zeile auf einer Rechnung.
model Rechnungsposition {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rechnungId String @db.Uuid
reihenfolge Int
bezeichnung String @db.VarChar(300)
/// Menge mit drei Nachkommastellen — Einsatzstunden werden viertelstündlich
/// abgerechnet, und 0,25 muss genau sein.
menge Decimal @default(1) @db.Decimal(12, 3)
einheit String? @db.VarChar(20)
einzelpreis Decimal @db.Decimal(12, 2)
/// Menge × Einzelpreis, beim Speichern gerechnet.
gesamtpreis Decimal @db.Decimal(12, 2)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rechnung Rechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
@@unique([rechnungId, reihenfolge])
@@index([organisationId])
@@map("rechnungsposition")
}
/// Ein Kassenabschluss.
///
/// Der Vorgang, für den es die ganze Kasse gibt: zwei Kassenprüfer setzen sich
/// hin, zählen das Bargeld, vergleichen die Kontoauszüge und unterschreiben.
/// Danach ist der Zeitraum zu — die Buchungsmaske nimmt kein Datum daraus mehr
/// an.
///
/// Zwei Prüfer, weil es die Satzung fast jeder Wehr so vorsieht. Der zweite
/// ist trotzdem optional: eine Wehr, die gerade nur einen hat, soll deshalb
/// nicht ohne Abschluss dastehen.
model Kassenabschluss {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
vonAm DateTime @db.Date
bisAm DateTime @db.Date
/// Summe über alle Konten, aus den Buchungen gerechnet.
bestandLautBuch Decimal @db.Decimal(12, 2)
/// Was tatsächlich gezählt wurde.
gezaehlterBestand Decimal @db.Decimal(12, 2)
/// gezaehlt minus laut Buch. Positiv heißt: es war mehr da.
differenz Decimal @db.Decimal(12, 2)
/// Eine Differenz ohne Erklärung ist der Punkt, an dem eine Kassenprüfung
/// hängen bleibt.
erlaeuterung String? @db.VarChar(2000)
prueferId String? @db.Uuid
zweiterPrueferId String? @db.Uuid
geprueftAm DateTime? @db.Date
berichtDateiId String? @db.Uuid
/// Festgeschrieben: ab hier ist der Zeitraum zu.
festgeschriebenAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
pruefer Person? @relation("AbschlussPruefer", fields: [prueferId], references: [id], onDelete: SetNull)
zweiterPruefer Person? @relation("AbschlussZweitpruefer", fields: [zweiterPrueferId], references: [id], onDelete: SetNull)
berichtDatei Datei? @relation("AbschlussBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
posten Abschlussposten[]
@@index([organisationId, bisAm(sort: Desc)])
@@map("kassenabschluss")
}
/// Was auf einem Konto zum Abschluss stand.
///
/// Je Konto eine Zeile: der Kassenprüfer zählt das Bargeld und liest den
/// Kontostand vom Auszug ab. Eine einzelne Summe über alles würde verbergen,
/// auf welchem Konto die Differenz sitzt.
model Abschlussposten {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
abschlussId String @db.Uuid
kontoId String @db.Uuid
bestandLautBuch Decimal @db.Decimal(12, 2)
gezaehlterBestand Decimal @db.Decimal(12, 2)
differenz Decimal @db.Decimal(12, 2)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
abschluss Kassenabschluss @relation(fields: [abschlussId], references: [id], onDelete: Cascade)
konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict)
@@unique([abschlussId, kontoId])
@@index([organisationId])
@@map("abschlussposten")
}