// ============================================================================= // FLORIAN — Datenmodell, Phase 0 (Fundament) // // Grundregeln, die für JEDES Modell gelten: // 1. Mandantentrennung: jede Tabelle trägt organisationId. Postgres Row Level // Security setzt das durch (siehe migrations/.../rls). Ausnahme ist der // systemweite Berechtigungskatalog, siehe Kommentar dort. // 2. Jede schreibende Operation erzeugt einen AuditLog-Eintrag (lib/audit.ts). // 3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht: // geloeschtAm setzen statt DELETE. // 4. Alle Zeitstempel in UTC speichern, Anzeige in Europe/Berlin. // // Bezeichner sind englisch, wo es Technik ist, und deutsch, wo es Fachlichkeit // ist — so wie es in der Wehr auch gesprochen wird. // ============================================================================= generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"] previewFeatures = ["postgresqlExtensions"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_DATABASE_URL") extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm"), vector] } // ============================================================================= // Aufzählungen // ============================================================================= /// Rollen im Sinne der Feuerwehrorganisation. Die Berechtigungen dahinter /// stehen in lib/authz.ts und werden beim Seed in die Datenbank gespiegelt. enum RolleSchluessel { SUPERADMIN KOMMANDANT GERAETEWART ATEMSCHUTZGERAETEWART KASSIER SCHRIFTFUEHRER AUSBILDER MITGLIED KIOSK } /// Art des Ausweises, mit dem sich jemand am Terminal im Gerätehaus anmeldet. enum AusweisTokenTyp { PIN RFID } /// Wie sich jemand angemeldet hat — landet im Audit-Log. enum AnmeldeArt { LOKAL OIDC KIOSK_PIN KIOSK_RFID SYSTEM } /// Aktionen im Audit-Log. enum AuditAktion { ERSTELLT GEAENDERT GELOESCHT WIEDERHERGESTELLT ANGEMELDET ABGEMELDET ANMELDUNG_FEHLGESCHLAGEN EXPORTIERT DATEI_HOCHGELADEN DATEI_ABGERUFEN BERECHTIGUNG_GEAENDERT } /// Wer die Aktion ausgelöst hat. enum AkteurTyp { PERSON SYSTEM WORKER KIOSK API } /// Betriebsstatus eines Geräts nach DGUV-Logik. enum GeraetStatus { EINSATZBEREIT PRUEFUNG_FAELLIG GESPERRT AUSGESONDERT IN_REPARATUR } /// Grobe Fahrzeugkategorie. Die vollständige DIN-Typisierung kommt in Phase 1. enum FahrzeugArt { LOESCHFAHRZEUG DREHLEITER RUESTWAGEN GERAETEWAGEN MANNSCHAFTSTRANSPORT EINSATZLEITWAGEN WECHSELLADER ANHAENGER SONSTIGES } /// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl). 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[] // Phase 7 — Benachrichtigungen und Termine benachrichtigungsvorlages Benachrichtigungsvorlage[] zustellkanals Zustellkanal[] benachrichtigungs Benachrichtigung[] termins Termin[] terminanmeldungs Terminanmeldung[] kalenderzugangs Kalenderzugang[] // Phase 8 — Lokale KI kiVorgaenge KiVorgang[] wikiseiten Wikiseite[] wikiabschnitte Wikiabschnitt[] wikiversionen Wikiversion[] diktate Diktat[] belegentwuerfe Belegentwurf[] // Phase 9 — Kassenverwaltung kassenkonten Kassenkonto[] buchungskategorien Buchungskategorie[] buchungen Buchung[] beitragssaetze Beitragssatz[] beitraege Beitrag[] rechnungen Rechnung[] rechnungspositionen Rechnungsposition[] kassenabschluesse Kassenabschluss[] abschlussposten Abschlussposten[] @@index([aktiv]) @@map("organisation") } // ============================================================================= // Personen und Rechte // ============================================================================= /// Ein Mitglied der Wehr. Gleichzeitig der Anmeldebenutzer — es gibt bewusst /// keine getrennte "User"-Tabelle, damit Dienstgrad, Qualifikation und Zugang /// nicht auseinanderlaufen. model Person { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid vorname String @db.VarChar(100) nachname String @db.VarChar(100) /// Fortlaufende Mitgliedsnummer innerhalb der Wehr. mitgliedsnummer String? @db.VarChar(30) geburtsdatum DateTime? @db.Date dienstgrad Dienstgrad @default(KEIN) email String? @db.VarChar(200) telefon String? @db.VarChar(40) mobil String? @db.VarChar(40) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) eintrittsdatum DateTime? @db.Date austrittsdatum DateTime? @db.Date /// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied. abteilung String? @db.VarChar(60) /// 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") // Phase 7 — Benachrichtigungen und Termine benachrichtigungen Benachrichtigung[] @relation("BenachrichtigungEmpfaenger") zustellkanaele Zustellkanal[] @relation("ZustellkanalPerson") geleiteteTermine Termin[] @relation("TerminLeitung") terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson") kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson") // Phase 8 — Lokale KI angestosseneKiVorgaenge KiVorgang[] @relation("KiVorgangErzeuger") entschiedeneKiVorgaenge KiVorgang[] @relation("KiVorgangEntscheider") verfassteWikiseiten Wikiseite[] @relation("WikiseiteAutor") wikiversionen Wikiversion[] @relation("WikiversionAutor") diktate Diktat[] @relation("DiktatErzeuger") erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser") bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger") // Phase 9 — Kassenverwaltung buchungen Buchung[] @relation("BuchungPerson") erfassteBuchungen Buchung[] @relation("BuchungErfasser") beitraege Beitrag[] @relation("BeitragPerson") gepruefteAbschluesse Kassenabschluss[] @relation("AbschlussPruefer") zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer") erstellteRechnungen Rechnung[] @relation("RechnungErsteller") /// 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") // Phase 8 alsDiktataufnahme Diktat[] @relation("DiktatAufnahme") alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto") // Phase 9 alsBuchungsbeleg Buchung[] @relation("BuchungBeleg") alsRechnungsPdf Rechnung[] @relation("RechnungPdf") alsKassenpruefbericht Kassenabschluss[] @relation("AbschlussBericht") @@unique([bucket, objektSchluessel]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, kategorie]) @@map("datei") } // ============================================================================= // Phase 1 — Stammdaten // // Ordnungsprinzip: // Standort wo etwas steht (Gerätehaus, Lager, Außenstelle) // Fahrzeug ein Einsatzfahrzeug, es hat Geräteräume // Geraeteraum ein Fach am Fahrzeug (G1 … G6, Dachkasten, Mannschaftsraum) // Geraetetyp die Bauart samt Prüfvorgabe — "Pressluftatmer PA 94" // Geraet das einzelne Stück mit Inventarnummer // // Ein Gerät liegt ENTWEDER an einem Standort ODER verlastet in einem // Geräteraum eines Fahrzeugs. Beides gleichzeitig oder gar nichts wäre eine // Falschangabe; eine Prüfbedingung in der Datenbank verhindert das. // ============================================================================= /// Art eines Standorts. enum StandortArt { GERAETEHAUS LAGER AUSSENSTELLE WERKSTATT SONSTIGES } /// Fachliche Einordnung eines Gerätetyps — bestimmt die Gruppierung in Listen. enum GeraeteKategorie { ATEMSCHUTZ BELEUCHTUNG BRANDBEKAEMPFUNG ELEKTRO FUNK HYGIENE MEDIZIN MESSTECHNIK PERSOENLICHE_SCHUTZAUSRUESTUNG RETTUNG SCHLAUCHMATERIAL TECHNISCHE_HILFELEISTUNG UMWELTSCHUTZ WASSERFOERDERUNG SONSTIGES } /// Lebensdauerkategorie. Diese Gruppen haben eine maximale Nutzungsdauer ab /// Herstellungs- oder Indienststellungsdatum, unabhängig vom Zustand. enum LebensdauerKategorie { KEINE HELM SCHUTZKLEIDUNG HANDSCHUHE STIEFEL SEIL_GURT PRESSLUFTATMER ATEMANSCHLUSS DRUCKBEHAELTER SCHLAUCH LEITER SONSTIGE } /// Woran sich die maximale Nutzungsdauer bemisst. enum LebensdauerBezug { HERSTELLUNG INDIENSTSTELLUNG } /// Art einer Qualifikation. enum QualifikationsArt { LEHRGANG BEFAEHIGUNG UNTERSUCHUNG FUEHRERSCHEIN UNTERWEISUNG SONSTIGES } // ----------------------------------------------------------------------------- /// Ein Ort, an dem Fahrzeuge stehen und Geräte lagern. model Standort { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid name String @db.VarChar(160) /// Kurzzeichen für Listen und Etiketten, z. B. "GH" oder "LAG". kuerzel String? @db.VarChar(20) art StandortArt @default(GERAETEHAUS) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) /// Koordinaten doppelt: als Dezimalgrad für die Anwendung und als /// PostGIS-Punkt für die Karte. Prisma kann den PostGIS-Typ nicht lesen — /// ohne die Grade daneben wäre jede Anzeige eine Rohabfrage. Ein Trigger /// hält beides im Gleichlauf (Phase 6). breitengrad Decimal? @db.Decimal(9, 6) laengengrad Decimal? @db.Decimal(9, 6) koordinaten Unsupported("geography(Point, 4326)")? bemerkung String? @db.VarChar(1000) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) fahrzeuge Fahrzeug[] geraete Geraet[] inventurgaenge Inventurgang[] @@unique([organisationId, name]) @@index([organisationId, aktiv]) @@map("standort") } /// Ein Einsatzfahrzeug. model Fahrzeug { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Funkrufname, z. B. "Florian Musterhausen 40/1". funkrufname String @db.VarChar(120) bezeichnung String @db.VarChar(120) art FahrzeugArt @default(LOESCHFAHRZEUG) /// Typkurzzeichen nach DIN, z. B. "LF 20", "TSF-W", "DLA(K) 23/12". dinTyp String? @db.VarChar(40) kennzeichen String? @db.VarChar(20) hersteller String? @db.VarChar(120) aufbauhersteller String? @db.VarChar(120) fahrgestellnummer String? @db.VarChar(30) baujahr Int? erstzulassung DateTime? @db.Date indienststellung DateTime? @db.Date /// Sitzplätze für die Stärkeberechnung ab Phase 3. sitzplaetze Int? /// Löschwasser in Litern. tankinhalt Int? /// Zulässige Gesamtmasse in kg. gesamtmasse Int? kilometerstand Int? /// Nächste Hauptuntersuchung. tuevBis DateTime? @db.Date /// Nächste Sicherheitsprüfung nach UVV. uvvBis DateTime? @db.Date naechsteWartung DateTime? @db.Date standortId String? @db.Uuid aktiv Boolean @default(true) bemerkung String? @db.VarChar(1000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull) geraeteraeume Geraeteraum[] geraete Geraet[] // Phase 3 einsaetze Einsatzfahrzeug[] einsatzkraefte Einsatzkraft[] // Phase 4 fahrten Fahrt[] inventurgaenge Inventurgang[] @@unique([organisationId, funkrufname]) @@unique([organisationId, kennzeichen]) @@index([organisationId, aktiv]) @@index([organisationId, tuevBis]) @@map("fahrzeug") } /// Ein Fach am Fahrzeug. Die Nummerierung folgt der Beladeordnung: G1 bis G6 /// an den Seiten, dazu Dachkasten, Heck und Mannschaftsraum. model Geraeteraum { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid fahrzeugId String @db.Uuid /// Kurzzeichen wie "G1", "GR", "Dach". bezeichnung String @db.VarChar(40) beschreibung String? @db.VarChar(200) /// Reihenfolge in der Anzeige — entspricht der Beladeordnung. sortierung Int @default(0) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Cascade) geraete Geraet[] @@unique([fahrzeugId, bezeichnung]) @@index([organisationId, fahrzeugId]) @@map("geraeteraum") } /// Die Bauart eines Geräts samt Prüf- und Lebensdauervorgabe. Was hier steht, /// gilt als Vorgabe für jedes Gerät dieses Typs; einzelne Geräte dürfen davon /// abweichen, wenn der Hersteller etwas anderes vorschreibt. model Geraetetyp { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid name String @db.VarChar(160) kategorie GeraeteKategorie @default(SONSTIGES) hersteller String? @db.VarChar(120) modell String? @db.VarChar(120) /// Vorgabe für das Prüfintervall in Monaten. pruefintervallMonate Int? /// Fundstelle der Prüfvorschrift, z. B. "DGUV Vorschrift 49" oder /// "DIN 14800-16". pruefvorschrift String? @db.VarChar(200) lebensdauerKategorie LebensdauerKategorie @default(KEINE) /// Maximale Nutzungsdauer in Jahren. lebensdauerJahre Int? lebensdauerBezug LebensdauerBezug @default(INDIENSTSTELLUNG) /// Wie viele Monate vor Ablauf gewarnt wird. vorwarnzeitMonate Int @default(3) beschreibung String? @db.VarChar(1000) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) geraete Geraet[] // Phase 2 wartungsvorlagen Wartungsvorlage[] rueckrufTypen RueckrufGeraetetyp[] @@unique([organisationId, name]) @@index([organisationId, kategorie]) @@index([organisationId, aktiv]) @@map("geraetetyp") } /// Ein einzelnes Gerät mit eigener Inventarnummer. model Geraet { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid inventarnummer String @db.VarChar(60) bezeichnung String @db.VarChar(160) geraetetypId String? @db.Uuid hersteller String? @db.VarChar(120) modell String? @db.VarChar(120) seriennummer String? @db.VarChar(120) herstellungsdatum DateTime? @db.Date indienststellung DateTime? @db.Date anschaffungspreis Decimal? @db.Decimal(12, 2) status GeraetStatus @default(EINSATZBEREIT) /// Prüfintervall in Monaten. Leer bedeutet: Vorgabe des Gerätetyps gilt. pruefintervallMonate Int? letztePruefung DateTime? @db.Date naechstePruefung DateTime? @db.Date /// Lebensdauer. Leer bedeutet ebenfalls: Vorgabe des Gerätetyps gilt. lebensdauerKategorie LebensdauerKategorie? lebensdauerJahre Int? /// Errechnetes Ende der Nutzungsdauer, vom Server gesetzt. lebensdauerBis DateTime? @db.Date /// ENTWEDER standortId ODER fahrzeugId + geraeteraumId. /// Eine Prüfbedingung in der Datenbank setzt das durch. standortId String? @db.Uuid fahrzeugId String? @db.Uuid geraeteraumId String? @db.Uuid /// Freitext für die genaue Stelle im Raum: "Regal 3, Fach B". lagerort String? @db.VarChar(160) ausgesondertAm DateTime? @db.Date ausgesondertGrund String? @db.VarChar(500) bemerkung String? @db.VarChar(1000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull) standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull) fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) geraeteraum Geraeteraum? @relation(fields: [geraeteraumId], references: [id], onDelete: SetNull) // Phase 2 — alles, was am Gerät geprüft, gemeldet und zurückgerufen wird wartungstermine Wartungstermin[] pruefungen Pruefung[] maengel Mangel[] rueckrufBetroffenheiten RueckrufBetroffenheit[] materialverbrauch Materialverbrauch[] // Phase 4 waescheabgaben Waescheabgabe[] inventurposten Inventurposten[] // Phase 5 alsPressluftatmer Truppmitglied[] @relation("TruppPressluftatmer") @@unique([organisationId, inventarnummer]) @@index([organisationId, status]) @@index([organisationId, naechstePruefung]) @@index([organisationId, lebensdauerBis]) @@index([organisationId, fahrzeugId]) @@index([organisationId, standortId]) @@index([organisationId, geraetetypId]) @@index([organisationId, seriennummer]) @@map("geraet") } /// Die Art einer Qualifikation — Lehrgang, Untersuchung, Führerschein. model Qualifikationstyp { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid name String @db.VarChar(160) /// Kurzzeichen für Listen, z. B. "MA", "AGT", "G26.3", "C1". kuerzel String? @db.VarChar(20) art QualifikationsArt @default(LEHRGANG) /// Gültigkeit in Monaten. Leer bedeutet: gilt unbefristet. gueltigkeitMonate Int? /// Wie viele Tage vor Ablauf gewarnt wird. vorwarnzeitTage Int @default(90) /// Voraussetzung für den Einsatzdienst — wird im Dashboard hervorgehoben. einsatzrelevant Boolean @default(false) beschreibung String? @db.VarChar(1000) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) qualifikationen Qualifikation[] @@unique([organisationId, name]) @@index([organisationId, aktiv]) @@map("qualifikationstyp") } /// Eine erworbene Qualifikation einer Person. model Qualifikation { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid typId String @db.Uuid erworbenAm DateTime @db.Date /// Ablaufdatum. Wird beim Speichern aus gueltigkeitMonate errechnet, kann /// aber überschrieben werden — auf einer Urkunde steht manchmal etwas /// anderes als die Regel hergibt. gueltigBis DateTime? @db.Date ausstellendeStelle String? @db.VarChar(200) /// Lehrgangs- oder Urkundennummer. nachweisnummer String? @db.VarChar(80) /// Eingescannte Urkunde, liegt in MinIO. urkundeDateiId String? @db.Uuid bestaetigtVonId String? @db.Uuid bestaetigtAm DateTime? @db.Timestamptz(3) bemerkung String? @db.VarChar(1000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("QualifikationInhaber", fields: [personId], references: [id], onDelete: Cascade) typ Qualifikationstyp @relation(fields: [typId], references: [id], onDelete: Restrict) bestaetigtVon Person? @relation("QualifikationBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull) @@index([organisationId, personId]) @@index([organisationId, typId]) @@index([organisationId, gueltigBis]) @@map("qualifikation") } // ============================================================================= // Phase 2 — Wartungs- und Prüfmanagement // // Ordnungsprinzip: // Wartungsvorlage was geprüft wird und in welchem Takt — je Gerätetyp // Checklistenpunkt eine einzelne Frage der Prüfung // Wartungstermin ein konkreter Prüftermin an einem konkreten Gerät // Pruefung die durchgeführte Prüfung samt Ergebnis // Pruefergebnis die Antwort auf einen Checklistenpunkt // Signatur die Unterschrift unter das Protokoll // Mangel was dabei auffiel und noch behoben werden muss // Rueckruf eine Herstellerwarnung und die betroffenen Geräte // // Zwei Festlegungen, die den Aufbau erklären: // // 1. Ein abgeschlossenes Prüfprotokoll ist unveränderbar. Ein Trigger in der // Datenbank lässt an einer Prüfung mit Status ABGESCHLOSSEN keine // Änderung mehr zu — außer der Kennzeichnung als ERSETZT. Eine Korrektur // ist eine neue Prüfung, die über ersetztPruefungId auf die alte zeigt. // // 2. "Überfällig" ist kein gespeicherter Zustand, sondern ergibt sich aus // faelligAm und dem heutigen Tag. Gespeicherte Zustände laufen der Uhr // hinterher; berechnete nicht. // ============================================================================= /// Zustand eines Prüftermins. Überfälligkeit steht bewusst nicht hier drin. enum WartungsterminStatus { OFFEN ERLEDIGT /// Der Termin ist gegenstandslos geworden — Gerät ausgesondert, Vorlage /// abgeschafft, Prüfpflicht entfallen. ENTFALLEN } /// Lebenszyklus einer Prüfung. enum PruefungStatus { /// Der Wizard läuft noch. Nur in diesem Zustand ist Ändern erlaubt. ENTWURF /// Unterschrieben und festgeschrieben. ABGESCHLOSSEN /// Durch eine Korrekturprüfung abgelöst. Bleibt erhalten und lesbar. ERSETZT } /// Ergebnis einer Prüfung nach DGUV-Logik. enum PruefErgebnis { BESTANDEN BESTANDEN_MIT_MAENGELN NICHT_BESTANDEN } /// Wer geprüft hat — die eigene Wehr oder eine Fachfirma. enum PruefungArt { INTERN EXTERN } /// Art eines Checklistenpunkts. Bestimmt, welches Eingabefeld der Wizard zeigt. enum ChecklistenpunktTyp { JA_NEIN MESSWERT TEXT FOTO } /// In welcher Eigenschaft jemand unterschreibt. enum SignaturRolle { PRUEFER GERAETEWART /// Bei externer Wartung: der Vertreter der Fachfirma. FREMDFIRMA } /// Dringlichkeit eines Mangels. enum MangelPrioritaet { NIEDRIG MITTEL HOCH /// Gerät ist nicht mehr einsatzbereit. KRITISCH } /// Bearbeitungsstand eines Mangels: melden → zugewiesen → in Arbeit → behoben. enum MangelStatus { GEMELDET ZUGEWIESEN IN_ARBEIT BEHOBEN /// Kein Mangel — Fehlmeldung, bleibt aber nachvollziehbar stehen. VERWORFEN } /// Bearbeitungsstand eines Rückrufs. enum RueckrufStatus { OFFEN IN_BEARBEITUNG ERLEDIGT } /// Stand je betroffenem Gerät innerhalb eines Rückrufs. enum BetroffenheitStatus { OFFEN /// Geprüft und doch nicht betroffen — falsche Charge, anderes Baujahr. NICHT_BETROFFEN MASSNAHME_LAEUFT ERLEDIGT } /// Vorwarnstufe einer Erinnerung. Je Termin und Stufe wird höchstens einmal /// erinnert — der Eintrag ist gleichzeitig die Sperre gegen Doppelversand. enum ErinnerungStufe { T_30 T_7 T_0 UEBERFAELLIG } // ----------------------------------------------------------------------------- /// Was an einem Gerätetyp regelmäßig zu prüfen ist. /// /// Die Vorlage ist die Regel, der Wartungstermin ihre Anwendung auf ein /// einzelnes Gerät. Ändert sich die Vorlage, ändern sich künftige Termine — /// bereits abgeschlossene Prüfungen bleiben unberührt, weil ihre Ergebnisse /// samt Fragestellung erhalten bleiben. model Wartungsvorlage { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid name String @db.VarChar(160) /// Für welchen Gerätetyp sie gilt. Leer bedeutet: nur von Hand zuweisbar. geraetetypId String? @db.Uuid /// Prüfintervall in Monaten. Zählt ab der letzten durchgeführten Prüfung. intervallMonate Int /// Wie viele Tage vorher gewarnt und erinnert wird. vorwarnzeitTage Int @default(30) /// Fundstelle, z. B. "DGUV Vorschrift 49" oder "DIN 14800-16". pruefvorschrift String? @db.VarChar(200) /// Erfahrungswert für die Planung, in Minuten. standardDauerMinuten Int? /// Vorbelegung: diese Prüfung macht üblicherweise eine Fachfirma. standardExtern Boolean @default(false) /// Der nächtliche Lauf erzeugt Termine für alle Geräte dieses Typs. automatisch Boolean @default(true) beschreibung String? @db.VarChar(1000) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull) punkte Checklistenpunkt[] termine Wartungstermin[] pruefungen Pruefung[] @@unique([organisationId, name]) @@index([organisationId, geraetetypId]) @@index([organisationId, aktiv]) @@map("wartungsvorlage") } /// Eine einzelne Frage der Checkliste. model Checklistenpunkt { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid vorlageId String @db.Uuid text String @db.VarChar(300) typ ChecklistenpunktTyp @default(JA_NEIN) /// Pflichtpunkte müssen beantwortet sein, bevor sich die Prüfung /// abschließen lässt. pflicht Boolean @default(true) /// Sollwert als Klartext für die Anzeige, z. B. "mindestens 270 bar". sollwert String? @db.VarChar(120) /// Einheit eines Messwerts, z. B. "bar", "mbar", "V", "kN". einheit String? @db.VarChar(20) /// Zulässiger Bereich eines Messwerts. Außerhalb davon gilt der Punkt als /// nicht in Ordnung — gerechnet wird das im Server, nicht im Browser. minWert Decimal? @db.Decimal(12, 3) maxWert Decimal? @db.Decimal(12, 3) hinweis String? @db.VarChar(500) sortierung Int @default(0) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) vorlage Wartungsvorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade) ergebnisse Pruefergebnis[] @@index([organisationId, vorlageId]) @@map("checklistenpunkt") } /// Ein fälliger Prüftermin an einem einzelnen Gerät. /// /// Termine entstehen entweder im nächtlichen Lauf aus den Vorlagen oder von /// Hand. Die Eindeutigkeit über Gerät, Vorlage und Fälligkeitstag verhindert, /// dass ein zweiter Lauf denselben Termin noch einmal anlegt. model Wartungstermin { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid geraetId String @db.Uuid vorlageId String? @db.Uuid faelligAm DateTime @db.Date status WartungsterminStatus @default(OFFEN) /// true, wenn der nächtliche Lauf den Termin erzeugt hat. automatischErzeugt Boolean @default(false) /// Wer den Termin übernehmen soll. zugewiesenAnId String? @db.Uuid bemerkung String? @db.VarChar(1000) erledigtAm DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade) vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull) zugewiesenAn Person? @relation("TerminZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull) pruefungen Pruefung[] erinnerungen Erinnerung[] /// Doppelte Termine aus wiederholten Läufen ausschließen. Für Termine ohne /// Vorlage greift die Bedingung nicht — Postgres wertet NULL als /// unterschiedlich —, die legt aber ohnehin nur ein Mensch an. @@unique([geraetId, vorlageId, faelligAm]) @@index([organisationId, faelligAm]) @@index([organisationId, status, faelligAm]) @@index([organisationId, geraetId]) @@index([organisationId, zugewiesenAnId]) @@map("wartungstermin") } /// Eine durchgeführte Prüfung. /// /// Ist der Status ABGESCHLOSSEN, ist der Datensatz festgeschrieben: die /// Datenbank weist jede weitere Änderung ab. Was korrigiert werden muss, wird /// als neue Prüfung erfasst, die über ersetztPruefungId auf die alte zeigt. model Pruefung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid geraetId String @db.Uuid terminId String? @db.Uuid vorlageId String? @db.Uuid art PruefungArt @default(INTERN) status PruefungStatus @default(ENTWURF) prueferId String? @db.Uuid /// Klarname des Prüfers zum Zeitpunkt der Prüfung. Bleibt lesbar, auch wenn /// die Person später austritt und anonymisiert wird — die Nachweispflicht /// gegenüber der Unfallkasse verlangt einen benennbaren Prüfer. prueferName String? @db.VarChar(200) durchgefuehrtAm DateTime @db.Date /// Beginn der Zeiterfassung im Wizard. begonnenUm DateTime? @db.Timestamptz(3) dauerMinuten Int? ergebnis PruefErgebnis? /// Nur bei art = EXTERN: wer geprüft hat, was es gekostet hat und das /// Protokoll der Fachfirma als PDF. externeFirma String? @db.VarChar(200) externeProtokollDateiId String? @db.Uuid kosten Decimal? @db.Decimal(10, 2) /// Nächster Termin laut dieser Prüfung. Kann vom Intervall abweichen, wenn /// der Prüfer eine kürzere Frist ansetzt. naechstePruefungAm DateTime? @db.Date /// Das erzeugte Protokoll-PDF in MinIO und seine laufende Nummer. protokollDateiId String? @db.Uuid protokollNummer String? @db.VarChar(40) abgeschlossenAm DateTime? @db.Timestamptz(3) /// Korrektur: diese Prüfung ersetzt eine frühere. ersetztPruefungId String? @db.Uuid korrekturGrund String? @db.VarChar(500) bemerkung String? @db.VarChar(2000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade) termin Wartungstermin? @relation(fields: [terminId], references: [id], onDelete: SetNull) vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull) pruefer Person? @relation("PruefungPruefer", fields: [prueferId], references: [id], onDelete: SetNull) protokoll Datei? @relation("PruefungProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull) externesProtokoll Datei? @relation("PruefungFremdprotokoll", fields: [externeProtokollDateiId], references: [id], onDelete: SetNull) ersetzt Pruefung? @relation("PruefungKorrektur", fields: [ersetztPruefungId], references: [id], onDelete: SetNull) ersetztDurch Pruefung[] @relation("PruefungKorrektur") ergebnisse Pruefergebnis[] signaturen Signatur[] maengel Mangel[] @@unique([organisationId, protokollNummer]) @@index([organisationId, geraetId, durchgefuehrtAm(sort: Desc)]) @@index([organisationId, status]) @@index([organisationId, durchgefuehrtAm(sort: Desc)]) @@index([organisationId, prueferId]) @@map("pruefung") } /// Die Antwort auf einen Checklistenpunkt. model Pruefergebnis { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid pruefungId String @db.Uuid punktId String @db.Uuid /// Je nach Typ des Punktes ist eines der drei Felder belegt. jaNein Boolean? zahlwert Decimal? @db.Decimal(12, 3) text String? @db.VarChar(1000) fotoDateiId String? @db.Uuid /// Ob der Punkt in Ordnung ist. Bei Messwerten rechnet der Server das aus /// dem zulässigen Bereich aus, sonst folgt es der Antwort. inOrdnung Boolean @default(true) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade) punkt Checklistenpunkt @relation(fields: [punktId], references: [id], onDelete: Restrict) foto Datei? @relation("PruefergebnisFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull) @@unique([pruefungId, punktId]) @@index([organisationId, pruefungId]) @@map("pruefergebnis") } /// Unterschrift unter ein Prüfprotokoll. Das Bild ist ein PNG aus dem /// Zeichenfeld und liegt wie alles andere in MinIO. model Signatur { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid pruefungId String @db.Uuid rolle SignaturRolle @default(PRUEFER) personId String? @db.Uuid /// Klarname, so wie er im Protokoll steht. name String @db.VarChar(200) bildDateiId String? @db.Uuid unterzeichnetAm DateTime @default(now()) @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade) person Person? @relation("SignaturUnterzeichner", fields: [personId], references: [id], onDelete: SetNull) bild Datei? @relation("SignaturBild", fields: [bildDateiId], references: [id], onDelete: SetNull) @@index([organisationId, pruefungId]) @@map("signatur") } /// Ein Mangel an einem Gerät — gemeldet aus einer Prüfung heraus oder einzeln. model Mangel { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid geraetId String @db.Uuid /// Gesetzt, wenn der Mangel bei einer Prüfung aufgefallen ist. pruefungId String? @db.Uuid melderId String? @db.Uuid melderName String? @db.VarChar(200) beschreibung String @db.VarChar(2000) fotoDateiId String? @db.Uuid prioritaet MangelPrioritaet @default(MITTEL) status MangelStatus @default(GEMELDET) zugewiesenAnId String? @db.Uuid gemeldetAm DateTime @default(now()) @db.Timestamptz(3) behobenAm DateTime? @db.Timestamptz(3) behobenVonId String? @db.Uuid behebung String? @db.VarChar(2000) /// Beim Melden wurde das Gerät gesperrt. Merkt sich, dass die Sperre zu /// diesem Mangel gehört — beim Beheben wird sie wieder aufgehoben. geraetGesperrt Boolean @default(false) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade) pruefung Pruefung? @relation(fields: [pruefungId], references: [id], onDelete: SetNull) melder Person? @relation("MangelMelder", fields: [melderId], references: [id], onDelete: SetNull) zugewiesenAn Person? @relation("MangelZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull) behobenVon Person? @relation("MangelBehebung", fields: [behobenVonId], references: [id], onDelete: SetNull) foto Datei? @relation("MangelFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull) @@index([organisationId, status, prioritaet]) @@index([organisationId, geraetId]) @@index([organisationId, zugewiesenAnId]) @@map("mangel") } /// Eine Herstellerwarnung. Betroffene Geräte werden gesucht, markiert und auf /// Wunsch gesperrt. model Rueckruf { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid hersteller String @db.VarChar(160) titel String @db.VarChar(200) /// Nummer der Bekanntmachung des Herstellers. referenz String? @db.VarChar(80) beschreibung String? @db.VarChar(4000) massnahme String? @db.VarChar(2000) veroeffentlichtAm DateTime? @db.Date /// Fundstelle, z. B. die Adresse der Herstellermitteilung. quelle String? @db.VarChar(500) /// Betroffene Geräte beim Zuordnen automatisch sperren. sperrtGeraete Boolean @default(false) status RueckrufStatus @default(OFFEN) erledigtAm DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) typen RueckrufGeraetetyp[] betroffenheiten RueckrufBetroffenheit[] @@index([organisationId, status]) @@index([organisationId, hersteller]) @@map("rueckruf") } /// Welche Bauart und welche Charge ein Rückruf betrifft. model RueckrufGeraetetyp { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid rueckrufId String @db.Uuid geraetetypId String? @db.Uuid /// Freitext, wenn die betroffene Bauart nicht als Gerätetyp erfasst ist. modell String? @db.VarChar(160) /// Eingrenzung der betroffenen Stücke. Alle Angaben sind freiwillig; was /// leer bleibt, schränkt nicht ein. seriennummerVon String? @db.VarChar(120) seriennummerBis String? @db.VarChar(120) herstellungVon DateTime? @db.Date herstellungBis DateTime? @db.Date erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade) geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull) @@index([organisationId, rueckrufId]) @@map("rueckruf_geraetetyp") } /// Ein einzelnes Gerät innerhalb eines Rückrufs. model RueckrufBetroffenheit { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid rueckrufId String @db.Uuid geraetId String @db.Uuid status BetroffenheitStatus @default(OFFEN) festgestelltAm DateTime @default(now()) @db.Timestamptz(3) erledigtAm DateTime? @db.Timestamptz(3) bemerkung String? @db.VarChar(1000) /// Der Rückruf hat dieses Gerät gesperrt — beim Erledigen wird die Sperre /// wieder aufgehoben. geraetGesperrt Boolean @default(false) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade) geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade) @@unique([rueckrufId, geraetId]) @@index([organisationId, rueckrufId]) @@index([organisationId, geraetId]) @@map("rueckruf_betroffenheit") } /// Nachweis einer versendeten Erinnerung. Der Eintrag ist gleichzeitig die /// Sperre gegen Doppelversand: je Termin und Stufe gibt es ihn nur einmal. model Erinnerung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid terminId String @db.Uuid stufe ErinnerungStufe versendetAm DateTime @default(now()) @db.Timestamptz(3) /// Kanal laut lib/integrations — "mail", "telegram", "protokoll". kanal String @default("mail") @db.VarChar(40) empfaenger String[] @default([]) erfolgreich Boolean @default(true) meldung String? @db.VarChar(500) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) termin Wartungstermin @relation(fields: [terminId], references: [id], onDelete: Cascade) @@unique([terminId, stufe]) @@index([organisationId, versendetAm(sort: Desc)]) @@map("erinnerung") } // ============================================================================= // Phase 3 — Einsätze und Übungen // // Ordnungsprinzip: // Einsatz der Vorgang selbst, mit Ort, Zeitstempeln und Bericht // Einsatzfahrzeug welches Fahrzeug wann ausgerückt und eingerückt ist // Einsatzkraft wer mitgefahren ist, in welcher Funktion, wie lange // Materialverbrauch was dabei verbraucht oder beschädigt wurde // Lagemeldung was währenddessen gemeldet wurde, mit Foto // PortalZugang ein zeitlich begrenzter Link für eine Fremdwehr // Uebung dasselbe für Übungen, mit Thema und Lernzielen // // Drei Festlegungen, die den Aufbau erklären: // // 1. Die Zeitstempel sind der Kern. Alarmierung, Ausrücken, Eintreffen, // Einsatzende, Einrücken, Einsatzbereitschaft — daraus rechnet sich die // Ausrückzeit, die Anfahrtszeit und die Einsatzdauer. Sie stehen deshalb // einzeln und nicht als Sammelfeld. // // 2. Koordinaten liegen doppelt: als Dezimalgrad, die Prisma lesen kann, und // als PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides // zusammen — sonst müsste jede Schreiboperation Roh-SQL sein. // // 3. Ein freigegebener Einsatzbericht ist unveränderbar, dieselbe Logik wie // beim Prüfprotokoll: er geht an die Gemeinde und an die Versicherung. // ============================================================================= /// Einsatzkategorie nach der bayerischen Feuerwehrstatistik. Danach meldet die /// Wehr ihre Zahlen an die Gemeinde und den Landkreis. enum EinsatzKategorie { BRAND_KLEIN BRAND_MITTEL BRAND_GROSS TECHNISCHE_HILFE_KLEIN TECHNISCHE_HILFE_MITTEL TECHNISCHE_HILFE_GROSS GEFAHRGUT /// Unterstützung des Rettungsdienstes, z. B. Türöffnung, Tragehilfe. RETTUNGSDIENST /// Auslösung einer Brandmeldeanlage ohne Schadenfeuer. BRANDMELDEANLAGE BOESWILLIGER_ALARM SICHERHEITSWACHE /// Amtshilfe, Nachbarschaftshilfe, Sonstiges. UNTERSTUETZUNG SONSTIGES } /// Lebenszyklus eines Einsatzes. enum EinsatzStatus { /// Schnellerfassung: Stichwort und Fahrzeuge stehen, der Rest fehlt noch. ENTWURF /// Läuft gerade — der Alarmmonitor und das Portal zeigen ihn an. LAUFEND /// Nachbereitet und abgeschlossen. ABGESCHLOSSEN /// Bericht freigegeben und festgeschrieben. FREIGEGEBEN } /// Wie alarmiert wurde. Für die Auswertung, ob die Alarmierungswege greifen. enum Alarmierungsart { SIRENE MELDEEMPFAENGER TELEFON APP PERSOENLICH SONSTIGES } /// Funktion einer Einsatzkraft. Aus ihr ergibt sich die Stärke nach FwDV 3. enum EinsatzFunktion { EINSATZLEITER ZUGFUEHRER GRUPPENFUEHRER STAFFELFUEHRER TRUPPFUEHRER MASCHINIST MELDER TRUPPMANN ATEMSCHUTZTRAEGER SICHERHEITSTRUPP SANITAETER SONSTIGES } /// Art einer Übung. enum UebungsArt { PRAXIS THEORIE ATEMSCHUTZ MASCHINIST FUNK ERSTE_HILFE /// Gemeinsam mit Nachbarwehren, THW oder Rettungsdienst. GEMEINSCHAFTSUEBUNG SONSTIGES } /// Woher eine Lagemeldung kommt. enum LagemeldungQuelle { /// Aus der Anwendung, von einer angemeldeten Person. INTERN /// Über den Portal-Link, von einer Fremdwehr ohne Konto. PORTAL } // ----------------------------------------------------------------------------- /// Ein Einsatz. model Einsatz { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN". einsatznummer String @db.VarChar(40) /// Einsatzstichwort der Leitstelle, z. B. "B 3 Wohnhausbrand". stichwort String @db.VarChar(160) kategorie EinsatzKategorie @default(SONSTIGES) status EinsatzStatus @default(ENTWURF) /// Einsatznummer der Integrierten Leitstelle — die Klammer zu deren Akte. ilsNummer String? @db.VarChar(60) alarmierungsart Alarmierungsart? // Ort strasse String? @db.VarChar(200) hausnummer String? @db.VarChar(20) plz String? @db.VarChar(10) ort String? @db.VarChar(120) ortsteil String? @db.VarChar(120) /// Objektbezeichnung, z. B. "Grundschule", "Autobahn A9, km 312". objekt String? @db.VarChar(200) /// Koordinaten als Dezimalgrad — von Prisma lesbar. breitengrad Decimal? @db.Decimal(9, 6) laengengrad Decimal? @db.Decimal(9, 6) /// Dieselben Koordinaten als PostGIS-Punkt, von einem Trigger gepflegt. /// Ab Phase 6 zeichnet die Karte daraus. koordinaten Unsupported("geography(Point, 4326)")? /// Woher sie stammen: "nominatim", "manuell", "leitstelle". koordinatenQuelle String? @db.VarChar(40) // Zeitstempel — der Kern jeder Einsatzauswertung alarmiertUm DateTime? @db.Timestamptz(3) ausgeruecktUm DateTime? @db.Timestamptz(3) eingetroffenUm DateTime? @db.Timestamptz(3) einsatzendeUm DateTime? @db.Timestamptz(3) eingeruecktUm DateTime? @db.Timestamptz(3) einsatzbereitUm DateTime? @db.Timestamptz(3) einsatzleiterId String? @db.Uuid beschreibung String? @db.VarChar(4000) bericht String? wetter String? @db.VarChar(200) temperatur Int? /// Wer sonst vor Ort war: Polizei, Rettungsdienst, THW, Nachbarwehren. weitereKraefte String? @db.VarChar(500) /// Zahlen für die Statistik der Gemeinde. geretteteePersonen Int @default(0) verletztePersonen Int @default(0) totePersonen Int @default(0) sachschaden Decimal? @db.Decimal(12, 2) /// Kostenersatzpflichtiger Einsatz (Art. 28 BayFwG). kostenpflichtig Boolean @default(false) /// Der erzeugte Einsatzbericht als PDF. berichtDateiId String? @db.Uuid freigegebenAm DateTime? @db.Timestamptz(3) freigegebenVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatzleiter Person? @relation("EinsatzLeitung", fields: [einsatzleiterId], references: [id], onDelete: SetNull) freigegebenVon Person? @relation("EinsatzFreigabe", fields: [freigegebenVonId], references: [id], onDelete: SetNull) berichtDatei Datei? @relation("EinsatzBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) fahrzeuge Einsatzfahrzeug[] kraefte Einsatzkraft[] material Materialverbrauch[] lagemeldungen Lagemeldung[] portalzugaenge PortalZugang[] fahrten Fahrt[] anwesenheiten Anwesenheit[] // Phase 5 atemschutz Atemschutzeinsatz[] // Phase 6 lagekarte Lagekarte? wasserfoerderungen Wasserfoerderung[] @@unique([organisationId, einsatznummer]) @@index([organisationId, alarmiertUm(sort: Desc)]) @@index([organisationId, status]) @@index([organisationId, kategorie]) @@map("einsatz") } /// Ein Fahrzeug in einem Einsatz, mit eigenen Zeiten: nicht jedes Fahrzeug /// rückt gleichzeitig aus, und die Nachalarmierung soll sichtbar bleiben. model Einsatzfahrzeug { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid einsatzId String @db.Uuid fahrzeugId String @db.Uuid ausgeruecktUm DateTime? @db.Timestamptz(3) eingetroffenUm DateTime? @db.Timestamptz(3) eingeruecktUm DateTime? @db.Timestamptz(3) kilometerAbfahrt Int? kilometerRueckkehr Int? /// Verbrauchtes Löschwasser in Litern. wasserabgabe Int? bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict) @@unique([einsatzId, fahrzeugId]) @@index([organisationId, einsatzId]) @@index([organisationId, fahrzeugId]) @@map("einsatzfahrzeug") } /// Wer mitgefahren ist. Grundlage für die Stärkemeldung nach FwDV 3 und für /// den Beteiligungsnachweis je Person. model Einsatzkraft { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid einsatzId String @db.Uuid personId String @db.Uuid funktion EinsatzFunktion @default(TRUPPMANN) /// Auf welchem Fahrzeug — leer, wenn jemand selbst angefahren ist. fahrzeugId String? @db.Uuid vonUm DateTime? @db.Timestamptz(3) bisUm DateTime? @db.Timestamptz(3) /// War unter Atemschutz im Einsatz. Zählt auf die Nachweise nach FwDV 7. atemschutz Boolean @default(false) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) person Person @relation("EinsatzTeilnahme", fields: [personId], references: [id], onDelete: Cascade) fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) @@unique([einsatzId, personId]) @@index([organisationId, einsatzId]) @@index([organisationId, personId]) @@map("einsatzkraft") } /// Was verbraucht oder beschädigt wurde — Schaummittel, Bindemittel, ein /// zerschnittener Schlauch. Grundlage für Nachbeschaffung und Kostenersatz. model Materialverbrauch { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid einsatzId String @db.Uuid /// Verweis auf ein erfasstes Gerät, wo es eines gibt. geraetId String? @db.Uuid bezeichnung String @db.VarChar(200) menge Decimal @db.Decimal(10, 2) einheit String @db.VarChar(20) kosten Decimal? @db.Decimal(10, 2) /// Nicht verbraucht, sondern kaputtgegangen — dann folgt ein Mangel. beschaedigt Boolean @default(false) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull) @@index([organisationId, einsatzId]) @@map("materialverbrauch") } /// Eine Meldung während des Einsatzes. Aus der Anwendung oder über den /// Portal-Link einer Fremdwehr. model Lagemeldung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid einsatzId String @db.Uuid zeitpunkt DateTime @default(now()) @db.Timestamptz(3) text String @db.VarChar(2000) quelle LagemeldungQuelle @default(INTERN) verfasserId String? @db.Uuid /// Klarname oder Wehrname — bei Portalmeldungen die einzige Angabe. verfasserName String? @db.VarChar(200) fotoDateiId String? @db.Uuid /// Hebt die Meldung im Verlauf hervor: Menschenrettung, Nachforderung, /// Lageänderung. wichtig Boolean @default(false) /// Über welchen Portalzugang die Meldung kam. portalZugangId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) verfasser Person? @relation("LagemeldungVerfasser", fields: [verfasserId], references: [id], onDelete: SetNull) foto Datei? @relation("LagemeldungFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull) portalZugang PortalZugang? @relation(fields: [portalZugangId], references: [id], onDelete: SetNull) @@index([organisationId, einsatzId, zeitpunkt]) @@map("lagemeldung") } /// Ein zeitlich begrenzter Zugang zum Einsatzportal für eine Fremdwehr. /// /// Der Link enthält die Kennung und ein Geheimnis; gespeichert wird nur dessen /// Hash. Er gilt bis gueltigBis, lässt sich jederzeit widerrufen und zählt /// jeden Aufruf mit — ein Link, der nach dem Einsatz noch benutzt wird, fällt /// so auf. model PortalZugang { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid einsatzId String @db.Uuid /// Für wen der Link ist, z. B. "FF Nachbarhausen". bezeichnung String @db.VarChar(160) /// bcrypt-Hash des Geheimnisses aus dem Link. tokenHash String @db.VarChar(120) gueltigBis DateTime @db.Timestamptz(3) widerrufenAm DateTime? @db.Timestamptz(3) aufrufe Int @default(0) letzterAufruf DateTime? @db.Timestamptz(3) /// Zuletzt sehende IP — für den Fall, dass ein Link weitergegeben wurde. letzteIp String? @db.Inet erstelltVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) erstelltVon Person? @relation("PortalzugangErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) lagemeldungen Lagemeldung[] @@index([organisationId, einsatzId]) @@index([gueltigBis]) @@map("portal_zugang") } /// Eine Übung. Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und /// Lernzielen statt Schadenlage — und ohne Portal. model Uebung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN". nummer String @db.VarChar(40) thema String @db.VarChar(200) art UebungsArt @default(PRAXIS) datum DateTime @db.Date beginnUm DateTime? @db.Timestamptz(3) endeUm DateTime? @db.Timestamptz(3) ort String? @db.VarChar(200) leiterId String? @db.Uuid /// Was gelernt werden soll — die Lernziele der Ausbildung. lernziele String? @db.VarChar(2000) beschreibung String? @db.VarChar(4000) bericht String? berichtDateiId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull) berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) teilnahmen Uebungsteilnahme[] fahrten Fahrt[] anwesenheiten Anwesenheit[] // Phase 5 atemschutz Atemschutzeinsatz[] belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungUebung") @@unique([organisationId, nummer]) @@index([organisationId, datum(sort: Desc)]) @@index([organisationId, art]) @@map("uebung") } /// Wer an einer Übung teilgenommen hat. Der Nachweis, den eine Wehr für die /// Beförderung und für die Statistik braucht. model Uebungsteilnahme { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid uebungId String @db.Uuid personId String @db.Uuid funktion EinsatzFunktion? vonUm DateTime? @db.Timestamptz(3) bisUm DateTime? @db.Timestamptz(3) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) uebung Uebung @relation(fields: [uebungId], references: [id], onDelete: Cascade) person Person @relation("UebungTeilnahme", fields: [personId], references: [id], onDelete: Cascade) @@unique([uebungId, personId]) @@index([organisationId, personId]) @@map("uebungsteilnahme") } // ============================================================================= // Phase 4 — Terminal im Gerätehaus // // Was am Wandtablet in der Fahrzeughalle erfasst wird: // // Fahrt das Fahrtenbuch — wer ist wann womit wohin gefahren // Anwesenheit ein- und auschecken zu Dienst, Übung oder Einsatz // Waescheabgabe Schutzkleidung zur Reinigung geben und zurückbekommen // Inventurgang Bestand am Regal scannen, Fehlendes finden // KioskVorgang Idempotenz: was das Terminal offline erfasst hat // SyncKonflikt was sich beim Nachtragen nicht auflösen ließ // // Die beiden letzten sind der Kern des Offline-Betriebs. Ein Tablet in einer // Halle aus Stahlbeton hat regelmäßig kein Netz; erfasst wird trotzdem, und // nachgetragen wird, sobald es wieder geht. Damit ein zweimal abgeschickter // Vorgang nicht zweimal ankommt, trägt jeder eine im Browser erzeugte Kennung. // ============================================================================= /// Wozu eine Fahrt unternommen wurde. enum FahrtZweck { EINSATZ UEBUNG AUSBILDUNG WERKSTATT BESCHAFFUNG /// Probefahrt, Überführung, Fahrsicherheitstraining. SONSTIGES } /// Weg der Schutzkleidung durch die Reinigung. enum WaescheStatus { ABGEGEBEN IN_REINIGUNG ZURUECK /// Nicht wieder aufgetaucht — muss ersetzt werden. VERSCHOLLEN } /// Wozu jemand am Terminal eincheckt. enum AnwesenheitAnlass { DIENST UEBUNG EINSATZ ARBEITSDIENST SONSTIGES } /// Stand eines Inventurgangs. enum InventurStatus { LAEUFT ABGESCHLOSSEN ABGEBROCHEN } /// Was ein offline erfasster Vorgang bewirken soll. enum KioskVorgangArt { MANGEL_MELDEN FAHRT_BEGINNEN FAHRT_BEENDEN ANWESENHEIT_KOMMEN ANWESENHEIT_GEHEN WAESCHE_ABGEBEN INVENTUR_SCANNEN PRUEFUNG_ERFASSEN } /// Warum sich ein nachgetragener Vorgang nicht anwenden ließ. enum KonfliktGrund { /// Der Datensatz, auf den er sich bezieht, gibt es nicht mehr. ZIEL_FEHLT /// Der Datensatz wurde zwischenzeitlich festgeschrieben. ZIEL_GESPERRT /// Zwei Terminals haben dasselbe erfasst. DOPPELT /// Die Angaben passen nicht mehr zum Stand auf dem Server. VERALTET SONSTIGES } // ----------------------------------------------------------------------------- /// Eine Fahrt im Fahrtenbuch. /// /// Nach § 6 StVO-Nachweispflicht und nach der Dienstanweisung der meisten /// Wehren muss nachvollziehbar sein, wer wann mit welchem Fahrzeug unterwegs /// war. Am Terminal ist das ein Vorgang von zwanzig Sekunden: Fahrzeug wählen, /// Kilometerstand eintippen, Zweck antippen. model Fahrt { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid fahrzeugId String @db.Uuid fahrerId String @db.Uuid zweck FahrtZweck @default(SONSTIGES) /// Wenn die Fahrt zu einem Einsatz oder einer Übung gehört. einsatzId String? @db.Uuid uebungId String? @db.Uuid abfahrtUm DateTime @default(now()) @db.Timestamptz(3) rueckkehrUm DateTime? @db.Timestamptz(3) kilometerAbfahrt Int kilometerRueckkehr Int? ziel String? @db.VarChar(200) /// Getankte Menge in Litern, wenn unterwegs getankt wurde. tankmenge Decimal? @db.Decimal(8, 2) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict) fahrer Person @relation("FahrtFahrer", fields: [fahrerId], references: [id], onDelete: Restrict) einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull) uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull) @@index([organisationId, fahrzeugId, abfahrtUm(sort: Desc)]) @@index([organisationId, fahrerId]) @@map("fahrt") } /// Anwesenheit am Gerätehaus. /// /// Wer eincheckt, ist da — für die Übungsteilnahme, für den Arbeitsdienst und /// im Einsatzfall dafür, dass die Einsatzleitung weiß, wer im Haus ist. model Anwesenheit { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid anlass AnwesenheitAnlass @default(DIENST) angemeldetUm DateTime @default(now()) @db.Timestamptz(3) abgemeldetUm DateTime? @db.Timestamptz(3) einsatzId String? @db.Uuid uebungId String? @db.Uuid bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("AnwesenheitPerson", fields: [personId], references: [id], onDelete: Cascade) einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull) uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull) @@index([organisationId, personId, angemeldetUm(sort: Desc)]) @@index([organisationId, abgemeldetUm]) @@map("anwesenheit") } /// Schutzkleidung in der Reinigung. /// /// Wer seine Einsatzjacke abgibt, steht ohne da — deshalb hängt an der Abgabe /// auch die Frage, ob Ersatz vorhanden ist. Erfasst wird am Terminal beim /// Einwurf in den Wäschekorb. model Waescheabgabe { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid /// Wenn das Kleidungsstück als Gerät erfasst ist. geraetId String? @db.Uuid bezeichnung String @db.VarChar(200) menge Int @default(1) status WaescheStatus @default(ABGEGEBEN) /// Grund: Einsatzverschmutzung, Kontamination, Routine. grund String? @db.VarChar(200) abgegebenAm DateTime @default(now()) @db.Timestamptz(3) zurueckAm DateTime? @db.Timestamptz(3) /// Wer die Rückgabe bestätigt hat. angenommenVonId String? @db.Uuid bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("WaescheTraeger", fields: [personId], references: [id], onDelete: Cascade) geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull) angenommenVon Person? @relation("WaescheAnnahme", fields: [angenommenVonId], references: [id], onDelete: SetNull) @@index([organisationId, status]) @@index([organisationId, personId]) @@map("waescheabgabe") } /// Ein Inventurgang. /// /// Der Gerätewart geht mit dem Tablet durch die Halle und scannt, was da ist. /// Am Ende steht die Liste dessen, was fehlt — das ist der Zweck der Sache. model Inventurgang { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid bezeichnung String @db.VarChar(160) status InventurStatus @default(LAEUFT) /// Worauf sich der Gang bezieht — ein Standort oder ein Fahrzeug. standortId String? @db.Uuid fahrzeugId String? @db.Uuid begonnenAm DateTime @default(now()) @db.Timestamptz(3) abgeschlossenAm DateTime? @db.Timestamptz(3) begonnenVonId String? @db.Uuid bemerkung String? @db.VarChar(1000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull) fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) begonnenVon Person? @relation("InventurLeitung", fields: [begonnenVonId], references: [id], onDelete: SetNull) posten Inventurposten[] @@index([organisationId, status]) @@map("inventurgang") } /// Ein gescanntes Gerät innerhalb eines Inventurgangs. model Inventurposten { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid inventurId String @db.Uuid geraetId String @db.Uuid gescanntAm DateTime @default(now()) @db.Timestamptz(3) /// Am erwarteten Ort vorgefunden. gefunden Boolean @default(true) /// Wo es tatsächlich lag, wenn es nicht dort war, wo es sein sollte. vorgefundenAn String? @db.VarChar(200) bemerkung String? @db.VarChar(500) erfasstVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) inventur Inventurgang @relation(fields: [inventurId], references: [id], onDelete: Cascade) geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade) erfasstVon Person? @relation("InventurErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) @@unique([inventurId, geraetId]) @@index([organisationId, inventurId]) @@map("inventurposten") } /// Ein vom Terminal übermittelter Vorgang. /// /// Der Schlüssel zum Offline-Betrieb: die Kennung erzeugt der Browser, bevor /// er den Vorgang in seine Warteschlange legt. Kommt derselbe Vorgang nach /// einem abgebrochenen Sendeversuch ein zweites Mal an, erkennt der Server ihn /// an dieser Kennung wieder und antwortet mit dem Ergebnis von damals, statt /// alles noch einmal anzulegen. /// /// Deshalb ist die Kennung der Primärschlüssel und wird nicht vergeben, /// sondern angenommen. model KioskVorgang { /// Vom Browser erzeugt (crypto.randomUUID) — nicht vom Server. id String @id @db.Uuid organisationId String @db.Uuid art KioskVorgangArt personId String? @db.Uuid /// Wann der Vorgang am Terminal erfasst wurde — kann deutlich vor dem /// Eingang liegen. erfasstAm DateTime @db.Timestamptz(3) empfangenAm DateTime @default(now()) @db.Timestamptz(3) /// Was der Vorgang bewirkt hat: die Kennung des angelegten Datensatzes. ergebnisId String? @db.Uuid /// Die Nutzlast, wie sie das Terminal geschickt hat — für die Fehlersuche /// und für den Fall, dass ein Vorgang von Hand nachgezogen werden muss. nutzlast Json? @db.JsonB erfolgreich Boolean @default(true) meldung String? @db.VarChar(500) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person? @relation("KioskVorgangPerson", fields: [personId], references: [id], onDelete: SetNull) @@index([organisationId, empfangenAm(sort: Desc)]) @@index([organisationId, art]) @@map("kiosk_vorgang") } /// Ein Vorgang, der sich beim Nachtragen nicht anwenden ließ. /// /// Die Regel dahinter: bei Stammdaten gewinnt der Server, bei neuen /// Datensätzen der Client. Was in keine der beiden Schubladen passt — ein /// Mangel zu einem inzwischen ausgesonderten Gerät, eine Prüfung zu einem /// festgeschriebenen Protokoll — landet hier und wartet auf einen Menschen. model SyncKonflikt { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Die Kennung des Vorgangs, der nicht durchging. vorgangId String @db.Uuid art KioskVorgangArt grund KonfliktGrund @default(SONSTIGES) beschreibung String @db.VarChar(500) nutzlast Json? @db.JsonB personId String? @db.Uuid entstandenAm DateTime @default(now()) @db.Timestamptz(3) erledigtAm DateTime? @db.Timestamptz(3) erledigtVonId String? @db.Uuid erledigung String? @db.VarChar(500) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person? @relation("KonfliktVerursacher", fields: [personId], references: [id], onDelete: SetNull) erledigtVon Person? @relation("KonfliktErledigung", fields: [erledigtVonId], references: [id], onDelete: SetNull) @@index([organisationId, erledigtAm]) @@map("sync_konflikt") } // ============================================================================= // Phase 5 — Atemschutz nach FwDV 7 // // Das sicherheitsrelevanteste Modul des Projekts. Zwei Dinge bestimmen den // Aufbau: // // 1. **Die Zeit gehoert dem Server.** Jeder Zeitpunkt steht als Zeitstempel // in der Datenbank, nie als heruntergezaehlte Restzeit. Der Browser // rechnet daraus nur die Anzeige. Ein Neuladen, ein Verbindungsabbruch // oder ein zweiter Bildschirm aendern daran nichts — es gibt keinen // Timer, der verloren gehen koennte. // 2. **Das Protokoll ist lueckenlos.** Jede Druckabfrage, jeder // Statuswechsel und jedes Mayday steht als eigene Zeile mit Zeitpunkt. // Gelöscht wird nichts; die Datenbank verweigert es (siehe Migration). // // Ueber allem steht der Hinweis, dass die analoge Ueberwachung fuehrend // bleibt. Er ist in der Anwendung fest verdrahtet und nur ueber eine // Konfigurationsoption abschaltbar, die eine dokumentierte Freigabe des // Kommandanten voraussetzt. // ============================================================================= /// Zustand eines Trupps. Die Reihenfolge ist der uebliche Verlauf. enum TruppStatus { /// Angelegt, Eingangsdruck erfasst, noch nicht unter Atemschutz. BEREIT /// Unter Atemschutz im Einsatz. IM_EINSATZ /// Auf dem Rueckweg — ab hier zaehlt die Zeit doppelt. RUECKWEG /// Draussen, Ausgangsdruck erfasst. Endzustand. DRAUSSEN } /// Wann ein Druck gemessen wurde. enum DruckArt { /// Vor dem Anlegen, am Geraet abgelesen. EINGANG /// Beim Erreichen des Einsatzziels. Aus dem Verbrauch bis hierher errechnet /// sich der Rueckzugsdruck — der Weg zurueck ist so lang wie der Weg hin. ZIEL /// Waehrend des Einsatzes, ueblicherweise alle zehn Minuten. ZWISCHEN /// Nach dem Herauskommen. AUSGANG } /// Was im Protokoll steht. enum EreignisArt { TRUPP_ANGELEGT EINSTIEG DRUCKABFRAGE RUECKZUG_ANGEORDNET RUECKZUG_ANGETRETEN HERAUSGEKOMMEN MAYDAY MAYDAY_AUFGEHOBEN HINWEIS } /// Ergebnis einer Untersuchung nach G26.3. enum G26Ergebnis { TAUGLICH BEFRISTET_TAUGLICH NACHUNTERSUCHUNG NICHT_TAUGLICH } /// Art der jaehrlichen Uebung nach FwDV 7. enum UebungsartAtemschutz { /// Belastungsuebung in der Atemschutzuebungsstrecke. UEBUNGSSTRECKE /// Einsatzuebung unter Atemschutz. EINSATZUEBUNG /// Unterweisung, theoretisch. UNTERWEISUNG } /// Eine Atemschutzueberwachung. /// /// Haengt an einem Einsatz oder einer Uebung — oder an keinem von beidem, denn /// im Ernstfall wird zuerst ueberwacht und spaeter zugeordnet. model Atemschutzeinsatz { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Wozu die Ueberwachung gehoert. Beides optional, siehe oben. einsatzId String? @db.Uuid uebungId String? @db.Uuid /// Wer die Ueberwachung fuehrt. Nach FwDV 7 eine benannte Person. ueberwacherId String @db.Uuid bezeichnung String @db.VarChar(200) beginnUm DateTime @default(now()) @db.Timestamptz(3) endeUm DateTime? @db.Timestamptz(3) /// Intervall der Druckabfrage in Sekunden. Vorgabe kommt aus der /// Konfiguration, laesst sich je Ueberwachung anpassen. abfrageIntervall Int @default(600) /// Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar. /// Der Kommandant legt ihn fest; die Formel steht in lib/atemschutz. sicherheitszuschlag Int @default(10) /// Ansprechdruck der Warneinrichtung des verwendeten Geraets, in bar. /// Steht je Ueberwachung im Datensatz, weil eine Wehr Geraete /// unterschiedlicher Baujahre im Bestand hat. warndruck Int @default(55) bemerkung String? @db.VarChar(2000) /// Das abgeschlossene Protokoll als PDF, liegt in MinIO. protokollDateiId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull) uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull) ueberwacher Person @relation("AtemschutzUeberwacher", fields: [ueberwacherId], references: [id], onDelete: Restrict) protokollDatei Datei? @relation("AtemschutzProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull) trupps Atemschutztrupp[] ereignisse AtemschutzEreignis[] @@index([organisationId, endeUm]) @@index([organisationId, beginnUm(sort: Desc)]) @@map("atemschutzeinsatz") } /// Ein Trupp unter Atemschutz. /// /// Die Zeiten stehen als Zeitstempel, nicht als Dauer: `einstiegUm` ist der /// Moment, ab dem die Uhr laeuft. Wie lange ein Trupp drin ist, rechnet jede /// Ansicht aus der aktuellen Zeit selbst aus — deshalb ueberlebt die Anzeige /// jeden Neustart. model Atemschutztrupp { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid atemschutzeinsatzId String @db.Uuid funkrufname String @db.VarChar(60) auftrag String? @db.VarChar(500) status TruppStatus @default(BEREIT) angelegtUm DateTime @default(now()) @db.Timestamptz(3) einstiegUm DateTime? @db.Timestamptz(3) rueckzugUm DateTime? @db.Timestamptz(3) drausenUm DateTime? @db.Timestamptz(3) /// Errechnet beim Einstieg aus den Eingangsdruecken. Steht fest im Datensatz, /// damit spaeter nachvollziehbar ist, mit welchem Wert gearbeitet wurde — /// auch wenn sich die Formel einmal aendert. rueckzugsdruck Int? /// Laeuft ein Notfall? Zeitpunkt statt Kennzeichen, damit die Dauer im /// Protokoll steht. maydayUm DateTime? @db.Timestamptz(3) maydayAufgehobenUm DateTime? @db.Timestamptz(3) bemerkung String? @db.VarChar(1000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade) mitglieder Truppmitglied[] druckmessungen Druckmessung[] ereignisse AtemschutzEreignis[] @@index([organisationId, atemschutzeinsatzId]) @@index([organisationId, status]) @@map("atemschutztrupp") } /// Wer im Trupp ist. /// /// Ueblich sind zwei, erlaubt sind drei — die Zuordnung steht hier und nicht /// als zwei Spalten am Trupp, weil sonst der dritte Mann nicht abbildbar waere. model Truppmitglied { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid truppId String @db.Uuid personId String @db.Uuid /// true beim Truppfuehrer. Genau einer je Trupp. truppfuehrer Boolean @default(false) /// Kennung des Pressluftatmers, falls erfasst — hilft beim Zuordnen der /// Flaschenfuellung hinterher. geraetId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade) person Person @relation("TruppMitgliedPerson", fields: [personId], references: [id], onDelete: Restrict) geraet Geraet? @relation("TruppPressluftatmer", fields: [geraetId], references: [id], onDelete: SetNull) @@unique([truppId, personId]) @@index([organisationId, personId]) @@map("truppmitglied") } /// Eine Druckmessung. /// /// Append-only: einmal erfasst, bleibt sie stehen. Ein Tippfehler wird durch /// eine neue Messung richtiggestellt, nicht durch Ueberschreiben — im /// Zweifelsfall zaehlt, was zum Zeitpunkt X abgelesen wurde. model Druckmessung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid truppId String @db.Uuid personId String @db.Uuid zeitpunkt DateTime @default(now()) @db.Timestamptz(3) /// Druck in bar. Ganzzahlig — feiner liest niemand ab. druck Int art DruckArt @default(ZWISCHEN) erfasstVonId String? @db.Uuid bemerkung String? @db.VarChar(300) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade) person Person @relation("DruckmessungPerson", fields: [personId], references: [id], onDelete: Restrict) erfasstVon Person? @relation("DruckmessungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) @@index([organisationId, truppId, zeitpunkt]) @@map("druckmessung") } /// Das Protokoll. /// /// Jede Zeile ein Ereignis mit Zeitpunkt. Zusammen mit den Druckmessungen ist /// das der lueckenlose Nachweis, den FwDV 7 verlangt — und der Grund, warum /// hier nichts geaendert und nichts geloescht werden kann. model AtemschutzEreignis { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid atemschutzeinsatzId String @db.Uuid truppId String? @db.Uuid zeitpunkt DateTime @default(now()) @db.Timestamptz(3) art EreignisArt text String @db.VarChar(500) /// Wer es ausgeloest hat. personId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade) trupp Atemschutztrupp? @relation(fields: [truppId], references: [id], onDelete: Cascade) person Person? @relation("AtemschutzEreignisPerson", fields: [personId], references: [id], onDelete: SetNull) @@index([organisationId, atemschutzeinsatzId, zeitpunkt]) @@map("atemschutz_ereignis") } /// Untersuchung nach G26.3. /// /// Steht bewusst nicht als Qualifikation aus Phase 1 da: eine G26 hat ein /// Ergebnis, einen Arzt und eine Frist, die sich nach dem Alter richtet — /// und sie ist die harte Voraussetzung dafuer, ueberhaupt eingesetzt zu /// werden. model G26Untersuchung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid untersuchtAm DateTime @db.Date gueltigBis DateTime @db.Date ergebnis G26Ergebnis @default(TAUGLICH) arzt String? @db.VarChar(200) bemerkung String? @db.VarChar(1000) /// Eingescannte Bescheinigung, liegt in MinIO. nachweisDateiId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("G26Person", fields: [personId], references: [id], onDelete: Cascade) nachweis Datei? @relation("G26Nachweis", fields: [nachweisDateiId], references: [id], onDelete: SetNull) @@index([organisationId, personId, gueltigBis(sort: Desc)]) @@index([organisationId, gueltigBis]) @@map("g26_untersuchung") } /// Belastungsuebung, Einsatzuebung oder Unterweisung. /// /// FwDV 7 verlangt beides jaehrlich. Wer eines davon versaeumt, ist nicht /// einsetzbar — auch mit gueltiger G26 nicht. model Belastungsuebung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid datum DateTime @db.Date art UebungsartAtemschutz @default(UEBUNGSSTRECKE) ort String? @db.VarChar(200) dauerMinuten Int? /// Wenn die Uebung im System als Uebung erfasst ist. uebungId String? @db.Uuid bemerkung String? @db.VarChar(1000) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("BelastungsuebungPerson", fields: [personId], references: [id], onDelete: Cascade) uebung Uebung? @relation("BelastungsuebungUebung", fields: [uebungId], references: [id], onDelete: SetNull) @@index([organisationId, personId, datum(sort: Desc)]) @@map("belastungsuebung") } // ============================================================================= // Phase 6 — Kartenmodule // // Alles, was einen Ort hat: Hydranten, Objekte mit Feuerwehrplänen, die // Lagekarte eines Einsatzes und die geplante Wasserförderstrecke. // // Koordinaten liegen überall doppelt — als Dezimalgrad und als PostGIS-Punkt. // Der Grund ist derselbe wie beim Einsatz aus Phase 3: Prisma kann den // PostGIS-Typ nicht lesen, und ohne die Grade daneben wäre jede Anzeige eine // Rohabfrage. Ein Trigger hält beides zusammen; die Umkreissuche läuft über // den PostGIS-Punkt, weil nur der einen Index hat, der Entfernungen kann. // ============================================================================= /// Woher das Löschwasser kommt. enum HydrantTyp { /// Der Regelfall im Ort: Deckel auf der Straße, Standrohr drauf. UNTERFLUR /// Sichtbare Säule. Im Winter schneller zu finden. UEBERFLUR /// Vorbereitete Entnahmestelle an einem Gewässer. SAUGSTELLE /// Löschwasserbehälter, meist unterirdisch. ZISTERNE /// Bach, Weiher, Fluss — mit allem, was daran hängt (Zufahrt, Jahreszeit). OFFENES_GEWAESSER SONSTIGES } /// Ob man sich darauf verlassen kann. enum HydrantStatus { EINSATZBEREIT /// Nutzbar, aber mit Einschränkung — geringe Leistung, schwer zugänglich. EINGESCHRAENKT DEFEKT /// Noch nie geprüft oder aus einem Import ohne Angabe übernommen. UNBEKANNT } /// Grobe Einordnung eines Objekts. Bestimmt, was im Einsatz zuerst zählt. enum ObjektKategorie { SCHULE KINDERGARTEN PFLEGEHEIM KRANKENHAUS VERSAMMLUNGSSTAETTE HOCHHAUS INDUSTRIE LANDWIRTSCHAFT GEFAHRSTOFFLAGER SONDERBAU SONSTIGES } /// Blattarten eines Feuerwehrplans nach DIN 14095. enum PlanArt { UEBERSICHTSPLAN GESCHOSSPLAN UMGEBUNGSPLAN SONDERPLAN /// Alles, was zusätzlich dazugehört: Betriebsanweisung, Stoffliste. ANLAGE } /// Was auf der Lagekarte liegt. enum LageelementArt { /// Taktisches Zeichen nach FwDV 102, siehe lib/karte/taktische-zeichen.ts. ZEICHEN /// Linienzug — Schlauchleitung, Absperrung, Anfahrtsweg. LINIE /// Geschlossene Fläche — Bereitstellungsraum, Gefahrenbereich. FLAECHE /// Freie Beschriftung. TEXT } /// Rolle eines Punktes in der Förderstrecke. enum FoerderpunktArt { /// Wo das Wasser herkommt. ENTNAHME /// Verstärkerpumpe in der Strecke. VERSTAERKER /// Zwischenpunkt ohne Pumpe — nur für den Streckenverlauf und die Höhe. WEGPUNKT /// Die Einsatzstelle. ZIEL } /// Ein Hydrant oder eine andere Löschwasserstelle. /// /// Die Frage, die dieses Modell beantworten muss, wird auf der Anfahrt /// gestellt und lautet: **Was liegt in der Nähe, und was gibt es her?** /// Deshalb stehen Typ, Nennweite und Durchfluss gleichrangig neben dem Ort — /// ein Unterflurhydrant mit 80er Leitung ist etwas anderes als einer mit 150. model Hydrant { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Nummer oder Bezeichnung, wie sie die Wehr verwendet. bezeichnung String? @db.VarChar(120) typ HydrantTyp @default(UNTERFLUR) status HydrantStatus @default(UNBEKANNT) /// Nennweite der Leitung in Millimetern (80, 100, 150 …). nennweite Int? /// Ergiebigkeit in Litern je Minute, aus der Prüfung oder vom Versorger. durchfluss Int? strasse String? @db.VarChar(200) hausnummer String? @db.VarChar(20) ortsteil String? @db.VarChar(120) breitengrad Decimal @db.Decimal(9, 6) laengengrad Decimal @db.Decimal(9, 6) koordinaten Unsupported("geography(Point, 4326)")? letztePruefung DateTime? @db.Date naechstePruefung DateTime? @db.Date /// Wer die Leitung betreibt — im Zweifel der Ansprechpartner bei Defekt. betreiber String? @db.VarChar(200) /// Was man vor Ort wissen muss: „hinter der Hecke", „im Winter geräumt". hinweis String? @db.VarChar(1000) fotoDateiId String? @db.Uuid /// Woher der Datensatz stammt: "hand", "geojson", "stadtwerke-csv". quelle String? @db.VarChar(40) /// Kennung beim Versorger, damit ein zweiter Import nichts doppelt anlegt. externeNummer String? @db.VarChar(80) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) foto Datei? @relation("HydrantFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull) @@unique([organisationId, externeNummer]) @@index([organisationId, status]) @@index([organisationId, typ]) @@map("hydrant") } /// Ein Objekt mit Feuerwehrplan. /// /// Was hier steht, wird auf der Anfahrt gelesen — oft vom Beifahrersitz aus. /// Deshalb sind Zufahrt, Gefahren und Schlüsseldepot eigene Felder und keine /// Fließtextnotiz: im Einsatz sucht niemand in einem Absatz. model Objekt { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid name String @db.VarChar(200) kategorie ObjektKategorie @default(SONSTIGES) strasse String? @db.VarChar(200) hausnummer String? @db.VarChar(20) plz String? @db.VarChar(10) ort String? @db.VarChar(120) breitengrad Decimal? @db.Decimal(9, 6) laengengrad Decimal? @db.Decimal(9, 6) koordinaten Unsupported("geography(Point, 4326)")? /// Wie man hinkommt: Tordurchfahrt, Höhenbegrenzung, Aufstellfläche. zufahrt String? @db.VarChar(2000) /// Was einen erwartet: Photovoltaik, Gasflaschen, Silo, Tiere. gefahren String? @db.VarChar(2000) /// Wo der Schlüssel liegt und wie man drankommt. schluesseldepot String? @db.VarChar(500) /// Löschwasser am Objekt: Wandhydranten, Sprinklerzentrale, Steigleitung. loeschwasser String? @db.VarChar(1000) bemerkung String? @db.VarChar(2000) /// Anzahl der Personen, mit denen zu rechnen ist (Schule, Heim, Betrieb). personenzahl Int? erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) kontakte Objektkontakt[] plaene Objektplan[] @@index([organisationId, kategorie]) @@index([organisationId, name]) @@map("objekt") } /// Wen man beim Objekt anruft. model Objektkontakt { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid objektId String @db.Uuid name String @db.VarChar(200) funktion String? @db.VarChar(120) telefon String? @db.VarChar(60) mobil String? @db.VarChar(60) /// Erreichbar rund um die Uhr? Im Einsatz die einzige Frage, die zählt. jederzeit Boolean @default(false) bemerkung String? @db.VarChar(500) reihenfolge Int @default(0) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade) @@index([organisationId, objektId]) @@map("objektkontakt") } /// Ein Blatt des Feuerwehrplans nach DIN 14095. model Objektplan { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid objektId String @db.Uuid dateiId String @db.Uuid art PlanArt @default(UEBERSICHTSPLAN) bezeichnung String @db.VarChar(200) /// Stand des Plans — ein Feuerwehrplan von 2009 ist eine Vermutung. standAm DateTime? @db.Date reihenfolge Int @default(0) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade) datei Datei @relation("ObjektplanDatei", fields: [dateiId], references: [id], onDelete: Restrict) @@index([organisationId, objektId]) @@map("objektplan") } /// Die Lagekarte eines Einsatzes. /// /// Eine je Einsatz. Sie ist der gemeinsame Zeichenblock: was einer einträgt, /// sehen die anderen — verteilt über denselben Ereignisstrom wie die /// Atemschutzüberwachung aus Phase 5. model Lagekarte { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid einsatzId String @unique @db.Uuid /// Kartenausschnitt, damit alle denselben Blick haben. breitengrad Decimal? @db.Decimal(9, 6) laengengrad Decimal? @db.Decimal(9, 6) zoomstufe Int? erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) elemente Lageelement[] @@map("lagekarte") } /// Ein Element auf der Lagekarte. /// /// Die Geometrie steht als JSON: ein Punkt für ein taktisches Zeichen, eine /// Liste von Punkten für Linie und Fläche. PostGIS wäre hier zu viel — /// gerechnet wird damit nichts, es wird nur gezeichnet. /// /// Gelöscht wird weich: eine Lagekarte ist die Mitschrift einer Entscheidung, /// und was jemand um 14:12 eingezeichnet hat, gehört auch dann noch dazu, wenn /// es um 14:20 nicht mehr stimmte. model Lageelement { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid lagekarteId String @db.Uuid art LageelementArt @default(ZEICHEN) /// Schlüssel aus dem Zeichensatz, etwa "loeschfahrzeug" oder "brandstelle". zeichen String? @db.VarChar(60) text String? @db.VarChar(300) farbe String? @db.VarChar(20) /// [[laenge, breite], …] in WGS 84. geometrie Json @db.JsonB /// Drehung eines Zeichens in Grad — ein Fahrzeug steht selten nach Norden. drehung Int @default(0) erstelltVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) lagekarte Lagekarte @relation(fields: [lagekarteId], references: [id], onDelete: Cascade) erstelltVon Person? @relation("LageelementZeichner", fields: [erstelltVonId], references: [id], onDelete: SetNull) @@index([organisationId, lagekarteId, geloeschtAm]) @@map("lageelement") } /// Eine geplante Wasserförderstrecke. /// /// Die Rechnung dahinter ist Physik mit klaren Formeln (siehe /// lib/karte/wasserfoerderung.ts) — deshalb steht hier nur, woraus gerechnet /// wurde. Das Ergebnis wird nicht gespeichert, sondern bei jeder Anzeige neu /// gerechnet: eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, /// ohne falsch auszusehen. model Wasserfoerderung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid einsatzId String? @db.Uuid bezeichnung String @db.VarChar(200) /// Schlauchdurchmesser in Millimetern: 75 (B) oder 110 (A). durchmesser Int @default(75) /// Geplante Fördermenge in Litern je Minute. foerdermenge Int @default(800) /// Ausgangsdruck der ersten Pumpe in bar. ausgangsdruck Decimal @default(8) @db.Decimal(4, 1) /// Mindestdruck am Eingang jeder weiteren Pumpe in bar. eingangsdruck Decimal @default(1.5) @db.Decimal(4, 1) bemerkung String? @db.VarChar(2000) geplantVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull) geplantVon Person? @relation("WasserfoerderungPlaner", fields: [geplantVonId], references: [id], onDelete: SetNull) punkte Foerderpunkt[] @@index([organisationId, einsatzId]) @@map("wasserfoerderung") } /// Ein Punkt der Förderstrecke. model Foerderpunkt { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid wasserfoerderungId String @db.Uuid reihenfolge Int art FoerderpunktArt @default(WEGPUNKT) bezeichnung String? @db.VarChar(200) breitengrad Decimal @db.Decimal(9, 6) laengengrad Decimal @db.Decimal(9, 6) /// Höhe über Normalnull in Metern. Der Höhenunterschied kostet Druck: /// zehn Meter Steigung sind ein bar. hoehe Decimal? @db.Decimal(7, 2) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) wasserfoerderung Wasserfoerderung @relation(fields: [wasserfoerderungId], references: [id], onDelete: Cascade) @@unique([wasserfoerderungId, reihenfolge]) @@index([organisationId, wasserfoerderungId]) @@map("foerderpunkt") } // ============================================================================= // Phase 7 — Benachrichtigungen und Termine // // Zwei Themen, die zusammengehören: ein Termin ist der häufigste Anlass für // eine Benachrichtigung, und eine Benachrichtigung ohne Termin dahinter ist // meistens eine Frist. // // Der Aufbau folgt einer Regel: **niemand wird ungefragt benachrichtigt.** // Jede Person entscheidet je Ereignis und Kanal selbst. Die Wehr legt fest, // was es überhaupt gibt (Vorlagen) und wen es betrifft (Rollen) — ob // zugestellt wird, entscheidet die Person. // ============================================================================= /// Wofür es eine Benachrichtigung geben kann. enum EreignisTyp { PRUEFUNG_FAELLIG MANGEL_GEMELDET QUALIFIKATION_LAEUFT_AB TERMIN_NEU TERMIN_ERINNERUNG TERMIN_ABGESAGT EINSATZBERICHT_FREIGEGEBEN } /// Wie zugestellt wird. enum Kanal { /// SMTP über den eigenen Mailserver. MAIL /// Telegram-Bot. Externer Dienst — nur Hinweise ohne Personenbezug. TELEGRAM /// ntfy, selbst gehostet. Der einzige Push-Weg ohne fremden Server. NTFY /// Web-Push über den Browser. Braucht VAPID-Schlüssel. WEBPUSH } /// Was für ein Termin. enum TerminArt { DIENST UEBUNG LEHRGANG ARBEITSDIENST VERANSTALTUNG SITZUNG SONSTIGES } /// Wie oft ein Termin wiederkehrt. enum Serienmuster { KEINE WOECHENTLICH ZWEIWOECHENTLICH MONATLICH JAEHRLICH } /// Was jemand zu einem Termin gesagt hat. enum Teilnahmestand { ZUGESAGT ABGESAGT /// Angemeldet, aber die Liste war voll — rückt nach, wenn jemand absagt. WARTELISTE } /// Wie es einer Benachrichtigung ergangen ist. enum Zustellstand { WARTET GESENDET FEHLGESCHLAGEN /// Niemand hat diesen Kanal abonniert oder der Kanal ist aus. UEBERSPRUNGEN } /// Eine Vorlage je Ereignis und Kanal. /// /// Der Text enthält Platzhalter in geschweiften Klammern: `{titel}`, `{datum}`, /// `{wehr}`. Welche es gibt, steht in lib/benachrichtigung/vorlagen.ts — und /// die Oberfläche zeigt sie beim Bearbeiten an, damit niemand raten muss. /// /// Fehlt eine Vorlage, greift der eingebaute Standardtext. Eine Wehr, die /// nichts einstellt, bekommt trotzdem brauchbare Nachrichten. model Benachrichtigungsvorlage { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid ereignis EreignisTyp kanal Kanal /// Nur für E-Mail. Push-Dienste kennen keinen Betreff. betreff String? @db.VarChar(200) text String @db.VarChar(2000) /// Wen es angeht. Leer heißt: alle, die es abonniert haben. rollen RolleSchluessel[] aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) @@unique([organisationId, ereignis, kanal]) @@map("benachrichtigungsvorlage") } /// Wohin eine Person benachrichtigt werden will. /// /// Ein Datensatz je Person und Kanal, mit der Adresse dieses Kanals: die /// Chat-Kennung bei Telegram, das Thema bei ntfy, das Abonnement des Browsers /// bei Web-Push. Die E-Mail steht schon an der Person. /// /// `ereignisse` sagt, wofür dieser Kanal benutzt wird. Leer heißt: für nichts — /// **niemand wird ungefragt benachrichtigt.** model Zustellkanal { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid kanal Kanal /// Chat-Kennung, ntfy-Thema oder die Endpunktadresse des Browsers. ziel String @db.VarChar(500) /// Web-Push braucht zwei Schlüssel dazu. p256dh String? @db.VarChar(200) auth String? @db.VarChar(200) /// Wofür dieser Kanal benutzt wird. ereignisse EreignisTyp[] bezeichnung String? @db.VarChar(120) aktiv Boolean @default(true) /// Wann zuletzt etwas darüber ankam — ein toter Kanal fällt sonst nicht auf. letzteZustellung DateTime? @db.Timestamptz(3) fehlversuche Int @default(0) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("ZustellkanalPerson", fields: [personId], references: [id], onDelete: Cascade) @@unique([personId, kanal, ziel]) @@index([organisationId, kanal]) @@map("zustellkanal") } /// Eine verschickte oder zu verschickende Benachrichtigung. /// /// Sie steht **vor** dem Versand in der Tabelle, nicht danach: der /// Hintergrundprozess arbeitet sie ab und vermerkt, wie es ausging. Damit ist /// nachvollziehbar, wer wann worüber informiert wurde — und wer nicht, weil /// sein Kanal nicht erreichbar war. model Benachrichtigung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid ereignis EreignisTyp kanal Kanal personId String @db.Uuid betreff String? @db.VarChar(200) text String @db.VarChar(2000) /// Wohin es ging — zum Nachvollziehen, auch wenn der Kanal später wegfällt. ziel String @db.VarChar(500) /// Worum es ging: "Wartungstermin", "Termin", "Mangel" … entitaet String? @db.VarChar(80) entitaetId String? @db.Uuid stand Zustellstand @default(WARTET) versuche Int @default(0) gesendetAm DateTime? @db.Timestamptz(3) fehler String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("BenachrichtigungEmpfaenger", fields: [personId], references: [id], onDelete: Cascade) @@index([organisationId, stand]) @@index([organisationId, personId, erstelltAm(sort: Desc)]) @@map("benachrichtigung") } /// Ein Termin im Kalender der Wehr. /// /// Serientermine werden **ausgeschrieben**, nicht gerechnet: jeder Abend ist /// ein eigener Datensatz mit Verweis auf den ersten. Sonst ließe sich ein /// einzelner Abend nicht absagen, keine Teilnehmerliste führen und keine /// Anwesenheit abhaken — und genau das ist der Alltag. model Termin { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid art TerminArt @default(DIENST) titel String @db.VarChar(200) beschreibung String? @db.VarChar(4000) ort String? @db.VarChar(200) beginn DateTime @db.Timestamptz(3) ende DateTime? @db.Timestamptz(3) ganztaegig Boolean @default(false) /// Wer ihn leitet. leiterId String? @db.Uuid /// Ab wie vielen Zusagen die Warteliste beginnt. Null heißt: unbegrenzt. teilnehmerLimit Int? /// Bis wann man sich anmelden kann. anmeldeschluss DateTime? @db.Timestamptz(3) /// Erwartet die Wehr eine Rückmeldung? Sonst ist es nur ein Eintrag. anmeldungNoetig Boolean @default(false) /// Serie: der erste Termin trägt das Muster, alle tragen dieselbe Serienkennung. serieId String? @db.Uuid serienmuster Serienmuster @default(KEINE) /// Abgesagt statt gelöscht — wer schon zugesagt hat, soll den Grund sehen. abgesagtAm DateTime? @db.Timestamptz(3) absageGrund String? @db.VarChar(500) /// Wann erinnert wurde, damit es nicht zweimal passiert. erinnertAm DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) leiter Person? @relation("TerminLeitung", fields: [leiterId], references: [id], onDelete: SetNull) anmeldungen Terminanmeldung[] @@index([organisationId, beginn]) @@index([organisationId, serieId]) @@map("termin") } /// Zu- oder Absage einer Person zu einem Termin. model Terminanmeldung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid terminId String @db.Uuid personId String @db.Uuid stand Teilnahmestand @default(ZUGESAGT) /// Bei einer Absage: warum. Freiwillig, aber hilfreich für die Planung. grund String? @db.VarChar(300) /// Beim Abhaken der Anwesenheitsliste gesetzt. anwesend Boolean? gemeldetAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade) person Person @relation("TerminanmeldungPerson", fields: [personId], references: [id], onDelete: Cascade) @@unique([terminId, personId]) @@index([organisationId, personId]) @@map("terminanmeldung") } /// Ein Kalenderabonnement je Person. /// /// Der Kalender im Handy fragt die Adresse regelmäßig ab — ohne Anmeldung, /// also mit einem Geheimnis in der Adresse. Wie beim Einsatzportal aus Phase 3 /// liegt davon nur der Hash in der Datenbank: wer die Tabelle liest, bekommt /// keinen Zugang. model Kalenderzugang { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid bezeichnung String @db.VarChar(120) /// SHA-256 des Tokens. Das Token selbst sieht man genau einmal. tokenHash String @db.VarChar(64) /// Nur eigene Termine oder alle der Wehr? nurEigene Boolean @default(false) aktiv Boolean @default(true) letzterZugriff DateTime? @db.Timestamptz(3) zugriffe Int @default(0) erstelltAm DateTime @default(now()) @db.Timestamptz(3) widerrufenAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("KalenderzugangPerson", fields: [personId], references: [id], onDelete: Cascade) @@unique([tokenHash]) @@index([organisationId, personId]) @@map("kalenderzugang") } // ============================================================================= // Phase 8 — Lokale KI // // Der Satz, der über allen Tabellen hier steht und der ihren Aufbau bestimmt: // **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.** // // Daraus folgt, dass jede KI-Ausgabe zuerst ein eigener Datensatz ist und // nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst, // wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf // daneben und ist als Entwurf erkennbar. Das ist der Unterschied zwischen // einer Hilfe und einer Fälschung. // // Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper // als Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die // das Gerätehaus verlässt. Ohne die Container fehlt die Funktion — sie wird // nicht durch einen fremden Dienst ersetzt. // ============================================================================= /// Wofür ein Modell befragt wurde. enum KiArt { /// Sprache zu Text (faster-whisper). DIKTAT /// Aus Stichpunkten einen Einsatzberichtsentwurf. BERICHTSENTWURF /// Frage ans Wiki, beantwortet aus den eigenen Seiten. WIKI_ANTWORT /// Quittungsfoto zu Betrag, Datum, Händler. BELEGERFASSUNG /// Text für die semantische Suche einbetten. EINBETTUNG } /// Was aus einem Vorschlag geworden ist. /// /// ENTWURF ist der einzige Zustand, den die Maschine allein erreicht. Alles /// andere setzt ein Mensch — und genau deshalb steht es hier und nicht als /// Häkchen irgendwo. enum KiStand { ENTWURF UEBERNOMMEN VERWORFEN FEHLGESCHLAGEN } /// Womit eine Wiki-Seite zu tun hat. enum WikiKategorie { DIENSTVORSCHRIFT AUSBILDUNG GERAETE EINSATZ ORGANISATION SONSTIGES } /// Jede Befragung eines Modells, mit dem, was dabei herauskam. /// /// Die Tabelle ist der Nachweis, dass die Regel eingehalten wurde: zu jedem /// Text, der aus einem Modell stammt, steht hier, wer ihn wann übernommen oder /// verworfen hat. Ohne sie ließe sich hinterher nicht mehr sagen, welcher Satz /// in einem Einsatzbericht von einem Menschen stammt. /// /// Gespeichert wird auch, was nicht geklappt hat. Ein Modell, das dreimal am /// Tag in eine Zeitüberschreitung läuft, ist eine Information. model KiVorgang { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid art KiArt stand KiStand @default(ENTWURF) /// Welches Modell — bei einem Wechsel soll nachvollziehbar bleiben, womit /// ein alter Entwurf entstanden ist. modell String @db.VarChar(120) /// Worauf sich der Vorgang bezieht: "Einsatz", "Uebung", "Wikiseite". entitaet String? @db.VarChar(80) entitaetId String? @db.Uuid /// Die Eingabe, gekürzt. Nicht das ganze Audio, nicht das ganze Foto — /// die liegen als Datei in MinIO. eingabe String @db.VarChar(8000) ausgabe String? @db.VarChar(40000) fehler String? @db.VarChar(1000) dauerMs Int? tokenEingabe Int? tokenAusgabe Int? erzeugtVonId String? @db.Uuid /// Wer entschieden hat — und wann. Beides leer heißt: noch offen. entschiedenVonId String? @db.Uuid entschiedenAm DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) erzeugtVon Person? @relation("KiVorgangErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull) entschiedenVon Person? @relation("KiVorgangEntscheider", fields: [entschiedenVonId], references: [id], onDelete: SetNull) diktate Diktat[] belegentwuerfe Belegentwurf[] @@index([organisationId, art, erstelltAm(sort: Desc)]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, stand]) @@map("ki_vorgang") } /// Eine Seite im Wiki der Wehr. /// /// Inhalt ist Markdown, weil es sich ohne Werkzeug lesen lässt — auch in /// zwanzig Jahren, auch ohne FLORIAN. Dienstvorschriften und /// Ausbildungsunterlagen sollen die Anwendung überleben, die sie verwaltet. model Wikiseite { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid slug String @db.VarChar(160) titel String @db.VarChar(200) art WikiKategorie @default(SONSTIGES) inhalt String @db.Text /// Woher es stammt: "FwDV 7, Ausgabe 2002", "Beschluss der Kommandanten". /// Steht unter jeder Antwort, die aus dieser Seite gebaut wird. quelle String? @db.VarChar(300) schlagworte String[] /// Unveröffentlichte Seiten sind Entwürfe: sie werden nicht durchsucht und /// nicht zum Beantworten herangezogen. veroeffentlicht Boolean @default(true) autorId String? @db.Uuid /// Wann die Abschnitte zuletzt eingebettet wurden. Liegt es vor /// `geaendertAm`, ist die Seite seit der letzten Änderung nicht mehr /// durchsuchbar — und das steht dann auch in der Ansicht. eingebettetAm DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) autor Person? @relation("WikiseiteAutor", fields: [autorId], references: [id], onDelete: SetNull) abschnitte Wikiabschnitt[] versionen Wikiversion[] @@unique([organisationId, slug]) @@index([organisationId, art]) @@map("wikiseite") } /// Ein Abschnitt einer Wiki-Seite samt seiner Einbettung. /// /// Zerlegt wird an den Überschriften, nicht nach Zeichenzahl: ein Abschnitt /// soll für sich verständlich sein, weil er für sich zitiert wird. Wer eine /// Antwort bekommt, bekommt die Fundstelle dazu — und die ist dann ein /// Kapitel, kein aus der Mitte gerissener Absatz. /// /// `einbettung` ist für Prisma ein unbekannter Typ; gelesen und geschrieben /// wird sie über `$queryRaw`. Das ist der übliche Weg mit pgvector und der /// Grund, warum es in lib/ki/ eine eigene Datei dafür gibt. model Wikiabschnitt { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid seiteId String @db.Uuid reihenfolge Int ueberschrift String? @db.VarChar(300) text String @db.Text /// 768 Dimensionen — das liefert nomic-embed-text, das Standardmodell für /// Einbettungen unter Ollama. Ein anderes Modell mit anderer Länge braucht /// eine Migration; das steht im README. einbettung Unsupported("vector(768)")? /// Womit eingebettet wurde. Nach einem Modellwechsel sind alte Einbettungen /// nicht mehr vergleichbar und werden neu erzeugt. modell String? @db.VarChar(120) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade) @@unique([seiteId, reihenfolge]) @@index([organisationId]) @@map("wikiabschnitt") } /// Der Stand einer Seite vor einer Änderung. /// /// Eine Dienstvorschrift, die sich unbemerkt ändert, ist schlimmer als keine. /// Deshalb wird bei jeder Änderung der vorherige Text weggeschrieben — schlicht /// und vollständig, nicht als Differenz. model Wikiversion { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid seiteId String @db.Uuid nummer Int titel String @db.VarChar(200) inhalt String @db.Text /// Was der Bearbeiter dazu gesagt hat. bemerkung String? @db.VarChar(500) autorId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade) autor Person? @relation("WikiversionAutor", fields: [autorId], references: [id], onDelete: SetNull) @@unique([seiteId, nummer]) @@index([organisationId, seiteId]) @@map("wikiversion") } /// Eine Aufnahme und was daraus wurde. /// /// Gedacht für den Weg, den es heute schon gibt: der Einsatzleiter spricht auf /// der Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Nur dass /// jetzt schon ein Text dasteht, den jemand nur noch geradezieht. /// /// Die Aufnahme bleibt liegen, bis sie jemand entfernt: solange das Transkript /// noch nicht bestätigt ist, ist sie das einzige Original. model Diktat { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Die Aufnahme in MinIO. Nie ein Pfad im Dateisystem. audioDateiId String @db.Uuid dauerSekunden Int? sprache String @default("de") @db.VarChar(10) transkript String? @db.Text stand KiStand @default(ENTWURF) /// Wohin der Text soll, wenn er übernommen wird. entitaet String? @db.VarChar(80) entitaetId String? @db.Uuid kiVorgangId String? @db.Uuid erzeugtVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) audioDatei Datei @relation("DiktatAufnahme", fields: [audioDateiId], references: [id], onDelete: Restrict) kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull) erzeugtVon Person? @relation("DiktatErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull) @@index([organisationId, stand]) @@index([organisationId, entitaet, entitaetId]) @@map("diktat") } /// Was ein Modell auf einer Quittung gelesen hat — als Vorschlag. /// /// Bewusst eine eigene Tabelle und keine Buchung: die Kasse kommt in Phase 9, /// und ein Betrag, den eine Maschine aus einem Foto gelesen hat, ist keine /// Buchung. Er ist ein ausgefülltes Formular, das jemand gegenzeichnet. /// /// Alle Felder sind optional. Ein Modell, das den Betrag nicht findet, soll /// nichts erfinden — dann bleibt das Feld leer und ein Mensch trägt es ein. model Belegentwurf { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid fotoDateiId String @db.Uuid betrag Decimal? @db.Decimal(12, 2) waehrung String @default("EUR") @db.VarChar(3) belegdatum DateTime? @db.Date haendler String? @db.VarChar(200) kategorie String? @db.VarChar(80) /// Steuersatz in Prozent, falls erkannt. steuersatz Decimal? @db.Decimal(5, 2) /// Was das Modell geantwortet hat, unverändert. Für den Fall, dass die /// Auswertung daneben lag und jemand nachsehen will, woran es lag. rohantwort Json? stand KiStand @default(ENTWURF) kiVorgangId String? @db.Uuid erfasstVonId String? @db.Uuid bestaetigtVonId String? @db.Uuid bestaetigtAm DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) /// Die Buchung, die daraus geworden ist (Phase 9). Leer heißt: noch keine. buchungId String? @db.Uuid organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull) fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict) kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull) erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) bestaetigtVon Person? @relation("BelegentwurfBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull) @@index([organisationId, stand]) @@index([organisationId, belegdatum]) @@map("belegentwurf") } // ============================================================================= // Phase 9 — Kassenverwaltung // // Drei Festlegungen bestimmen alles Weitere. Sie stehen hier, weil sie sich // im Schema abbilden und nicht bloß in der Anwendung durchgesetzt werden: // // 1. **Beträge sind DECIMAL(12,2), niemals Fließkomma.** 0,1 + 0,2 ist in // binärer Fließkommadarstellung nicht 0,3, und eine Kasse, die sich um // Cent verrechnet, ist keine Kasse. Zwölf Stellen reichen für zehn // Milliarden — mehr als jede Wehr je bewegt. // // 2. **Eine festgeschriebene Buchung ist unveränderbar.** Korrekturen laufen // über eine Gegenbuchung mit Verweis auf das Original. Das ist keine // Förmlichkeit: ein Kassenbuch, in dem man rückwirkend ändern kann, ist // vor der Kassenprüfung wertlos. Der Trigger in der Migration setzt es // durch. // // 3. **Der Kassenabschluss friert seinen Zeitraum ein.** Danach nimmt die // Buchungsmaske kein Datum daraus mehr an. Sonst hätte der geprüfte // Bestand am Tag nach der Prüfung eine andere Zahl. // // Der Betrag ist immer **positiv**; die Richtung steht in `art`. Ein // vorzeichenbehafteter Betrag wäre kürzer, aber jede Auswertung müsste dann // darauf vertrauen, dass das Vorzeichen zur Kategorie passt — und beim ersten // Tippfehler stimmt die Summe nicht mehr. // ============================================================================= /// Welche Art von Konto. enum KontoArt { /// Die Barkasse im Gerätehaus. Wird bei der Prüfung gezählt. BAR /// Ein Bankkonto. Wird gegen den Auszug geprüft. BANK /// Rücklage für eine bestimmte Anschaffung — getrennt geführt, damit /// niemand versehentlich das Geld für das neue Fahrzeug verbraucht. RUECKLAGE } /// Geht Geld ein oder hinaus. enum BuchungsArt { EINNAHME AUSGABE } /// Wie es um einen Beitrag steht. enum BeitragStand { OFFEN BEZAHLT /// Erlassen — bei Härtefällen, Ehrenmitgliedern, Jugendlichen. ERLASSEN GEMAHNT } /// Wie es um eine Rechnung steht. enum RechnungStand { ENTWURF VERSENDET BEZAHLT GEMAHNT STORNIERT } /// Woraus eine Rechnung an Dritte entsteht. enum RechnungsAnlass { /// Böswilliger oder fahrlässig ausgelöster Fehlalarm. FEHLALARM /// Ölspur, auslaufende Betriebsstoffe — nach Verursacherprinzip. OELEINSATZ /// Absicherung, Brandsicherheitswache bei einer Veranstaltung. VERANSTALTUNG /// Vermietung von Gerät oder Räumen. VERMIETUNG SONSTIGES } /// Ein Konto der Wehr. model Kassenkonto { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid bezeichnung String @db.VarChar(120) art KontoArt @default(BAR) /// Nur bei Bankkonten. Wird in Auswertungen nie vollständig angezeigt. iban String? @db.VarChar(34) bic String? @db.VarChar(11) /// Der Bestand zu Beginn der Aufzeichnung. Alles Weitere ergibt sich aus /// den Buchungen — er wird nie nachträglich „korrigiert". anfangsbestand Decimal @default(0) @db.Decimal(12, 2) bemerkung String? @db.VarChar(500) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) buchungen Buchung[] posten Abschlussposten[] @@unique([organisationId, bezeichnung]) @@index([organisationId, aktiv]) @@map("kassenkonto") } /// Wofür Geld ausgegeben oder eingenommen wurde. /// /// Zweistufig: eine Oberkategorie („Fahrzeuge") mit Unterkategorien /// („Kraftstoff", „Reparatur"). Mehr Ebenen braucht eine Wehr nicht, und jede /// weitere macht die Auswertung unlesbar. model Buchungskategorie { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid name String @db.VarChar(120) art BuchungsArt /// Die Oberkategorie. Leer heißt: das hier ist eine. oberkategorieId String? @db.Uuid /// Nummer im Kontenrahmen der Gemeinde — für den Export an die Kämmerei. kontonummer String? @db.VarChar(20) bemerkung String? @db.VarChar(500) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) oberkategorie Buchungskategorie? @relation("Kategoriebaum", fields: [oberkategorieId], references: [id], onDelete: SetNull) unterkategorien Buchungskategorie[] @relation("Kategoriebaum") buchungen Buchung[] @@unique([organisationId, name]) @@index([organisationId, art]) @@map("buchungskategorie") } /// Eine Buchung. /// /// Der Betrag ist positiv, die Richtung steht in `art`. Festgeschrieben heißt: /// unveränderbar und nicht löschbar — die Datenbank hält dagegen. Wer sich /// vertan hat, bucht dagegen; die Gegenbuchung verweist auf das Original, und /// beide bleiben stehen. model Buchung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid kontoId String @db.Uuid kategorieId String @db.Uuid /// Das Buchungsdatum, nicht der Zeitpunkt der Erfassung. Danach richtet /// sich, in welchen Abschluss die Buchung fällt. datum DateTime @db.Date art BuchungsArt betrag Decimal @db.Decimal(12, 2) verwendungszweck String @db.VarChar(300) belegnummer String? @db.VarChar(60) /// Wer das Geld bekommen oder gegeben hat. Entweder eine Person der Wehr /// **oder** ein Dritter als Text — nie beides. personId String? @db.Uuid lieferant String? @db.VarChar(200) /// Bei einer Umbuchung zwischen zwei eigenen Konten das Gegenkonto. gegenkontoId String? @db.Uuid belegDateiId String? @db.Uuid /// Festgeschrieben: ab hier unveränderbar. festgeschriebenAm DateTime? @db.Timestamptz(3) /// Die Buchung, die diese hier aufhebt — und umgekehrt. storniertVonId String? @db.Uuid erfasstVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict) kategorie Buchungskategorie @relation(fields: [kategorieId], references: [id], onDelete: Restrict) person Person? @relation("BuchungPerson", fields: [personId], references: [id], onDelete: SetNull) erfasstVon Person? @relation("BuchungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) belegDatei Datei? @relation("BuchungBeleg", fields: [belegDateiId], references: [id], onDelete: SetNull) storniertVon Buchung? @relation("Stornierung", fields: [storniertVonId], references: [id], onDelete: SetNull) storniert Buchung[] @relation("Stornierung") beitraege Beitrag[] rechnungen Rechnung[] belegentwurf Belegentwurf[] @@index([organisationId, datum(sort: Desc)]) @@index([organisationId, kontoId, datum]) @@index([organisationId, kategorieId]) @@map("buchung") } /// Was ein Mitglied im Jahr zahlt. /// /// Je Abteilung ein Satz: Aktive zahlen anderes als Jugend, Ehrenmitglieder /// oft nichts. Der Satz gilt ab einem Jahr und wird nicht rückwirkend /// geändert — sonst stimmen die Beiträge vergangener Jahre nicht mehr. model Beitragssatz { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid bezeichnung String @db.VarChar(120) betrag Decimal @db.Decimal(12, 2) /// Für welche Abteilung. Leer heißt: für alle, die keinen eigenen Satz haben. abteilung String? @db.VarChar(60) /// Ab welchem Jahr er gilt. abJahr Int aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) beitraege Beitrag[] @@unique([organisationId, abteilung, abJahr]) @@index([organisationId, abJahr]) @@map("beitragssatz") } /// Der Beitrag einer Person für ein Jahr. model Beitrag { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid jahr Int satzId String? @db.Uuid betrag Decimal @db.Decimal(12, 2) stand BeitragStand @default(OFFEN) /// Die Buchung, mit der bezahlt wurde. Erst sie macht aus „bezahlt" eine /// Zahl in der Kasse. buchungId String? @db.Uuid bezahltAm DateTime? @db.Date /// Wie oft gemahnt wurde. Null heißt: noch nicht. mahnstufe Int @default(0) gemahntAm DateTime? @db.Date /// Bei Erlass: warum. Ein erlassener Beitrag ohne Begründung ist in der /// Kassenprüfung eine offene Frage. erlassGrund String? @db.VarChar(300) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("BeitragPerson", fields: [personId], references: [id], onDelete: Cascade) satz Beitragssatz? @relation(fields: [satzId], references: [id], onDelete: SetNull) buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull) @@unique([personId, jahr]) @@index([organisationId, jahr, stand]) @@map("beitrag") } /// Eine Rechnung an Dritte. /// /// Fehlalarm, Ölspur, Absicherung einer Veranstaltung. Nummer aus dem /// Nummernkreis (`JJJJ-NNNN`), PDF über Gotenberg. model Rechnung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid nummer String @db.VarChar(20) anlass RechnungsAnlass @default(SONSTIGES) stand RechnungStand @default(ENTWURF) /// An wen. Bewusst als Text und nicht als Verweis: der Empfänger ist in /// aller Regel niemand, den die Wehr dauerhaft führt. empfaenger String @db.VarChar(200) empfaengerZusatz String? @db.VarChar(200) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) /// Worauf sie sich bezieht. einsatzId String? @db.Uuid datum DateTime @db.Date faelligAm DateTime @db.Date /// Summe der Positionen. Wird beim Speichern gerechnet, nicht eingegeben. betrag Decimal @db.Decimal(12, 2) bemerkung String? @db.VarChar(1000) /// Die Buchung des Zahlungseingangs. buchungId String? @db.Uuid bezahltAm DateTime? @db.Date gemahntAm DateTime? @db.Date mahnstufe Int @default(0) pdfDateiId String? @db.Uuid erstelltVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull) pdfDatei Datei? @relation("RechnungPdf", fields: [pdfDateiId], references: [id], onDelete: SetNull) erstelltVon Person? @relation("RechnungErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) positionen Rechnungsposition[] @@unique([organisationId, nummer]) @@index([organisationId, stand]) @@index([organisationId, datum(sort: Desc)]) @@map("rechnung") } /// Eine Zeile auf einer Rechnung. model Rechnungsposition { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid rechnungId String @db.Uuid reihenfolge Int bezeichnung String @db.VarChar(300) /// Menge mit drei Nachkommastellen — Einsatzstunden werden viertelstündlich /// abgerechnet, und 0,25 muss genau sein. menge Decimal @default(1) @db.Decimal(12, 3) einheit String? @db.VarChar(20) einzelpreis Decimal @db.Decimal(12, 2) /// Menge × Einzelpreis, beim Speichern gerechnet. gesamtpreis Decimal @db.Decimal(12, 2) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) rechnung Rechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade) @@unique([rechnungId, reihenfolge]) @@index([organisationId]) @@map("rechnungsposition") } /// Ein Kassenabschluss. /// /// Der Vorgang, für den es die ganze Kasse gibt: zwei Kassenprüfer setzen sich /// hin, zählen das Bargeld, vergleichen die Kontoauszüge und unterschreiben. /// Danach ist der Zeitraum zu — die Buchungsmaske nimmt kein Datum daraus mehr /// an. /// /// Zwei Prüfer, weil es die Satzung fast jeder Wehr so vorsieht. Der zweite /// ist trotzdem optional: eine Wehr, die gerade nur einen hat, soll deshalb /// nicht ohne Abschluss dastehen. model Kassenabschluss { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid vonAm DateTime @db.Date bisAm DateTime @db.Date /// Summe über alle Konten, aus den Buchungen gerechnet. bestandLautBuch Decimal @db.Decimal(12, 2) /// Was tatsächlich gezählt wurde. gezaehlterBestand Decimal @db.Decimal(12, 2) /// gezaehlt minus laut Buch. Positiv heißt: es war mehr da. differenz Decimal @db.Decimal(12, 2) /// Eine Differenz ohne Erklärung ist der Punkt, an dem eine Kassenprüfung /// hängen bleibt. erlaeuterung String? @db.VarChar(2000) prueferId String? @db.Uuid zweiterPrueferId String? @db.Uuid geprueftAm DateTime? @db.Date berichtDateiId String? @db.Uuid /// Festgeschrieben: ab hier ist der Zeitraum zu. festgeschriebenAm DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) pruefer Person? @relation("AbschlussPruefer", fields: [prueferId], references: [id], onDelete: SetNull) zweiterPruefer Person? @relation("AbschlussZweitpruefer", fields: [zweiterPrueferId], references: [id], onDelete: SetNull) berichtDatei Datei? @relation("AbschlussBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) posten Abschlussposten[] @@index([organisationId, bisAm(sort: Desc)]) @@map("kassenabschluss") } /// Was auf einem Konto zum Abschluss stand. /// /// Je Konto eine Zeile: der Kassenprüfer zählt das Bargeld und liest den /// Kontostand vom Auszug ab. Eine einzelne Summe über alles würde verbergen, /// auf welchem Konto die Differenz sitzt. model Abschlussposten { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid abschlussId String @db.Uuid kontoId String @db.Uuid bestandLautBuch Decimal @db.Decimal(12, 2) gezaehlterBestand Decimal @db.Decimal(12, 2) differenz Decimal @db.Decimal(12, 2) bemerkung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) abschluss Kassenabschluss @relation(fields: [abschlussId], references: [id], onDelete: Cascade) konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict) @@unique([abschlussId, kontoId]) @@index([organisationId]) @@map("abschlussposten") }