4171 lines
152 KiB
Plaintext
4171 lines
152 KiB
Plaintext
// =============================================================================
|
||
// FLORIAN — Datenmodell, Phase 0 (Fundament)
|
||
//
|
||
// Grundregeln, die für JEDES Modell gelten:
|
||
// 1. Mandantentrennung: jede Tabelle trägt organisationId. Postgres Row Level
|
||
// Security setzt das durch (siehe migrations/.../rls). Ausnahme ist der
|
||
// systemweite Berechtigungskatalog, siehe Kommentar dort.
|
||
// 2. Jede schreibende Operation erzeugt einen AuditLog-Eintrag (lib/audit.ts).
|
||
// 3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht:
|
||
// geloeschtAm setzen statt DELETE.
|
||
// 4. Alle Zeitstempel in UTC speichern, Anzeige in Europe/Berlin.
|
||
//
|
||
// Bezeichner sind englisch, wo es Technik ist, und deutsch, wo es Fachlichkeit
|
||
// ist — so wie es in der Wehr auch gesprochen wird.
|
||
// =============================================================================
|
||
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"]
|
||
previewFeatures = ["postgresqlExtensions"]
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
url = env("DATABASE_URL")
|
||
directUrl = env("DIRECT_DATABASE_URL")
|
||
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm"), 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")
|
||
}
|