// ============================================================================= // 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[] @@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") /// 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") @@unique([bucket, objektSchluessel]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, kategorie]) @@map("datei") } // ============================================================================= // Phase 1 — Stammdaten // // Ordnungsprinzip: // Standort wo etwas steht (Gerätehaus, Lager, Außenstelle) // Fahrzeug ein Einsatzfahrzeug, es hat Geräteräume // Geraeteraum ein Fach am Fahrzeug (G1 … G6, Dachkasten, Mannschaftsraum) // Geraetetyp die Bauart samt Prüfvorgabe — "Pressluftatmer PA 94" // Geraet das einzelne Stück mit Inventarnummer // // Ein Gerät liegt ENTWEDER an einem Standort ODER verlastet in einem // Geräteraum eines Fahrzeugs. Beides gleichzeitig oder gar nichts wäre eine // Falschangabe; eine Prüfbedingung in der Datenbank verhindert das. // ============================================================================= /// Art eines Standorts. enum StandortArt { GERAETEHAUS LAGER AUSSENSTELLE WERKSTATT SONSTIGES } /// Fachliche Einordnung eines Gerätetyps — bestimmt die Gruppierung in Listen. enum GeraeteKategorie { ATEMSCHUTZ BELEUCHTUNG BRANDBEKAEMPFUNG ELEKTRO FUNK HYGIENE MEDIZIN MESSTECHNIK PERSOENLICHE_SCHUTZAUSRUESTUNG RETTUNG SCHLAUCHMATERIAL TECHNISCHE_HILFELEISTUNG UMWELTSCHUTZ WASSERFOERDERUNG SONSTIGES } /// Lebensdauerkategorie. Diese Gruppen haben eine maximale Nutzungsdauer ab /// Herstellungs- oder Indienststellungsdatum, unabhängig vom Zustand. enum LebensdauerKategorie { KEINE HELM SCHUTZKLEIDUNG HANDSCHUHE STIEFEL SEIL_GURT PRESSLUFTATMER ATEMANSCHLUSS DRUCKBEHAELTER SCHLAUCH LEITER SONSTIGE } /// Woran sich die maximale Nutzungsdauer bemisst. enum LebensdauerBezug { HERSTELLUNG INDIENSTSTELLUNG } /// Art einer Qualifikation. enum QualifikationsArt { LEHRGANG BEFAEHIGUNG UNTERSUCHUNG FUEHRERSCHEIN UNTERWEISUNG SONSTIGES } // ----------------------------------------------------------------------------- /// Ein Ort, an dem Fahrzeuge stehen und Geräte lagern. model Standort { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid name String @db.VarChar(160) /// Kurzzeichen für Listen und Etiketten, z. B. "GH" oder "LAG". kuerzel String? @db.VarChar(20) art StandortArt @default(GERAETEHAUS) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) /// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht, /// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage. /// Ab Phase 6 zeigt die Karte diese Punkte an. koordinaten Unsupported("geography(Point, 4326)")? bemerkung String? @db.VarChar(1000) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) fahrzeuge Fahrzeug[] geraete Geraet[] @@unique([organisationId, name]) @@index([organisationId, aktiv]) @@map("standort") } /// Ein Einsatzfahrzeug. model Fahrzeug { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Funkrufname, z. B. "Florian Musterhausen 40/1". funkrufname String @db.VarChar(120) bezeichnung String @db.VarChar(120) art FahrzeugArt @default(LOESCHFAHRZEUG) /// Typkurzzeichen nach DIN, z. B. "LF 20", "TSF-W", "DLA(K) 23/12". dinTyp String? @db.VarChar(40) kennzeichen String? @db.VarChar(20) hersteller String? @db.VarChar(120) aufbauhersteller String? @db.VarChar(120) fahrgestellnummer String? @db.VarChar(30) baujahr Int? erstzulassung DateTime? @db.Date indienststellung DateTime? @db.Date /// Sitzplätze für die Stärkeberechnung ab Phase 3. sitzplaetze Int? /// Löschwasser in Litern. tankinhalt Int? /// Zulässige Gesamtmasse in kg. gesamtmasse Int? kilometerstand Int? /// Nächste Hauptuntersuchung. tuevBis DateTime? @db.Date /// Nächste Sicherheitsprüfung nach UVV. uvvBis DateTime? @db.Date naechsteWartung DateTime? @db.Date standortId String? @db.Uuid aktiv Boolean @default(true) bemerkung String? @db.VarChar(1000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull) geraeteraeume Geraeteraum[] geraete Geraet[] // Phase 3 einsaetze Einsatzfahrzeug[] einsatzkraefte Einsatzkraft[] @@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[] @@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[] @@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[] @@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") }