// ============================================================================= // FLORIAN — Datenmodell, Phase 0 (Fundament) // // Grundregeln, die für JEDES Modell gelten: // 1. Mandantentrennung: jede Tabelle trägt organisationId. Postgres Row Level // Security setzt das durch (siehe migrations/.../rls). Ausnahme ist der // systemweite Berechtigungskatalog, siehe Kommentar dort. // 2. Jede schreibende Operation erzeugt einen AuditLog-Eintrag (lib/audit.ts). // 3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht: // geloeschtAm setzen statt DELETE. // 4. Alle Zeitstempel in UTC speichern, Anzeige in Europe/Berlin. // // Bezeichner sind englisch, wo es Technik ist, und deutsch, wo es Fachlichkeit // ist — so wie es in der Wehr auch gesprochen wird. // ============================================================================= generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"] previewFeatures = ["postgresqlExtensions"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_DATABASE_URL") extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")] } // ============================================================================= // Aufzählungen // ============================================================================= /// Rollen im Sinne der Feuerwehrorganisation. Die Berechtigungen dahinter /// stehen in lib/authz.ts und werden beim Seed in die Datenbank gespiegelt. enum RolleSchluessel { SUPERADMIN KOMMANDANT GERAETEWART ATEMSCHUTZGERAETEWART KASSIER SCHRIFTFUEHRER AUSBILDER MITGLIED KIOSK } /// Art des Ausweises, mit dem sich jemand am Terminal im Gerätehaus anmeldet. enum AusweisTokenTyp { PIN RFID } /// Wie sich jemand angemeldet hat — landet im Audit-Log. enum AnmeldeArt { LOKAL OIDC KIOSK_PIN KIOSK_RFID SYSTEM } /// Aktionen im Audit-Log. enum AuditAktion { ERSTELLT GEAENDERT GELOESCHT WIEDERHERGESTELLT ANGEMELDET ABGEMELDET ANMELDUNG_FEHLGESCHLAGEN EXPORTIERT DATEI_HOCHGELADEN DATEI_ABGERUFEN BERECHTIGUNG_GEAENDERT } /// Wer die Aktion ausgelöst hat. enum AkteurTyp { PERSON SYSTEM WORKER KIOSK API } /// Betriebsstatus eines Geräts nach DGUV-Logik. enum GeraetStatus { EINSATZBEREIT PRUEFUNG_FAELLIG GESPERRT AUSGESONDERT IN_REPARATUR } /// Grobe Fahrzeugkategorie. Die vollständige DIN-Typisierung kommt in Phase 1. enum FahrzeugArt { LOESCHFAHRZEUG DREHLEITER RUESTWAGEN GERAETEWAGEN MANNSCHAFTSTRANSPORT EINSATZLEITWAGEN WECHSELLADER ANHAENGER SONSTIGES } /// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl). enum Dienstgrad { FEUERWEHRANWAERTER FEUERWEHRMANN OBERFEUERWEHRMANN HAUPTFEUERWEHRMANN LOESCHMEISTER OBERLOESCHMEISTER HAUPTLOESCHMEISTER BRANDMEISTER OBERBRANDMEISTER HAUPTBRANDMEISTER KEIN } // ============================================================================= // Mandant // ============================================================================= /// Eine Feuerwehr. Oberste Klammer — an ihr hängt alles andere. model Organisation { id String @id @default(uuid()) @db.Uuid /// Kurzform für URLs und Konfiguration, z. B. "ff-musterhausen". slug String @unique @db.VarChar(64) name String @db.VarChar(200) kurzname String @db.VarChar(60) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) landkreis String? @db.VarChar(120) bundesland String @default("Bayern") @db.VarChar(60) telefon String? @db.VarChar(40) email String? @db.VarChar(200) /// Kennung im Einsatzleitsystem der Integrierten Leitstelle. ilsKennung String? @db.VarChar(40) /// Funkrufname-Präfix der Wehr, z. B. "Florian Musterhausen". funkPraefix String? @db.VarChar(80) zeitzone String @default("Europe/Berlin") @db.VarChar(60) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) personen Person[] rollen Rolle[] personRollen PersonRolle[] rolleRechte RolleBerechtigung[] ausweisTokens AusweisToken[] auditEintraege AuditLog[] dateien Datei[] fahrzeuge Fahrzeug[] geraete Geraet[] standorte Standort[] geraeteraeume Geraeteraum[] geraetetypen Geraetetyp[] qualifikationstypen Qualifikationstyp[] qualifikationen Qualifikation[] // Phase 2 — Wartung und Prüfung wartungsvorlagen Wartungsvorlage[] checklistenpunkte Checklistenpunkt[] wartungstermine Wartungstermin[] pruefungen Pruefung[] pruefergebnisse Pruefergebnis[] signaturen Signatur[] maengel Mangel[] rueckrufe Rueckruf[] rueckrufTypen RueckrufGeraetetyp[] rueckrufBetroffenheiten RueckrufBetroffenheit[] erinnerungen Erinnerung[] // Phase 3 — Einsätze und Übungen einsaetze Einsatz[] einsatzfahrzeuge Einsatzfahrzeug[] einsatzkraefte Einsatzkraft[] materialverbrauch Materialverbrauch[] lagemeldungen Lagemeldung[] portalzugaenge PortalZugang[] uebungen Uebung[] uebungsteilnahmen Uebungsteilnahme[] // Phase 4 — Terminal im Gerätehaus fahrten Fahrt[] anwesenheiten Anwesenheit[] waescheabgaben Waescheabgabe[] inventurgaenge Inventurgang[] inventurposten Inventurposten[] kioskvorgaenge KioskVorgang[] syncKonflikte SyncKonflikt[] // Phase 5 — Atemschutz nach FwDV 7 atemschutzeinsaetze Atemschutzeinsatz[] atemschutztrupps Atemschutztrupp[] truppmitglieder Truppmitglied[] druckmessungen Druckmessung[] atemschutzereignisse AtemschutzEreignis[] g26Untersuchungen G26Untersuchung[] belastungsuebungen Belastungsuebung[] // Phase 6 — Kartenmodule hydranten Hydrant[] objekte Objekt[] objektkontakte Objektkontakt[] objektplaene Objektplan[] lagekarten Lagekarte[] lageelemente Lageelement[] wasserfoerderungen Wasserfoerderung[] foerderpunkte Foerderpunkt[] @@index([aktiv]) @@map("organisation") } // ============================================================================= // Personen und Rechte // ============================================================================= /// Ein Mitglied der Wehr. Gleichzeitig der Anmeldebenutzer — es gibt bewusst /// keine getrennte "User"-Tabelle, damit Dienstgrad, Qualifikation und Zugang /// nicht auseinanderlaufen. model Person { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid vorname String @db.VarChar(100) nachname String @db.VarChar(100) /// Fortlaufende Mitgliedsnummer innerhalb der Wehr. mitgliedsnummer String? @db.VarChar(30) geburtsdatum DateTime? @db.Date dienstgrad Dienstgrad @default(KEIN) email String? @db.VarChar(200) telefon String? @db.VarChar(40) mobil String? @db.VarChar(40) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) eintrittsdatum DateTime? @db.Date austrittsdatum DateTime? @db.Date /// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied. abteilung String? @db.VarChar(60) /// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird. passwortHash String? @db.VarChar(120) passwortGeaendertAm DateTime? @db.Timestamptz(3) /// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto. oidcSubject String? @db.VarChar(255) /// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit. oidcGruppen String[] @default([]) letzteAnmeldung DateTime? @db.Timestamptz(3) fehlversuche Int @default(0) gesperrtBis DateTime? @db.Timestamptz(3) aktiv Boolean @default(true) /// Profilbild. Liegt wie alles andere in MinIO. avatarDateiId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull) rollen PersonRolle[] @relation("PersonRollenInhaber") vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber") ausweisTokens AusweisToken[] auditEintraege AuditLog[] hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon") qualifikationen Qualifikation[] @relation("QualifikationInhaber") bestaetigteQualifikationen Qualifikation[] @relation("QualifikationBestaetiger") // Phase 2 — Wartung und Prüfung zugewieseneTermine Wartungstermin[] @relation("TerminZustaendig") durchgefuehrtePruefungen Pruefung[] @relation("PruefungPruefer") signaturen Signatur[] @relation("SignaturUnterzeichner") gemeldeteMaengel Mangel[] @relation("MangelMelder") zugewieseneMaengel Mangel[] @relation("MangelZustaendig") behobeneMaengel Mangel[] @relation("MangelBehebung") // Phase 3 — Einsätze und Übungen geleiteteEinsaetze Einsatz[] @relation("EinsatzLeitung") freigegebeneBerichte Einsatz[] @relation("EinsatzFreigabe") einsatzteilnahmen Einsatzkraft[] @relation("EinsatzTeilnahme") lagemeldungen Lagemeldung[] @relation("LagemeldungVerfasser") portalzugaenge PortalZugang[] @relation("PortalzugangErsteller") geleiteteUebungen Uebung[] @relation("UebungLeitung") uebungsteilnahmen Uebungsteilnahme[] @relation("UebungTeilnahme") // Phase 4 — Terminal im Gerätehaus fahrten Fahrt[] @relation("FahrtFahrer") anwesenheiten Anwesenheit[] @relation("AnwesenheitPerson") waescheabgaben Waescheabgabe[] @relation("WaescheTraeger") angenommeneWaesche Waescheabgabe[] @relation("WaescheAnnahme") geleiteteInventuren Inventurgang[] @relation("InventurLeitung") inventurposten Inventurposten[] @relation("InventurErfasser") kioskvorgaenge KioskVorgang[] @relation("KioskVorgangPerson") verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher") erledigteKonflikte SyncKonflikt[] @relation("KonfliktErledigung") // Phase 5 — Atemschutz nach FwDV 7 ueberwachteEinsaetze Atemschutzeinsatz[] @relation("AtemschutzUeberwacher") truppeinsaetze Truppmitglied[] @relation("TruppMitgliedPerson") druckmessungen Druckmessung[] @relation("DruckmessungPerson") erfassteDruckmessungen Druckmessung[] @relation("DruckmessungErfasser") atemschutzereignisse AtemschutzEreignis[] @relation("AtemschutzEreignisPerson") g26Untersuchungen G26Untersuchung[] @relation("G26Person") belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson") // Phase 6 — Kartenmodule gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner") geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner") /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@unique([organisationId, oidcSubject]) @@unique([organisationId, mitgliedsnummer]) @@index([organisationId, nachname, vorname]) @@index([organisationId, aktiv]) @@index([oidcSubject]) @@map("person") } /// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene /// Rollen kann der Kommandant ergänzen. model Rolle { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid schluessel RolleSchluessel name String @db.VarChar(100) beschreibung String? @db.VarChar(500) /// Systemrollen dürfen nicht gelöscht werden. systemRolle Boolean @default(false) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) personen PersonRolle[] rechte RolleBerechtigung[] @@unique([organisationId, schluessel]) @@index([organisationId]) @@map("rolle") } /// Zuweisung Person zu Rolle. Eine Person kann mehrere Rollen tragen — der /// Gerätewart ist meist auch aktives Mitglied. model PersonRolle { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid rolleId String @db.Uuid zugewiesenAm DateTime @default(now()) @db.Timestamptz(3) zugewiesenVonId String? @db.Uuid /// Befristete Zuweisung, z. B. Vertretung während Urlaub. gueltigBis DateTime? @db.Timestamptz(3) /// true, wenn die Rolle aus einer authentik-Gruppe vorbelegt wurde. Sobald /// jemand sie in FLORIAN ändert, steht hier false und OIDC überschreibt nicht /// mehr. ausOidcGruppe Boolean @default(false) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("PersonRollenInhaber", fields: [personId], references: [id], onDelete: Cascade) rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade) zugewiesenVon Person? @relation("PersonRollenVergeber", fields: [zugewiesenVonId], references: [id], onDelete: SetNull) @@unique([personId, rolleId]) @@index([organisationId, personId]) @@index([organisationId, rolleId]) @@map("person_rolle") } /// Systemweiter Katalog der Einzelrechte, z. B. "geraet.bearbeiten". /// /// BEWUSSTE AUSNAHME von der organisationId-Regel: das hier sind keine /// Mandantendaten, sondern eine Liste dessen, was die Software überhaupt kann. /// Der Katalog wird vom Seed aus lib/authz.ts befüllt und ist für alle Wehren /// identisch. Mandantenbezogen wird es erst durch RolleBerechtigung, und die /// trägt organisationId. Die Tabelle ist deshalb für alle lesbar und für die /// Anwendungsrolle nicht beschreibbar. model Berechtigung { id String @id @default(uuid()) @db.Uuid schluessel String @unique @db.VarChar(80) /// Fachbereich für die Gruppierung in der Oberfläche. bereich String @db.VarChar(60) bezeichnung String @db.VarChar(160) beschreibung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) rollen RolleBerechtigung[] @@index([bereich]) @@map("berechtigung") } /// Welche Rolle welches Recht hat — je Wehr getrennt, damit eine Wehr dem /// Gerätewart mehr erlauben kann als eine andere. model RolleBerechtigung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid rolleId String @db.Uuid berechtigungId String @db.Uuid /// false setzt ein Recht gezielt ab, das die Rolle sonst hätte. gewaehrt Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade) berechtigung Berechtigung @relation(fields: [berechtigungId], references: [id], onDelete: Cascade) @@unique([rolleId, berechtigungId]) @@index([organisationId, rolleId]) @@map("rolle_berechtigung") } /// Anmeldemittel für das Terminal im Gerätehaus: PIN oder RFID-Karte. /// Gespeichert wird immer nur ein Hash, nie die PIN oder die UID im Klartext. model AusweisToken { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid typ AusweisTokenTyp /// bcrypt-Hash der PIN bzw. der RFID-UID. tokenHash String @db.VarChar(120) /// Letzte vier Stellen der Karten-UID, damit man Karten unterscheiden kann. kennzeichen String? @db.VarChar(20) bezeichnung String? @db.VarChar(120) aktiv Boolean @default(true) gueltigAb DateTime @default(now()) @db.Timestamptz(3) gueltigBis DateTime? @db.Timestamptz(3) letzteNutzung DateTime? @db.Timestamptz(3) fehlversuche Int @default(0) gesperrtBis DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation(fields: [personId], references: [id], onDelete: Cascade) /// Je Person und Art höchstens ein aktives Mittel dieser Bezeichnung. @@index([organisationId, personId]) @@index([organisationId, typ, aktiv]) @@map("ausweis_token") } // ============================================================================= // Nachvollziehbarkeit // ============================================================================= /// Append-only. Es gibt keine UPDATE- oder DELETE-Policy für die Anwendung; /// zusätzlich verhindert ein Trigger Änderungen auch für den Eigentümer. model AuditLog { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid zeitpunkt DateTime @default(now()) @db.Timestamptz(3) akteurTyp AkteurTyp @default(PERSON) akteurPersonId String? @db.Uuid /// Klarname zum Zeitpunkt der Aktion — bleibt lesbar, auch wenn die Person /// später anonymisiert wird. akteurName String? @db.VarChar(200) anmeldeArt AnmeldeArt? aktion AuditAktion /// Modellname, z. B. "Geraet". entitaet String @db.VarChar(80) entitaetId String? @db.Uuid /// Menschenlesbare Kurzfassung für die Oberfläche. beschreibung String? @db.VarChar(500) vorher Json? @db.JsonB nachher Json? @db.JsonB /// Zusatzangaben: Anfrage-ID, Endpunkt, betroffene Felder. kontext Json? @db.JsonB ipAdresse String? @db.Inet userAgent String? @db.VarChar(400) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) akteur Person? @relation(fields: [akteurPersonId], references: [id], onDelete: SetNull) @@index([organisationId, zeitpunkt(sort: Desc)]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, akteurPersonId]) @@map("audit_log") } // ============================================================================= // Dateien // ============================================================================= /// Metadaten zu einem Objekt in MinIO. Der Inhalt liegt NIE im Dateisystem und /// nie in der Datenbank — hier steht nur, wo er zu finden ist. model Datei { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid bucket String @db.VarChar(80) /// Objektschlüssel in MinIO, z. B. "ff-musterhausen/geraet//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") @@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") }