Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c779266450 | ||
|
|
4cda79434f | ||
|
|
1c1e141666 | ||
|
|
e1480c0c3e | ||
|
|
4e2daaafd4 | ||
|
|
b2b31ed00a | ||
|
|
e500ad6c0e | ||
|
|
791523027f | ||
|
|
d8d692098d | ||
|
|
91954c402b | ||
|
|
bc62a9c1e8 | ||
|
|
e503aafc18 | ||
|
|
7f866ac35c | ||
|
|
1744403620 | ||
|
|
47b8e36a38 | ||
|
|
26b62a3477 |
@@ -185,6 +185,57 @@ KIOSK_SPERRDAUER=300
|
|||||||
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
|
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
|
||||||
KIOSK_IDLE_TIMEOUT=120
|
KIOSK_IDLE_TIMEOUT=120
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Kartenmodule (Phase 6)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# FLORIAN laedt keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre
|
||||||
|
# Hydranten liegen, ist niemandes Geschaeft — der Kartenserver laeuft selbst:
|
||||||
|
#
|
||||||
|
# docker compose --profile karte up -d
|
||||||
|
#
|
||||||
|
# Dafuer wird eine MBTiles-Datei des Ausrueckegebiets gebraucht (siehe README).
|
||||||
|
# Ohne KARTE_STIL_URL zeigt die Karte einen leeren Hintergrund mit Gitternetz;
|
||||||
|
# Hydranten, Objekte und Lagekarte funktionieren trotzdem.
|
||||||
|
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
|
||||||
|
# Kartenmittelpunkt beim Oeffnen (Musterhausen).
|
||||||
|
KARTE_BREITENGRAD=48.5
|
||||||
|
KARTE_LAENGENGRAD=12.15
|
||||||
|
KARTE_ZOOM=13
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Atemschutz nach FwDV 7 (Phase 5)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Der Sicherheitshinweis ueber jeder Ansicht der Atemschutzueberwachung:
|
||||||
|
# "Ergaenzendes System — die analoge Atemschutzueberwachung bleibt fuehrend."
|
||||||
|
# Er gehoert dorthin. Auf false setzen darf ihn nur, wer eine schriftliche
|
||||||
|
# Freigabe des Kommandanten hat — und deren Fundstelle gehoert in
|
||||||
|
# ATEMSCHUTZ_FREIGABE, sonst bleibt der Hinweis stehen.
|
||||||
|
ATEMSCHUTZ_HINWEIS=true
|
||||||
|
# Aktenzeichen oder Fundstelle der Freigabe, z. B. "Kommandantenbeschluss vom 12.03.2026".
|
||||||
|
ATEMSCHUTZ_FREIGABE=
|
||||||
|
# Abstand der Druckabfrage in Sekunden. FwDV 7 nennt keinen festen Wert;
|
||||||
|
# zehn Minuten sind die uebliche Praxis.
|
||||||
|
ATEMSCHUTZ_ABFRAGE_INTERVALL=600
|
||||||
|
# Ansprechdruck der Warneinrichtung in bar. Je nach Baujahr 50 bis 60.
|
||||||
|
ATEMSCHUTZ_WARNDRUCK=55
|
||||||
|
# Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar.
|
||||||
|
ATEMSCHUTZ_ZUSCHLAG=10
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Wetterwarnungen auf dem Hallenmonitor (optional, DWD)
|
||||||
|
# Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut —
|
||||||
|
# deshalb standardmaessig aus. Ohne WETTER_ENABLED=true wird nichts abgerufen.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
WETTER_ENABLED=false
|
||||||
|
# Warnzellen-Nummer der eigenen Gemeinde aus der DWD-Warnzellenliste:
|
||||||
|
# https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv
|
||||||
|
DWD_WARNCELL=
|
||||||
|
DWD_WARNUNGEN_URL=https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json
|
||||||
|
# Zeitlimit fuer den Abruf, in Sekunden. Der Monitor darf davon nicht haengen.
|
||||||
|
WETTER_TIMEOUT=5
|
||||||
|
# Wie lange eine Antwort gilt, bevor neu gefragt wird, in Sekunden.
|
||||||
|
WETTER_ZWISCHENSPEICHER=300
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# E-Mail-Versand (optional, hinter lib/integrations/mail.ts abstrahiert)
|
# E-Mail-Versand (optional, hinter lib/integrations/mail.ts abstrahiert)
|
||||||
# Ohne MAIL_ENABLED=true wird nur ins Log geschrieben — praktisch für Tests.
|
# Ohne MAIL_ENABLED=true wird nur ins Log geschrieben — praktisch für Tests.
|
||||||
@@ -213,6 +264,39 @@ LLM_BASE_URL=http://ollama:11434/v1
|
|||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODELL=qwen2.5:14b
|
LLM_MODELL=qwen2.5:14b
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Einsätze und Übungen (Phase 3)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Adressen zu Koordinaten auflösen. Vorgesehen ist ein SELBST betriebener
|
||||||
|
# Nominatim — Einsatzadressen gehören nicht an einen öffentlichen Dienst, und
|
||||||
|
# dessen Nutzungsbedingungen erlauben es ohnehin nicht.
|
||||||
|
GEOCODING_ENABLED=false
|
||||||
|
# NOMINATIM_URL=http://nominatim:8080
|
||||||
|
GEOCODING_TIMEOUT=5
|
||||||
|
# Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt).
|
||||||
|
GEOCODING_LAENDER=de,at,ch
|
||||||
|
|
||||||
|
# Einsatzportal für Fremdwehren: ein signierter Link ohne Anmeldung, über den
|
||||||
|
# eine Nachbarwehr den Stand sieht und Lagemeldungen absetzen kann.
|
||||||
|
PORTAL_ENABLED=true
|
||||||
|
# Wie lange ein neu erzeugter Link gilt.
|
||||||
|
PORTAL_GUELTIGKEIT_STUNDEN=24
|
||||||
|
# Höchstzahl Zugriffe je Link und Stunde.
|
||||||
|
PORTAL_MAX_ZUGRIFFE=120
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Wartung und Prüfung (Phase 2)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Wann der nächtliche Lauf Prüftermine plant. Cron-Syntax, Zeitzone
|
||||||
|
# Europe/Berlin. Der Worker trägt den Zeitplan beim Start je Wehr selbst ein.
|
||||||
|
WARTUNG_NACHTLAUF=15 3 * * *
|
||||||
|
# Wann die Erinnerungen verschickt werden. Bewusst nach der Planung: sonst
|
||||||
|
# erinnert der Lauf an Termine, die es noch gar nicht gibt.
|
||||||
|
WARTUNG_ERINNERUNG_LAUF=45 3 * * *
|
||||||
|
# Wie weit im Voraus Termine angelegt werden. Ohne Horizont stünden Termine für
|
||||||
|
# die nächsten zehn Jahre im Dashboard.
|
||||||
|
WARTUNG_PLANUNGSHORIZONT_TAGE=180
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Datenschutz und Aufbewahrung (DSGVO)
|
# Datenschutz und Aufbewahrung (DSGVO)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,7 +8,707 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
Noch nichts. Nächster Schritt ist Phase 1 (Stammdatenverwaltung).
|
Nächster Schritt ist Phase 7 (Benachrichtigungen und Termine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-09-04
|
||||||
|
|
||||||
|
Phase 6: Kartenmodule. Ab hier weiß die Wehr auf der Anfahrt, wo der nächste
|
||||||
|
Hydrant liegt und was er hergibt.
|
||||||
|
|
||||||
|
**Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt, wo ihre Hydranten liegen
|
||||||
|
und welche Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver
|
||||||
|
läuft selbst (tileserver-gl im Compose-Profil `karte`); ohne ihn zeigt die
|
||||||
|
Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und
|
||||||
|
Umkreissuche funktionieren trotzdem.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
|
||||||
|
- `Hydrant` mit Typ (Unterflur, Überflur, Saugstelle, Zisterne, offenes
|
||||||
|
Gewässer), Nennweite, Durchfluss, Zustand, letzter Prüfung und Hinweis für
|
||||||
|
das, was man vor Ort wissen muss.
|
||||||
|
- `Objekt` mit Kategorie, **Zufahrt, Gefahren und Schlüsseldepot als eigene
|
||||||
|
Felder** — im Einsatz sucht niemand in einem Absatz. Dazu `Objektkontakt`
|
||||||
|
(mit „jederzeit erreichbar") und `Objektplan` (DIN 14095).
|
||||||
|
- `Lagekarte` und `Lageelement` — eine Karte je Einsatz, jedes Element mit
|
||||||
|
Geometrie, Zeichen und Urheber.
|
||||||
|
- `Wasserfoerderung` und `Foerderpunkt` — die Eingaben einer Förderstrecke.
|
||||||
|
Das Ergebnis wird **nicht** gespeichert, sondern bei jeder Anzeige neu
|
||||||
|
gerechnet.
|
||||||
|
- Standorte haben jetzt Dezimalgrade neben dem PostGIS-Punkt und liegen damit
|
||||||
|
auf der Karte.
|
||||||
|
|
||||||
|
**Karte**
|
||||||
|
|
||||||
|
- MapLibre GL aus dem eigenen Paketstand, Kacheln aus dem eigenen
|
||||||
|
tileserver-gl. Kein Skript, kein Stylesheet, kein Bild von einem fremden
|
||||||
|
Server.
|
||||||
|
- Hydranten und Objekte als getrennt schaltbare Ebenen, Filter nach Art und
|
||||||
|
Zustand, Suche über Straße, Nummer, Objektname und Gefahrenstichwort.
|
||||||
|
- **Umkreissuche über PostGIS**: `ST_DWithin` auf der Geografie mit
|
||||||
|
GiST-Index — in die Karte tippen, und es steht da, was in 500 Metern
|
||||||
|
erreichbar ist, nach Entfernung sortiert.
|
||||||
|
|
||||||
|
**Import**
|
||||||
|
|
||||||
|
- GeoJSON und Tabellen der Stadtwerke. Spaltennamen werden erkannt („DN",
|
||||||
|
„Lage", „Latitude", „Pruefdatum"), deutsche Kommazahlen und Datumsangaben
|
||||||
|
ebenso, vertauschte Achsen werden erkannt und zurückgedreht.
|
||||||
|
- Zwei Schritte wie beim Geräteimport aus Phase 1: erst zeigen, was
|
||||||
|
herauskäme, dann übernehmen. Übersprungene Zeilen stehen mit Zeilennummer
|
||||||
|
und Grund da.
|
||||||
|
- Wiederholbar: Hydranten mit einer Nummer des Versorgers werden aktualisiert
|
||||||
|
statt doppelt angelegt.
|
||||||
|
|
||||||
|
**Objekte und Feuerwehrpläne**
|
||||||
|
|
||||||
|
- Zufahrt, Gefahren, Schlüsseldepot und Löschwasser am Objekt stehen ganz
|
||||||
|
oben — in dieser Reihenfolge werden sie auf der Anfahrt gelesen.
|
||||||
|
- Ansprechpartner mit Telefonnummern zum Antippen und der Angabe, wer nachts
|
||||||
|
ans Telefon geht.
|
||||||
|
- Planblätter nach DIN 14095 mit Art und Stand. Ein Plan von 2009 ist eine
|
||||||
|
Vermutung, und das steht dran.
|
||||||
|
|
||||||
|
**Lagekarte**
|
||||||
|
|
||||||
|
- 22 taktische Zeichen nach FwDV 102, **selbst gezeichnet** als SVG in
|
||||||
|
`lib/karte/taktische-zeichen.ts`: Fahrzeuge, Einheiten, Führung, Stellen,
|
||||||
|
Gefahren, Maßnahmen.
|
||||||
|
- Zeichen setzen, Linien ziehen, Flächen aufziehen, beschriften — ein
|
||||||
|
Werkzeug, ein Klick.
|
||||||
|
- Abgleich zwischen Geräten über denselben Ereignisstrom wie die
|
||||||
|
Atemschutzüberwachung aus Phase 5; der Zustand der Verbindung steht sichtbar
|
||||||
|
da.
|
||||||
|
- Zurücknehmen statt löschen: was um 14:12 eingezeichnet wurde, gehört zur
|
||||||
|
Mitschrift.
|
||||||
|
|
||||||
|
**Wasserförderung**
|
||||||
|
|
||||||
|
- Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen — die
|
||||||
|
Rechnung läuft mit: Reibungsverlust, Höhenunterschied, Zahl der
|
||||||
|
Verstärkerpumpen, Schlauchlängen je Abschnitt.
|
||||||
|
- Gerechnet wird im Browser **und** auf dem Server mit demselben Code.
|
||||||
|
- Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
|
||||||
|
statt eine Zahl auszuwerfen, die niemand halten kann.
|
||||||
|
- Unter jeder Rechnung steht, dass sie eine Planungshilfe ist — was zählt, ist
|
||||||
|
der Druck am Verteiler.
|
||||||
|
|
||||||
|
**Dateien abrufen**
|
||||||
|
|
||||||
|
- `/api/dateien/[id]` liefert eine abgelegte Datei aus, mandantengebunden und
|
||||||
|
mit Eintrag im Protokoll. Bis Phase 6 gab es nur den Upload und eigene
|
||||||
|
Endpunkte je Dokumentart.
|
||||||
|
|
||||||
|
**Sonstiges**
|
||||||
|
|
||||||
|
- Der Seed legt 24 Löschwasserstellen an — mit Weiher, Zisterne und
|
||||||
|
Saugstelle im Außenbereich, zwei defekten und zwei ungeprüften — sowie drei
|
||||||
|
Objekte mit Ansprechpartnern: Schule, Pflegeheim, Aussiedlerhof.
|
||||||
|
- 59 neue Tests: 48 Unit-Tests zu Druckverlust, Förderplan und Import,
|
||||||
|
11 Playwright-Durchläufe je Browser.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Der Koordinaten-Trigger aus Phase 3 ist jetzt allgemein: eine Funktion für
|
||||||
|
jede Tabelle mit Breiten- und Längengrad. Sie hängt an Standort, Hydrant und
|
||||||
|
Objekt.
|
||||||
|
- Der Ereignisstrom-Hook aus Phase 5 nimmt jetzt eine Adresse entgegen und
|
||||||
|
wird von Lagekarte und Atemschutzüberwachung gemeinsam benutzt. Zwei Ströme
|
||||||
|
mit unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||||||
|
verstecken kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-09-03
|
||||||
|
|
||||||
|
Phase 5: Atemschutzüberwachung nach FwDV 7. Das sicherheitsrelevanteste Modul
|
||||||
|
des Projekts — und deshalb das mit dem höchsten Testanteil.
|
||||||
|
|
||||||
|
Über jeder Ansicht steht: **Ergänzendes System — die analoge
|
||||||
|
Atemschutzüberwachung bleibt führend.** Der Hinweis lässt sich nur abschalten,
|
||||||
|
wenn zusätzlich die Fundstelle einer Freigabe des Kommandanten hinterlegt ist;
|
||||||
|
ohne sie bleibt er stehen.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
|
||||||
|
- `Atemschutzeinsatz` — eine Überwachung mit Überwacher, Abfrageintervall,
|
||||||
|
Warndruck und Sicherheitszuschlag. Hängt an einem Einsatz oder einer Übung,
|
||||||
|
kann aber auch für sich stehen: im Ernstfall wird zuerst überwacht und
|
||||||
|
später zugeordnet.
|
||||||
|
- `Atemschutztrupp` mit Status (bereit, im Einsatz, Rückweg, draußen), den
|
||||||
|
Zeitstempeln dazu, dem errechneten Rückzugsdruck und dem Notfallzeitpunkt.
|
||||||
|
- `Truppmitglied` — zwei oder drei Personen je Trupp, genau eine davon
|
||||||
|
Truppführer, optional mit dem verwendeten Pressluftatmer.
|
||||||
|
- `Druckmessung` mit Art (Eingang, Einsatzziel, Zwischenmeldung, Ausgang).
|
||||||
|
- `AtemschutzEreignis` — jede Zeile des Protokolls mit Zeitpunkt.
|
||||||
|
- `G26Untersuchung` und `Belastungsuebung` als eigene Nachweise.
|
||||||
|
|
||||||
|
**Die Zeit gehört dem Server**
|
||||||
|
|
||||||
|
- Nirgends wird eine Restzeit heruntergezählt. Jede Dauer entsteht aus zwei
|
||||||
|
Zeitstempeln — einem aus der Datenbank und der aktuellen Uhr. Deshalb
|
||||||
|
übersteht die Anzeige ein Neuladen, einen zweiten Bildschirm und einen
|
||||||
|
Verbindungsabbruch: es gibt nichts, das dabei verloren gehen könnte.
|
||||||
|
- Server und Browser rechnen mit **denselben** Funktionen aus
|
||||||
|
`lib/atemschutz/berechnung.ts`. Was das Tablet anzeigt, weist das Protokoll
|
||||||
|
später genauso aus.
|
||||||
|
|
||||||
|
**Rückzugsdruck nach dem doppelten Anmarschverbrauch**
|
||||||
|
|
||||||
|
- Der Trupp meldet beim Erreichen des Einsatzziels einmal seinen Druck. Daraus:
|
||||||
|
`2 × Anmarschverbrauch + Ansprechdruck der Warneinrichtung + Zuschlag`.
|
||||||
|
- Solange keine Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt
|
||||||
|
höher und ist damit die vorsichtigere Annahme.
|
||||||
|
- Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht
|
||||||
|
gemeinsam zurück.
|
||||||
|
- Ampel in drei Stufen mit Klartext daneben, Gelb schon zehn Prozent über der
|
||||||
|
Grenze. Bei Notfall zusätzlich ein erzeugter Ton — ausdrücklich eine Zugabe.
|
||||||
|
|
||||||
|
**Überwachungsschirm**
|
||||||
|
|
||||||
|
- Trupp anlegen mit Eingangsdruck je Person; daneben steht, ob die Person nach
|
||||||
|
FwDV 7 überhaupt einsetzbar ist. **Gesperrt wird nichts** — im Einsatz
|
||||||
|
entscheidet der Einheitsführer, und ein Programm, das ihm im entscheidenden
|
||||||
|
Moment eine Person verweigert, wird umgangen.
|
||||||
|
- Druckabfrage für den ganzen Trupp in einer Zeile, Erinnerung im
|
||||||
|
konfigurierbaren Intervall (Vorgabe zehn Minuten).
|
||||||
|
- Mayday je Trupp, ausdrücklich aufzuheben statt wegzuklicken.
|
||||||
|
- Rückzug anordnen, Statuswechsel, Notizen ins Protokoll.
|
||||||
|
- Die Überwachung lässt sich erst beenden, wenn alle Trupps als draußen
|
||||||
|
gemeldet sind.
|
||||||
|
|
||||||
|
**Ereignisstrom über Server-Sent Events**
|
||||||
|
|
||||||
|
- Ein Route Handler unter `/api/atemschutz/strom` meldet Änderungen; der
|
||||||
|
Browser holt daraufhin den Stand. Kein eigener Server nötig, `EventSource`
|
||||||
|
verbindet nach einem Abbruch von selbst neu.
|
||||||
|
- Der Zustand der Verbindung steht sichtbar auf dem Schirm. Bleibt das
|
||||||
|
Lebenszeichen des Servers aus, gilt die Verbindung nach acht Sekunden als
|
||||||
|
tot — auch wenn der Browser keinen Fehler meldet.
|
||||||
|
- Fällt der Strom ganz aus, bleibt die Ansicht bedienbar: sie fragt zusätzlich
|
||||||
|
im festen Takt nach.
|
||||||
|
|
||||||
|
**Protokoll**
|
||||||
|
|
||||||
|
- Lückenloses PDF mit Rahmen, Trupps und dem Verlauf aus Ereignissen und
|
||||||
|
Druckmessungen in einer gemeinsamen Zeitachse.
|
||||||
|
- Entsteht beim Beenden im Hintergrundprozess und bei Bedarf beim Abruf — ein
|
||||||
|
Nachweis, den man nicht bekommt, weil ein Dienst einen schlechten Tag hatte,
|
||||||
|
wäre keiner.
|
||||||
|
- Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
|
||||||
|
später liest, soll wissen, was er in der Hand hält.
|
||||||
|
|
||||||
|
**Tauglichkeit nach FwDV 7**
|
||||||
|
|
||||||
|
- Drei Nachweise, die gleichzeitig gelten müssen: gültige G26.3,
|
||||||
|
Belastungsübung im laufenden Jahr, Einsatzübung im laufenden Jahr. Ein
|
||||||
|
Einsatz unter Atemschutz ersetzt die Einsatzübung.
|
||||||
|
- Ampel mit **Klartext**, was fehlt — eine rote Ampel ohne Begründung hilft am
|
||||||
|
Fahrzeug niemandem.
|
||||||
|
- Fristvorschlag für die G26 nach Alter: drei Jahre, ab fünfzig eines.
|
||||||
|
- Nachweise pflegen: G26 je Person, Belastungsübung für die ganze Gruppe auf
|
||||||
|
einmal.
|
||||||
|
- Kachel auf der Startseite: wie viele Träger heute einsetzbar sind und wie
|
||||||
|
viele davon knapp davor stehen, es nicht mehr zu sein.
|
||||||
|
|
||||||
|
**Sonstiges**
|
||||||
|
|
||||||
|
- Der Seed legt acht G26-Untersuchungen an — darunter eine abgelaufene und
|
||||||
|
eine bald ablaufende —, zwölf Übungsnachweise und eine vollständig
|
||||||
|
durchgespielte Überwachung mit zwei Trupps.
|
||||||
|
- 68 neue Tests: 51 Unit-Tests zu Rückzugsdruck, Ampel, Restzeit, Statusfolge
|
||||||
|
und Tauglichkeit, 17 gegen eine echte Datenbank und 8 Playwright-Durchläufe
|
||||||
|
je Browser — darunter die beiden, um die es in dieser Phase geht: die Uhr
|
||||||
|
nach einem Neuladen und bei abgerissenem Ereignisstrom.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` auf
|
||||||
|
`druckmessung` und `atemschutz_ereignis`; zusätzlich sitzt auf beiden ein
|
||||||
|
Trigger. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht
|
||||||
|
durch Ändern — sonst wäre jede Aufzeichnung nachträglich glättbar.
|
||||||
|
- Neue Bedingungen: Druck zwischen 0 und 400 bar, kein Rückzug vor dem
|
||||||
|
Einstieg, kein Ende vor dem Beginn, genau ein Truppführer je Trupp.
|
||||||
|
- Die Startseite stand noch auf dem Stand von Phase 1 („Als Nächstes kommt das
|
||||||
|
Prüfmanagement"). Sie nennt jetzt den tatsächlichen Ausbaustand.
|
||||||
|
- Der Navigationseintrag „Protokoll" führte auf eine Seite, die es nicht gibt.
|
||||||
|
Die Ansicht des Audit-Logs gehört zum Löschkonzept in Phase 8; bis dahin
|
||||||
|
steht der Eintrag ausgegraut da statt ins Leere zu führen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-09-03
|
||||||
|
|
||||||
|
Phase 4: das Terminal im Gerätehaus. Ein Wandtablet, das mit Handschuhen
|
||||||
|
bedienbar ist und weiterarbeitet, wenn das Netz weg ist.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
|
||||||
|
- `Fahrt` — Fahrtenbuch je Fahrzeug: Fahrer, Zweck, Ziel, Kilometerstand bei
|
||||||
|
Abfahrt und Rückkehr, getankte Menge. Eine Fahrt ohne Rückkehr ist die
|
||||||
|
laufende Fahrt.
|
||||||
|
- `Anwesenheit` — wer im Gerätehaus ist, mit Anlass (Dienst, Übung, Einsatz,
|
||||||
|
Arbeitsdienst) und offener Abmeldung, solange jemand da ist.
|
||||||
|
- `Waescheabgabe` — Schutzkleidung in der Reinigung, mit Grund und Stand von
|
||||||
|
„abgegeben" bis „zurück".
|
||||||
|
- `Inventurgang` und `Inventurposten` — der Gerätewart geht mit dem Tablet durch
|
||||||
|
die Halle und scannt; am Ende steht die Liste dessen, was fehlt.
|
||||||
|
- `KioskVorgang` — jeder vom Terminal übermittelte Vorgang, **mit der im
|
||||||
|
Browser erzeugten Kennung als Primärschlüssel**. Das ist der Kern des
|
||||||
|
Offline-Betriebs: kommt derselbe Vorgang nach einem abgebrochenen
|
||||||
|
Sendeversuch ein zweites Mal an, wird er wiedererkannt statt neu angelegt.
|
||||||
|
- `SyncKonflikt` — was sich beim Nachtragen nicht anwenden ließ. Nichts geht
|
||||||
|
verloren, es wartet auf eine Entscheidung.
|
||||||
|
|
||||||
|
**Terminaloberfläche unter `/kiosk`**
|
||||||
|
|
||||||
|
- Kachelschirm: sieben Kacheln, jede mindestens 160 Pixel hoch, jede mit Symbol
|
||||||
|
und Text, sichtbar nach Rechten. Keine Navigationsleiste.
|
||||||
|
- Anmeldung auf drei Wegen: Ausweis auflegen (der RFID-Leser tippt die Kennung
|
||||||
|
als Tastatureingabe, erkannt am Anschlagstempo), QR-Code vom Handy oder
|
||||||
|
Mitgliedsnummer samt PIN.
|
||||||
|
- Mangel melden in drei Schritten — Gerät scannen, Foto und ein Satz dazu,
|
||||||
|
Dringlichkeit antippen. Anspruch war „unter dreißig Sekunden".
|
||||||
|
- Kommen und Gehen mit zwei Tipps, Fahrtenbuch, Wäscheabgabe und Inventurgang.
|
||||||
|
- Abmeldeuhr: nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit meldet sich das
|
||||||
|
Terminal von selbst ab, mit sichtbarer Warnung vorher.
|
||||||
|
- Hallenmonitor unter `/kiosk/monitor`: Uhrzeit, laufender Einsatz, wer im Haus
|
||||||
|
ist, Fahrzeuge, fällige Prüfungen und die nächsten Termine — Vollbild ohne
|
||||||
|
Bedienung.
|
||||||
|
|
||||||
|
**Offline-Betrieb**
|
||||||
|
|
||||||
|
- Ein von Hand geschriebener Service Worker (`public/sw.js`): App-Shell und
|
||||||
|
Stammdaten im Zwischenspeicher, `network-first` für Seiten mit
|
||||||
|
Ausweichseite `/kiosk/offline`, `stale-while-revalidate` für lesende
|
||||||
|
tRPC-Aufrufe. Schreibende Anfragen und alles unter `/api/auth` fasst er nicht
|
||||||
|
an — eine zwischengespeicherte Anmeldung wäre eine Sicherheitslücke.
|
||||||
|
- Warteschlange in einer IndexedDB (Dexie): **jede** schreibende Aktion geht
|
||||||
|
zuerst dorthin, auch online. Sonst gäbe es zwei Wege durch den Code, und der
|
||||||
|
seltenere wäre der ungetestete.
|
||||||
|
- Fotos liegen als Blob in derselben Warteschlange und werden beim Übertragen
|
||||||
|
zuerst hochgeladen.
|
||||||
|
- Sichtbarer Zustand als Leiste über allem: verbunden, wie viele Änderungen
|
||||||
|
warten, kein Netz — in Klartext, nicht nur als Farbe.
|
||||||
|
- Erledigt heißt: angenommen **oder** als bereits verarbeitet erkannt **oder**
|
||||||
|
in der Konfliktliste gelandet. In allen drei Fällen ist der Vorgang beim
|
||||||
|
Server und darf aus der Warteschlange verschwinden.
|
||||||
|
|
||||||
|
**Anmeldemarke**
|
||||||
|
|
||||||
|
- `/anmeldemarke` zeigt einen QR-Code fürs Handy: signiert mit `AUTH_SECRET`,
|
||||||
|
gültig 60 Sekunden, nicht gespeichert. Ein abfotografierter Code aus der
|
||||||
|
Umkleide nützt nach einer Minute nichts mehr.
|
||||||
|
|
||||||
|
**Terminalübersicht in der Verwaltung**
|
||||||
|
|
||||||
|
- `/terminal` zeigt Konflikte zuerst — das sind die Vorgänge, die ein Mensch
|
||||||
|
anfassen muss —, danach Anwesende, Wäsche und Fahrtenbuch.
|
||||||
|
|
||||||
|
**Installierbar als App**
|
||||||
|
|
||||||
|
- Web-App-Manifest, eigene Symbole (`npm run icons`), Startseite `/kiosk`. Kein
|
||||||
|
externes CDN, keine fremde Schriftart.
|
||||||
|
|
||||||
|
**Wetterwarnungen (optional, aus)**
|
||||||
|
|
||||||
|
- Warnungen des DWD auf dem Hallenmonitor, hinter einer Schnittstelle in
|
||||||
|
`lib/integrations/`. Standardmäßig **aus** — es ist die einzige Verbindung,
|
||||||
|
die FLORIAN von sich aus nach außen aufbaut. Fällt sie aus, bleibt die Kachel
|
||||||
|
leer.
|
||||||
|
|
||||||
|
**Sonstiges**
|
||||||
|
|
||||||
|
- Der Seed legt ein Fahrtenbuch mit vier Fahrten (eine läuft noch), vier
|
||||||
|
Anwesenheiten (eine Person ist noch im Haus) und drei Wäscheabgaben an.
|
||||||
|
- 31 neue Tests: 20 Unit-Tests zu Anmeldemarke und Warteschlange (letztere
|
||||||
|
unter jsdom mit `fake-indexeddb`), 11 gegen eine echte Datenbank
|
||||||
|
(Kilometerstände, Anwesenheitszeiten, Unveränderbarkeit der Vorgänge,
|
||||||
|
Mandantentrennung) und 9 Playwright-Durchläufe je Browser — darunter der
|
||||||
|
Offline-Fall mit `context.setOffline(true)` und der doppelt gesendete
|
||||||
|
Vorgang, dessen Antwort unterwegs verloren geht.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Die Datenbank setzt vier neue Regeln selbst durch: Kilometerstände laufen
|
||||||
|
vorwärts, eine Rückkehr liegt nicht vor der Abfahrt, ein Gehen nicht vor dem
|
||||||
|
Kommen, eine Wäscheabgabe umfasst mindestens ein Stück. Dazu `REVOKE UPDATE,
|
||||||
|
DELETE` auf `kiosk_vorgang` — ein übermittelter Vorgang lässt sich nicht
|
||||||
|
nachträglich glätten.
|
||||||
|
- Die Beschriftung der DWD-Warnstufen liegt in
|
||||||
|
`src/lib/integrations/wetter-texte.ts`, getrennt vom `server-only`-Modul
|
||||||
|
daneben — dieselbe Trennung wie bei `qr.ts` und `qr-parser.ts`.
|
||||||
|
- Neue Rechte `fahrtenbuch.lesen` und `fahrtenbuch.fuehren`, beide im
|
||||||
|
Grundrechtebündel.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Die Warteschlange rief sich selbst in eine Renderschleife: `jetztSenden`
|
||||||
|
hatte das Mutationsobjekt von React Query in seinen Abhängigkeiten, und das
|
||||||
|
ist bei jedem Rendern ein neues. Damit wurde der 20-Sekunden-Zeitgeber
|
||||||
|
ständig neu aufgezogen und lief nie ab — ein liegen gebliebener Vorgang wäre
|
||||||
|
ohne Zutun nie ein zweites Mal losgeschickt worden. Gefunden vom
|
||||||
|
Offline-E2E-Test, der genau darauf wartet.
|
||||||
|
- `/kiosk/einrichten` ließ sich nicht vorab erzeugen, weil `useSearchParams()`
|
||||||
|
ohne Suspense-Grenze stand. Der Produktionsbuild brach dabei ab.
|
||||||
|
- Der Anwesenheits- und der Fahrten-Wizard zeigten erst die eine, nach dem
|
||||||
|
Laden des eigenen Stands die andere Fassung — wer zügig tippte, drückte ins
|
||||||
|
Leere. Bis der Stand bekannt ist, steht dort jetzt ein Platzhalter.
|
||||||
|
- Die Offline-Seite blieb stehen, wenn nur eine einzelne Anfrage abgebrochen
|
||||||
|
war. Am Hallenmonitor, den niemand bedient, hieße das: bis jemand vorbeikommt.
|
||||||
|
Sie versucht es jetzt alle zehn Sekunden von selbst.
|
||||||
|
- Der Hallenmonitor stapelte alle laufenden Einsätze übereinander und schob
|
||||||
|
damit Anwesenheit, Fahrzeuge und Prüfungen aus dem Bild. Zwei stehen groß,
|
||||||
|
der Rest als Zeile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.0] — 2026-09-03
|
||||||
|
|
||||||
|
Phase 3: Einsätze und Übungen. Ab hier dokumentiert die Wehr, was sie tut —
|
||||||
|
für die Gemeinde, für die Versicherung und für die eigene Mannschaft.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
|
||||||
|
- `Einsatz` mit den sechs Zeitstempeln (alarmiert, ausgerückt, eingetroffen,
|
||||||
|
Einsatzende, eingerückt, einsatzbereit), Ort samt Koordinaten, Kategorie nach
|
||||||
|
der bayerischen Feuerwehrstatistik und den Zahlen für die Gemeindemeldung.
|
||||||
|
- `Einsatzfahrzeug` mit eigenen Zeiten je Fahrzeug — Nachalarmierung bleibt
|
||||||
|
sichtbar —, Kilometerstand und Wasserabgabe.
|
||||||
|
- `Einsatzkraft` mit Funktion nach FwDV 3, Fahrzeug, Zeitraum und
|
||||||
|
Atemschutz-Kennzeichen.
|
||||||
|
- `Materialverbrauch` und `Lagemeldung` mit Foto und Herkunft.
|
||||||
|
- `PortalZugang` — ein zeitlich begrenzter Link für eine Fremdwehr, gespeichert
|
||||||
|
nur als Hash.
|
||||||
|
- `Uebung` und `Uebungsteilnahme` mit Thema, Lernzielen und Anwesenheit.
|
||||||
|
|
||||||
|
**Schnellerfassung**
|
||||||
|
|
||||||
|
- Stichwort, Ort, ausgerückte Fahrzeuge — mehr fragt die Erfassung im Alarmfall
|
||||||
|
nicht. Alarmzeit ist der Moment des Anlegens, alles Weitere wird nachgetragen.
|
||||||
|
- Fortlaufende Einsatznummer `JJJJ-NNNN` je Wehr und Jahr, vergeben unter einer
|
||||||
|
Transaktionssperre.
|
||||||
|
|
||||||
|
**Zeiten und Stärke**
|
||||||
|
|
||||||
|
- Ein Knopf je Zeitstempel, solange der Einsatz läuft.
|
||||||
|
- Daraus gerechnet: Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
|
||||||
|
- Stärke nach FwDV 3 in der Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`,
|
||||||
|
mit Zuordnung zur taktischen Einheit (Trupp, Staffel, Gruppe, Zug).
|
||||||
|
- Hinweise statt Sperren: eine verdrehte Zeitreihenfolge oder eine fehlende
|
||||||
|
Führungskraft wird angezeigt, verhindert das Speichern aber nicht — ein
|
||||||
|
Einsatz, der drei Wochen später nachgetragen wird, ist immer noch besser als
|
||||||
|
einer, den niemand erfasst.
|
||||||
|
|
||||||
|
**Personalvorschlag**
|
||||||
|
|
||||||
|
- Sortiert nach Einsatzerfahrung der letzten zwölf Monate, mit dem
|
||||||
|
Qualifikationsstand daneben: eine abgelaufene G26.3 fällt auf, bevor jemand
|
||||||
|
unter Atemschutz geht.
|
||||||
|
|
||||||
|
**Einsatzbericht und Freigabe**
|
||||||
|
|
||||||
|
- Bericht als PDF über Gotenberg: Kopf, Zeitleiste mit Kennzahlen, Fahrzeuge,
|
||||||
|
Mannschaft mit Stärke, Material, Verlauf und Bericht.
|
||||||
|
- Mit der Freigabe wird der Einsatz festgeschrieben — durchgesetzt von einem
|
||||||
|
Datenbanktrigger, nicht vom Anwendungscode. Ausgenommen bleiben das
|
||||||
|
nachgetragene PDF und die Lagemeldungen.
|
||||||
|
- Jahresexport als quer gedrucktes PDF unter `/api/einsaetze/jahresexport`.
|
||||||
|
|
||||||
|
**Statistik**
|
||||||
|
|
||||||
|
- Einsätze nach Kategorie und Monat, Verteilung der Hilfsfristen mit dem
|
||||||
|
80-Prozent-Wert, Beteiligung je Person (Einsätze, davon unter Atemschutz,
|
||||||
|
Übungen) und Auslastung je Fahrzeug.
|
||||||
|
|
||||||
|
**Einsatzportal für Fremdwehren**
|
||||||
|
|
||||||
|
- Signierter Link ohne Anmeldung, gültig 24 Stunden, jederzeit widerrufbar,
|
||||||
|
jeder Aufruf wird mitgezählt.
|
||||||
|
- Zeigt Stichwort, Ort, Zeitstempel und Lagemeldungen — keine Namen von
|
||||||
|
Einsatzkräften, keinen Bericht, keinen Weg in den Rest der Anwendung.
|
||||||
|
- Nimmt Lagemeldungen mit Foto an, begrenzt auf 30 Meldungen und 10 Fotos je
|
||||||
|
Zugang und Stunde.
|
||||||
|
- Eigene Zugriffsbegrenzung über Redis (`src/lib/ratelimit.ts`), die bei
|
||||||
|
Störung durchlässt statt zu blockieren.
|
||||||
|
|
||||||
|
**Übungen**
|
||||||
|
|
||||||
|
- Thema, Art, Lernziele, Ort und Leitung; Anwesenheit als Liste zum Abhaken.
|
||||||
|
- Fließt in die Beteiligungsstatistik ein.
|
||||||
|
|
||||||
|
**Adressen zu Koordinaten**
|
||||||
|
|
||||||
|
- Anbindung an einen **selbst betriebenen** Nominatim hinter einer Schnittstelle
|
||||||
|
in `lib/integrations/`. Ohne Konfiguration bleibt die Suche leer und die
|
||||||
|
Koordinaten werden von Hand eingetragen.
|
||||||
|
- Koordinaten liegen doppelt: als Dezimalgrad für die Anwendung und als
|
||||||
|
PostGIS-Punkt für die Karte ab Phase 6, zusammengehalten von einem Trigger.
|
||||||
|
|
||||||
|
**Sonstiges**
|
||||||
|
|
||||||
|
- Der Seed legt acht Einsätze über das Jahr verteilt an — Brände, technische
|
||||||
|
Hilfeleistungen, ein Fehlalarm einer Brandmeldeanlage — dazu sechs Übungen
|
||||||
|
und die Beteiligung der Mannschaft.
|
||||||
|
- 57 neue Tests: 32 Unit-Tests zu Stärke, Zeiten und Nummernkreis, 14 gegen eine
|
||||||
|
echte Datenbank (Koordinaten-Trigger, Freigabe, Mandantentrennung) und 11
|
||||||
|
Playwright-Durchläufe einschließlich des Portals in einem Browser **ohne**
|
||||||
|
Sitzung.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Der Nummernkreis `JJJJ-NNNN` liegt jetzt in `src/lib/nummernkreis.ts` und wird
|
||||||
|
von Prüfprotokollen, Einsätzen und Übungen gemeinsam verwendet.
|
||||||
|
- Die Seitenleiste zeigt Einsätze und Übungen als verfügbar.
|
||||||
|
- Das Zeitlimit der E2E-Erwartungen steigt von 15 auf 25 Sekunden: mit fünfzig
|
||||||
|
Routen dauert die erste Übersetzung einer Seite im Entwicklungsserver unter
|
||||||
|
Last länger als vorher, und daran scheiterten gelegentlich Tests, die einzeln
|
||||||
|
grün sind.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Die Anwesenheitsliste einer Übung forderte 300 Mitglieder an und lief damit
|
||||||
|
in die Höchstzahl von 200 je Seite — die Liste blieb leer. Gefunden über die
|
||||||
|
Server-Ausgabe während der E2E-Tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-09-03
|
||||||
|
|
||||||
|
Phase 2: Wartungs- und Prüfmanagement. Das Modul, wegen dem eine Wehr so ein
|
||||||
|
Programm einführt — Fristen im Blick behalten und im Streitfall nachweisen
|
||||||
|
können, dass geprüft wurde.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
|
||||||
|
- `Wartungsvorlage` — was an einem Gerätetyp in welchem Takt geprüft wird, mit
|
||||||
|
Intervall, Vorwarnzeit, Prüfvorschrift und Erfahrungswert für die Dauer.
|
||||||
|
- `Checklistenpunkt` in vier Ausprägungen: Ja/Nein, Messwert (mit zulässigem
|
||||||
|
Bereich und Einheit), Text und Foto. Pflichtpunkte sind die, an denen die
|
||||||
|
Einsatzbereitschaft hängt.
|
||||||
|
- `Wartungstermin` — ein fälliger Termin an einem konkreten Gerät. Eindeutig
|
||||||
|
über Gerät, Vorlage und Fälligkeitstag, damit ein zweiter nächtlicher Lauf
|
||||||
|
nichts doppelt anlegt.
|
||||||
|
- `Pruefung`, `Pruefergebnis` und `Signatur` — die durchgeführte Prüfung mit
|
||||||
|
Antworten je Punkt und Unterschrift.
|
||||||
|
- `Mangel` mit dem Weg melden → zugewiesen → in Arbeit → behoben.
|
||||||
|
- `Rueckruf`, `RueckrufGeraetetyp` und `RueckrufBetroffenheit` — eine
|
||||||
|
Herstellerwarnung, die betroffene Bauart und der Stand je Gerät.
|
||||||
|
- `Erinnerung` als Nachweis und gleichzeitig als Sperre gegen Doppelversand.
|
||||||
|
|
||||||
|
**Unveränderbarkeit der Protokolle**
|
||||||
|
|
||||||
|
- Ein Trigger in der Datenbank weist jede Änderung an einer abgeschlossenen
|
||||||
|
Prüfung ab. Zulässig bleiben genau drei Dinge: der Wechsel auf „ersetzt", das
|
||||||
|
Nachtragen des PDF, solange keines hinterlegt ist, und `geaendertAm`.
|
||||||
|
- Dieselbe Sperre gilt für die Bestandteile: an einem abgeschlossenen Protokoll
|
||||||
|
lassen sich weder Einzelergebnisse ändern noch Unterschriften nachtragen.
|
||||||
|
- Korrekturen erzeugen eine **neue** Prüfung mit Verweis auf die alte. Beide
|
||||||
|
bleiben lesbar.
|
||||||
|
- Gelöscht wird ein Protokoll nur im gekennzeichneten Aufbewahrungslauf —
|
||||||
|
derselbe Schalter wie beim Audit-Log.
|
||||||
|
|
||||||
|
**Fristen-Dashboard** (`/pruefungen`)
|
||||||
|
|
||||||
|
- Vier Töpfe nach Dringlichkeit: überfällig, diese Woche, diesen Monat, nächste
|
||||||
|
90 Tage. Jede Zahl ist gleichzeitig ein Filter.
|
||||||
|
- Bündelung nach Fahrzeug, Standort, Gerätetyp oder Zuständigem — wer einen
|
||||||
|
Prüftag ansetzt, arbeitet ein Fahrzeug ab, keine alphabetische Liste.
|
||||||
|
- Sammelzuweisung mehrerer Termine an eine Person.
|
||||||
|
- Daneben die Lebensdauerüberwachung (`/pruefungen/lebensdauer`) mit Vorschlag
|
||||||
|
zur Aussonderung.
|
||||||
|
|
||||||
|
**Prüfwizard** (`/pruefungen/neu`)
|
||||||
|
|
||||||
|
- Ein Prüfpunkt je Bildschirm, große Schaltflächen, Foto direkt aus der Kamera.
|
||||||
|
- Zeiterfassung läuft mit und landet im Protokoll.
|
||||||
|
- Unterschrift auf einem Zeichenfeld, das Finger, Stift und Maus versteht; das
|
||||||
|
PNG landet in MinIO.
|
||||||
|
- Jede Antwort geht sofort zum Server — bricht das WLAN weg, ist höchstens die
|
||||||
|
letzte verloren, und der Entwurf wird beim nächsten Öffnen fortgesetzt.
|
||||||
|
- Mängel werden aus den beanstandeten Punkten vorgeschlagen.
|
||||||
|
- Externe Wartung: Fachfirma, Kosten und das Fremdprotokoll als PDF.
|
||||||
|
|
||||||
|
**Prüfprotokoll als PDF**
|
||||||
|
|
||||||
|
- Erzeugt der Hintergrundprozess über Gotenberg und legt es in MinIO ab: Kopf
|
||||||
|
mit Wehr, Gerät und Prüfer-Kennung, Checkliste mit Soll, Ist und Befund,
|
||||||
|
eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift, Prüfdatum
|
||||||
|
und Seitenzahl.
|
||||||
|
- Laufende Protokollnummer `JJJJ-NNNN` je Wehr und Jahr.
|
||||||
|
|
||||||
|
**Nächtlicher Lauf**
|
||||||
|
|
||||||
|
- `fristen.pruefen` (3:15 Uhr) plant Termine aus den Vorlagen und zieht Frist
|
||||||
|
und Status an jedem Gerät nach.
|
||||||
|
- `erinnerung.senden` (3:45 Uhr) verschickt Vorwarnungen bei T-30, T-7, T-0 und
|
||||||
|
einmal bei Überfälligkeit — als Sammelnachricht je Empfänger.
|
||||||
|
- Beide Läufe sind wiederholbar und tragen sich beim Start des Workers je Wehr
|
||||||
|
selbst ein.
|
||||||
|
|
||||||
|
**Mängel und Rückrufe**
|
||||||
|
|
||||||
|
- Melden darf jedes Mitglied; ein kritischer Mangel sperrt das Gerät sofort.
|
||||||
|
- Beim Beheben wird die Sperre aufgehoben — aber nur, wenn kein anderer offener
|
||||||
|
Mangel dasselbe Gerät sperrt.
|
||||||
|
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||||
|
und Herstellungszeitraum. Das Ergebnis ist ein Vorschlag, keine automatische
|
||||||
|
Sperre.
|
||||||
|
|
||||||
|
**Sonstiges**
|
||||||
|
|
||||||
|
- Datei-Upload unter `/api/dateien` für Fotos, Unterschriften und
|
||||||
|
Fremdprotokolle — als Multipart, nicht Base64 durch eine JSON-Antwort.
|
||||||
|
- Der Seed legt 5 Wartungsvorlagen mit echten Prüfvorgaben an (DGUV 112-190,
|
||||||
|
DIN 14811, DGUV Vorschrift 3, DIN 14920), dazu die fälligen Termine, ein
|
||||||
|
abgeschlossenes Protokoll samt Mangel und einen Beispielrückruf.
|
||||||
|
- 79 neue Tests: 47 Unit-Tests zu Fristen, Bewertung und Planung, 10 gegen eine
|
||||||
|
echte Datenbank zur Unveränderbarkeit und Mandantentrennung, 22 Playwright-
|
||||||
|
Durchläufe über Dashboard, Vorlagen, Wizard, Mängel und Rückrufe.
|
||||||
|
|
||||||
|
**Nachzügler aus Phase 1**
|
||||||
|
|
||||||
|
Mit dieser Version wird auch das Geräteformular ausgeliefert, das nach der
|
||||||
|
0.2.0 entstanden ist:
|
||||||
|
|
||||||
|
- **Formular zum Anlegen und Bearbeiten eines Geräts** (`/geraete/neu` und
|
||||||
|
`/geraete/<id>/bearbeiten`). Damit ist die letzte offene Lücke aus Phase 1
|
||||||
|
geschlossen: Ein Neuzugang lässt sich einzeln erfassen, ohne den Umweg über
|
||||||
|
eine Importdatei.
|
||||||
|
- Die Lagerung wird als Umschalter geführt statt als zwei Auswahlfelder, die
|
||||||
|
man versehentlich beide füllen kann.
|
||||||
|
- Prüftermin und Lebensdauerende werden schon beim Tippen ausgerechnet und
|
||||||
|
angezeigt — mit denselben Funktionen, mit denen der Server anschließend
|
||||||
|
rechnet.
|
||||||
|
- Ist nur ein Standort angelegt, wird er vorbelegt.
|
||||||
|
- 7 weitere Playwright-Tests zum Formular (nun 58 statt 44, zwei davon unter
|
||||||
|
Touch-Emulation begründet übersprungen).
|
||||||
|
|
||||||
|
Dabei behoben:
|
||||||
|
|
||||||
|
- **Das Formular stürzte beim ersten Rechnen ab.** Die Vorgabewerte lagen in
|
||||||
|
einem `"use client"`-Modul und wurden von einer Server-Komponente importiert;
|
||||||
|
dort kommt nur eine Client-Referenz an, kein Objekt. Das Spreaden davon ergab
|
||||||
|
lauter `undefined`. Die Werte liegen jetzt in einem eigenen Modul ohne
|
||||||
|
`"use client"`.
|
||||||
|
- **Das Sternchen an Pflichtfeldern wurde vorgelesen.** Eine Sprachausgabe
|
||||||
|
nannte das Feld „Bezeichnung Stern". Das Sternchen trägt jetzt `aria-hidden`;
|
||||||
|
dass ein Feld ausgefüllt werden muss, sagt das `required`-Attribut.
|
||||||
|
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) —
|
||||||
|
Next.js hängt einen eigenen Routen-Ansager mit derselben Rolle in jede Seite.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Die Datumsarithmetik aus Phase 1 (`utcTag`, `monateAddieren`, `heutigerTag`,
|
||||||
|
`tagesabstand`) ist jetzt exportiert. Zwei Umsetzungen derselben Rechnung
|
||||||
|
wären früher oder später um einen Tag auseinandergelaufen.
|
||||||
|
- Die Seitenleiste zeigt Prüfungen, Mängel und Rückrufe als verfügbar.
|
||||||
|
- Die Gerätedetailseite zeigt Prüfhistorie, offene Mängel und anstehende
|
||||||
|
Termine — und startet die Prüfung.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer.**
|
||||||
|
Die eindeutige Bedingung in der Datenbank fing das ab, aber der zweite Prüfer
|
||||||
|
bekam einen Datenbankfehler statt eines Protokolls. Die Nummer wird jetzt
|
||||||
|
unter einer Transaktionssperre je Wehr und Jahr vergeben — gefunden von den
|
||||||
|
E2E-Tests, weil Desktop- und Tablet-Durchlauf parallel prüften.
|
||||||
|
- **Der Worker ließ sich außerhalb des Containers nicht starten.** Ihm fehlte
|
||||||
|
das Laden der `.env`, und `server-only` warf beim Import unter Node. Beide
|
||||||
|
Startbefehle laufen jetzt mit `--conditions=react-server`, womit der leere
|
||||||
|
Export dieses Pakets greift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-09-03
|
||||||
|
|
||||||
|
Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit
|
||||||
|
Standorten, Fahrzeugen samt Beladeordnung, Gerätetypen als Vorgabe und
|
||||||
|
Geräten, die per QR-Code am Regal auffindbar sind.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
|
||||||
|
- `Standort` (Gerätehaus, Lager, Außenstelle, Werkstatt) mit PostGIS-Punkt für
|
||||||
|
die Karte ab Phase 6.
|
||||||
|
- `Geraeteraum` — die Fächer am Fahrzeug (G1 bis G6, Heck, Dach,
|
||||||
|
Mannschaftsraum), in Beladereihenfolge sortierbar.
|
||||||
|
- `Geraetetyp` als Vorgabe für Prüfintervall, Prüfvorschrift und Lebensdauer.
|
||||||
|
Ein einzelnes Gerät darf davon abweichen; sonst gilt der Typ.
|
||||||
|
- `Qualifikationstyp` und `Qualifikation` mit Gültigkeitsdauer und eigener
|
||||||
|
Vorwarnzeit je Typ.
|
||||||
|
- `Fahrzeug` erweitert um Fahrgestellnummer, DIN-Typkurzzeichen, Erstzulassung,
|
||||||
|
Sitzplätze, Tankinhalt, Gesamtmasse und die UVV-Frist.
|
||||||
|
- `Geraet` erweitert um Gerätetyp, Lebensdauerkategorie, errechnetes
|
||||||
|
Lebensdauerende, Anschaffungspreis und die Lagerung.
|
||||||
|
|
||||||
|
**Regeln in der Datenbank**
|
||||||
|
|
||||||
|
- Ein Gerät steht entweder an einem Standort oder ist auf einem Fahrzeug
|
||||||
|
verlastet — eine Prüfbedingung setzt das durch, ein Trigger stellt zusätzlich
|
||||||
|
sicher, dass ein angegebener Geräteraum auch zum angegebenen Fahrzeug gehört.
|
||||||
|
- Row Level Security auf allen neuen Tabellen.
|
||||||
|
- Trigram-Indizes für die Volltextsuche über Geräte, Fahrzeuge und Personen.
|
||||||
|
|
||||||
|
**Funktionen**
|
||||||
|
|
||||||
|
- Geräteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
|
||||||
|
Blättern und Spaltenauswahl. Die Spaltenauswahl bleibt je Browser gespeichert.
|
||||||
|
- Massenbearbeitung: Status mehrerer Geräte auf einmal setzen, Geräte umlagern.
|
||||||
|
Jede Änderung erzeugt einen eigenen Protokolleintrag am jeweiligen Gerät.
|
||||||
|
- QR-Code je Gerät. Der Code zeigt auf `/g/<inventarnummer>` und führt nach der
|
||||||
|
Anmeldung direkt zur Gerätedetailseite.
|
||||||
|
- Etikettenbogen als PDF über Gotenberg: DIN A4 mit 65 Etiketten
|
||||||
|
(38,1 × 21,2 mm). Der Druck kann an einer beliebigen Position beginnen, damit
|
||||||
|
angebrochene Bögen weiterverwendet werden können.
|
||||||
|
- QR-Scan über die Kamera im Browser, mit Eingabefeld als Rückfallweg.
|
||||||
|
- Import aus CSV und Excel: Spalten werden nach den Überschriften automatisch
|
||||||
|
zugeordnet, die Vorschau zeigt Zeile für Zeile, was übernommen würde und was
|
||||||
|
daran nicht stimmt. Geschrieben wird erst nach Bestätigung.
|
||||||
|
- Gerätehistorie und Fahrzeughistorie, gerendert aus dem Audit-Log.
|
||||||
|
- Qualifikationen mit Ablaufwarnung, gerechnet mit der Vorwarnzeit des
|
||||||
|
jeweiligen Typs.
|
||||||
|
- Fahrzeugdetail zeigt die Beladung nach Geräteräumen statt alphabetisch.
|
||||||
|
|
||||||
|
**Daten und Tests**
|
||||||
|
|
||||||
|
- Der Seed legt jetzt zusätzlich 3 Standorte, 20 Geräteräume, 14 Gerätetypen
|
||||||
|
mit echten Prüfvorgaben (DGUV, DIN, vfdb), 49 Geräte und 107 Qualifikationen
|
||||||
|
an. Fristen sind bewusst gestreut, damit die Ampeln etwas zeigen.
|
||||||
|
- 104 Vitest-Tests (vorher 32), davon 40 zum Import und 32 zu QR und Fristen.
|
||||||
|
- 44 Playwright-Tests (vorher 10) über Desktop und Tablet.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Fristenrechnung auf UTC-Kalenderarithmetik umgestellt.** Vorher wurde über
|
||||||
|
`startOfDay` aus date-fns gerechnet, das die Zeitzone des Servers verwendet.
|
||||||
|
Ein Prüfdatum kommt aus der Datenbank als UTC-Mitternacht und wurde dabei auf
|
||||||
|
den Vortag zurückgeschoben — Fristen wären einen Tag zu früh fällig geworden.
|
||||||
|
- `CardTitle` rendert eine echte Überschrift statt eines `div`. Wer mit einer
|
||||||
|
Sprachausgabe arbeitet, springt von Überschrift zu Überschrift; Karten
|
||||||
|
tauchten in dieser Navigation vorher gar nicht auf.
|
||||||
|
- Die Entwicklungsanzeige von Next.js ist abgeschaltet, damit sie nicht in den
|
||||||
|
Bildern der Anleitung mitläuft.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Der Etikettendruck wird protokolliert (`EXPORTIERT`) — damit ist
|
||||||
|
nachvollziehbar, wann welche Aufkleber erzeugt wurden.
|
||||||
|
- Der Import übernimmt keine Werte, die der Browser geschickt hat: die Datei
|
||||||
|
wird beim Übernehmen erneut serverseitig eingelesen und geprüft. Sonst ließe
|
||||||
|
sich die Vorschau umgehen.
|
||||||
|
|
||||||
## [0.1.0] — 2026-09-03
|
## [0.1.0] — 2026-09-03
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
|||||||
| Bereich | Stand |
|
| Bereich | Stand |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Phase 0 — Fundament | fertig |
|
| Phase 0 — Fundament | fertig |
|
||||||
| Phase 1 — Stammdaten | offen |
|
| Phase 1 — Stammdaten | fertig |
|
||||||
| Phase 2 — Wartung und Prüfung | offen |
|
| Phase 2 — Wartung und Prüfung | fertig |
|
||||||
| Phase 3 — Einsätze und Übungen | offen |
|
| Phase 3 — Einsätze und Übungen | fertig |
|
||||||
| Phase 4 — Kiosk-PWA | offen |
|
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
|
||||||
| Phase 5 — Atemschutz (FwDV 7) | offen |
|
| Phase 5 — Atemschutz (FwDV 7) | fertig |
|
||||||
| Phase 6 — Kartenmodule | offen |
|
| Phase 6 — Kartenmodule | fertig |
|
||||||
| Phase 7 — Benachrichtigungen und Termine | offen |
|
| Phase 7 — Benachrichtigungen und Termine | offen |
|
||||||
| Phase 8 — Lokale KI | offen |
|
| Phase 8 — Lokale KI | offen |
|
||||||
| Phase 9 — Kassenverwaltung | offen |
|
| Phase 9 — Kassenverwaltung | offen |
|
||||||
@@ -154,6 +154,637 @@ Downloads laufen über presignte Links mit begrenzter Gültigkeit
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Wartung und Prüfung
|
||||||
|
|
||||||
|
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt: Prüffristen
|
||||||
|
im Blick behalten und im Streitfall nachweisen können, dass geprüft wurde.
|
||||||
|
|
||||||
|
### Der Weg durch das Modul
|
||||||
|
|
||||||
|
```
|
||||||
|
Wartungsvorlage was an einem Gerätetyp in welchem Takt geprüft wird
|
||||||
|
└─ Checklistenpunkt die einzelne Frage: ja/nein, Messwert, Text, Foto
|
||||||
|
↓ nächtlicher Lauf
|
||||||
|
Wartungstermin ein fälliger Termin an einem konkreten Gerät
|
||||||
|
↓ Prüfwizard
|
||||||
|
Pruefung durchgeführte Prüfung, unterschrieben, festgeschrieben
|
||||||
|
├─ Pruefergebnis je Checklistenpunkt eine Antwort
|
||||||
|
├─ Signatur Unterschrift aus dem Zeichenfeld, PNG in MinIO
|
||||||
|
└─ Mangel was aufgefallen ist und behoben werden muss
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne Vorlage gibt es keine Termine, ohne Termine kein Fristen-Dashboard. Die
|
||||||
|
erste Seite, die eine Wehr in Phase 2 ausfüllt, ist deshalb
|
||||||
|
`/pruefungen/vorlagen`.
|
||||||
|
|
||||||
|
### Das Protokoll ist unveränderbar
|
||||||
|
|
||||||
|
Ein abgeschlossenes Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse.
|
||||||
|
Wäre es nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
|
||||||
|
deshalb in der Datenbank (Trigger `pruefung_unveraenderlich`, Migration
|
||||||
|
`20260903000300_wartung_rls`) und nicht nur im Anwendungscode — ein vergessener
|
||||||
|
Zustandscheck in einem Router würde sonst reichen, um sie auszuhebeln.
|
||||||
|
|
||||||
|
Nach dem Abschluss sind genau drei Änderungen zulässig, und alle drei ergänzen
|
||||||
|
das Protokoll, statt es zu verfälschen:
|
||||||
|
|
||||||
|
| Änderung | Warum |
|
||||||
|
|---|---|
|
||||||
|
| `ABGESCHLOSSEN` → `ERSETZT` | eine Korrekturprüfung ist nachgefolgt |
|
||||||
|
| `protokollDateiId`, solange leer | das PDF entsteht im Hintergrund und wird nachgetragen |
|
||||||
|
| `geaendertAm` | setzt Prisma bei jedem Schreibvorgang mit |
|
||||||
|
|
||||||
|
Dasselbe gilt für die Bestandteile: an einem abgeschlossenen Protokoll lassen
|
||||||
|
sich weder Einzelergebnisse ändern noch Unterschriften nachtragen. **Korrekturen
|
||||||
|
erzeugen eine neue Prüfung** mit Verweis auf die alte; beide bleiben lesbar.
|
||||||
|
|
||||||
|
Gelöscht wird ein Protokoll nie — außer im ausdrücklich gekennzeichneten
|
||||||
|
Aufbewahrungslauf, demselben Schalter wie beim Audit-Log
|
||||||
|
(`SET LOCAL app.audit_bereinigung = 'an'`).
|
||||||
|
|
||||||
|
### Der nächtliche Lauf
|
||||||
|
|
||||||
|
Der Worker trägt sich beim Start je Wehr zwei wiederkehrende Aufträge ein:
|
||||||
|
|
||||||
|
| Auftrag | Vorgabe | Was er tut |
|
||||||
|
|---|---|---|
|
||||||
|
| `fristen.pruefen` | 03:15 Uhr | Termine aus den Vorlagen planen, Frist und Status an jedem Gerät nachziehen |
|
||||||
|
| `erinnerung.senden` | 03:45 Uhr | Vorwarnungen zu offenen Terminen verschicken |
|
||||||
|
|
||||||
|
Beide sind wiederholbar: doppelte Termine verhindert die Eindeutigkeit über
|
||||||
|
Gerät, Vorlage und Fälligkeitstag, doppelte Mails ein Erinnerungseintrag je
|
||||||
|
Termin und Stufe. Erinnert wird bei T-30 (bzw. der Vorwarnzeit der Vorlage),
|
||||||
|
T-7, T-0 und einmal bei Überfälligkeit — als **eine** Sammelnachricht je
|
||||||
|
Empfänger, nicht als eine Mail je Gerät.
|
||||||
|
|
||||||
|
„Überfällig" ist bewusst kein gespeicherter Zustand, sondern wird aus dem
|
||||||
|
Fälligkeitsdatum und dem heutigen Tag gerechnet. Gespeicherte Zustände laufen
|
||||||
|
der Uhr hinterher; berechnete nicht.
|
||||||
|
|
||||||
|
### Prüfwizard
|
||||||
|
|
||||||
|
Zugeschnitten auf das Tablet im Gerätehaus: ein Prüfpunkt je Bildschirm, große
|
||||||
|
Schaltflächen, Foto direkt aus der Kamera, Unterschrift auf einem Zeichenfeld,
|
||||||
|
das Finger, Stift und Maus gleichermaßen versteht.
|
||||||
|
|
||||||
|
Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens die
|
||||||
|
letzte Antwort verloren, nicht die ganze Prüfung — beim erneuten Öffnen wird der
|
||||||
|
Entwurf fortgesetzt.
|
||||||
|
|
||||||
|
Die Bewertung rechnet der Server, nicht der Browser
|
||||||
|
([`src/lib/wartung/bewertung.ts`](src/lib/wartung/bewertung.ts)):
|
||||||
|
|
||||||
|
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
|
||||||
|
- ein beanstandeter **Pflichtpunkt** bedeutet „nicht bestanden" und sperrt das
|
||||||
|
Gerät,
|
||||||
|
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
|
||||||
|
|
||||||
|
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er
|
||||||
|
trägt die Verantwortung.
|
||||||
|
|
||||||
|
### Prüfprotokoll als PDF
|
||||||
|
|
||||||
|
Erzeugt der Worker über Gotenberg
|
||||||
|
([`src/lib/wartung/protokoll.ts`](src/lib/wartung/protokoll.ts)) und legt es in
|
||||||
|
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
|
||||||
|
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
|
||||||
|
Prüfdatum und Seitenzahl.
|
||||||
|
|
||||||
|
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder.
|
||||||
|
Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
|
||||||
|
|
||||||
|
Die laufende Protokollnummer (`JJJJ-NNNN`) wird je Wehr und Jahr unter einer
|
||||||
|
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
|
||||||
|
verschiedene Nummern.
|
||||||
|
|
||||||
|
### Mängel und Rückrufe
|
||||||
|
|
||||||
|
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben.
|
||||||
|
**Melden darf jedes Mitglied** — der Mangel fällt dem auf, der das Gerät
|
||||||
|
benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das
|
||||||
|
Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn
|
||||||
|
kein anderer offener Mangel dasselbe Gerät sperrt.
|
||||||
|
|
||||||
|
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich
|
||||||
|
und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein
|
||||||
|
**Vorschlag**, keine automatische Sperre: eine Chargenangabe des Herstellers
|
||||||
|
passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
|
||||||
|
|
||||||
|
### Zusätzliche Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `WARTUNG_NACHTLAUF` | `15 3 * * *` | wann Termine geplant werden (cron, Europe/Berlin) |
|
||||||
|
| `WARTUNG_ERINNERUNG_LAUF` | `45 3 * * *` | wann Erinnerungen verschickt werden |
|
||||||
|
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Einsätze und Übungen
|
||||||
|
|
||||||
|
Was die Wehr tut, wird hier dokumentiert — für die Gemeinde, für die
|
||||||
|
Versicherung und für die eigene Mannschaft.
|
||||||
|
|
||||||
|
### Der Weg durch das Modul
|
||||||
|
|
||||||
|
```
|
||||||
|
Schnellerfassung Stichwort, Ort, Fahrzeuge — im Alarmfall in 20 Sekunden
|
||||||
|
↓ während des Einsatzes
|
||||||
|
Zeitstempel alarmiert, ausgerückt, eingetroffen, Ende, eingerückt,
|
||||||
|
einsatzbereit — je ein Knopf
|
||||||
|
Lagemeldungen aus der Wehr und über das Portal einer Fremdwehr
|
||||||
|
↓ danach, in Ruhe
|
||||||
|
Kräfte und Material wer war dabei, was wurde verbraucht
|
||||||
|
Bericht der Text des Einsatzleiters
|
||||||
|
↓ Freigabe
|
||||||
|
Einsatzbericht als PDF, festgeschrieben, an die Gemeinde
|
||||||
|
```
|
||||||
|
|
||||||
|
### Die sechs Zeitstempel
|
||||||
|
|
||||||
|
Sie sind der Kern jeder Einsatzauswertung. Aus ihnen ergeben sich drei Zahlen:
|
||||||
|
|
||||||
|
| Kennzahl | Rechnung | sagt etwas aus über |
|
||||||
|
|---|---|---|
|
||||||
|
| **Ausrückzeit** | Alarm bis Ausrücken | die Wehr selbst |
|
||||||
|
| **Anfahrtszeit** | Ausrücken bis Eintreffen | Lage und Wegstrecke |
|
||||||
|
| **Hilfsfrist** | Alarm bis Eintreffen | das, wonach die Gemeinde fragt |
|
||||||
|
|
||||||
|
In Bayern gilt als Ziel: **in 80 Prozent der Fälle innerhalb von zehn Minuten**.
|
||||||
|
Genau dieser Wert steht in der Statistik — nicht der Durchschnitt, denn der
|
||||||
|
verdeckt die Ausreißer, um die es geht.
|
||||||
|
|
||||||
|
### Stärke nach FwDV 3
|
||||||
|
|
||||||
|
Aus den Funktionen der erfassten Kräfte errechnet FLORIAN die Stärke in der
|
||||||
|
üblichen Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`:
|
||||||
|
|
||||||
|
| Einheit | Stärke | Besetzung |
|
||||||
|
|---|---|---|
|
||||||
|
| Trupp | 0/1/1/2 | Truppführer, Truppmann |
|
||||||
|
| Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps |
|
||||||
|
| Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps |
|
||||||
|
| Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel |
|
||||||
|
|
||||||
|
**Regional unterschiedlich.** Manche Wehren zählen den Maschinisten zu den
|
||||||
|
Unterführern, andere schreiben dreistellig ohne Unterführer (Gruppe = 1/8/9).
|
||||||
|
Die Zuordnung steht an einer Stelle — [`src/lib/einsatz/staerke.ts`](src/lib/einsatz/staerke.ts) —
|
||||||
|
und ist bewusst leicht zu ändern.
|
||||||
|
|
||||||
|
### Der freigegebene Bericht ist unveränderbar
|
||||||
|
|
||||||
|
Dieselbe Logik wie beim Prüfprotokoll aus Phase 2, denn der Bericht geht an
|
||||||
|
die Gemeinde und im Schadensfall an die Versicherung. Bis zur Freigabe ist
|
||||||
|
alles änderbar — ein Einsatz wird tagelang ergänzt, und genau dafür ist der
|
||||||
|
Entwurfsstand da. Mit der Freigabe ist Schluss; der Trigger
|
||||||
|
`einsatz_freigabe_schuetzen` setzt das durch.
|
||||||
|
|
||||||
|
Zwei Ausnahmen bleiben, beide nachtragend: das PDF, solange keines hinterlegt
|
||||||
|
ist, und `geaendertAm`.
|
||||||
|
|
||||||
|
**Lagemeldungen sind ausdrücklich ausgenommen.** Sie sind das Protokoll des
|
||||||
|
Verlaufs, kein Bestandteil des Berichts — und eine Meldung aus dem Portal kann
|
||||||
|
eintreffen, wenn der Bericht längst geschrieben ist.
|
||||||
|
|
||||||
|
### Einsatzportal für Fremdwehren
|
||||||
|
|
||||||
|
Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor
|
||||||
|
Ort. Die haben kein Konto in FLORIAN und sollen auch keines bekommen. Der
|
||||||
|
Einsatzleiter erzeugt stattdessen einen Link, den er als QR-Code zeigt oder
|
||||||
|
weiterschickt.
|
||||||
|
|
||||||
|
Vier Schranken sichern ihn ab:
|
||||||
|
|
||||||
|
1. **Zufall** — 32 zufällige Bytes im Link.
|
||||||
|
2. **Nur ein Hash in der Datenbank** — wer sie liest, bekommt keinen gültigen Link.
|
||||||
|
3. **Zeitlich begrenzt** — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder
|
||||||
|
Aufruf wird mitgezählt.
|
||||||
|
4. **Zugriffsbegrenzung** — 120 Aufrufe je Link und Stunde, 30 Meldungen, 10
|
||||||
|
Fotos.
|
||||||
|
|
||||||
|
Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort,
|
||||||
|
Zeitstempel und Lagemeldungen. **Keine Namen von Einsatzkräften, keine
|
||||||
|
Berichte, kein Weg in den Rest der Anwendung.**
|
||||||
|
|
||||||
|
### Adressen zu Koordinaten
|
||||||
|
|
||||||
|
Über einen **selbst betriebenen** Nominatim (`NOMINATIM_URL`). Einsatzadressen
|
||||||
|
sagen, wo es gebrannt hat und wer betroffen war; solche Anfragen gehören nicht
|
||||||
|
an einen fremden Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim
|
||||||
|
erlauben es ohnehin nicht.
|
||||||
|
|
||||||
|
Ohne Konfiguration bleibt das Feld leer und die Koordinaten werden von Hand
|
||||||
|
eingetragen — kein Fehler, sondern der Normalfall in einer frischen
|
||||||
|
Installation. Die Karte dazu kommt in Phase 6.
|
||||||
|
|
||||||
|
Gespeichert wird doppelt: als Dezimalgrad, die Prisma lesen kann, und als
|
||||||
|
PostGIS-Punkt für die Karte. Ein Trigger hält beides zusammen.
|
||||||
|
|
||||||
|
### Statistik und Jahresexport
|
||||||
|
|
||||||
|
`/einsaetze/statistik` beantwortet beide Fragen: die der Gemeinde (Anzahl nach
|
||||||
|
Kategorie der Landesstatistik, Hilfsfristen) und die der Wehr (Beteiligung je
|
||||||
|
Person, Auslastung je Fahrzeug). Der Jahresexport als PDF liegt unter
|
||||||
|
`/api/einsaetze/jahresexport?jahr=…` und wird bewusst **nicht** abgelegt — er
|
||||||
|
ist eine Auswertung, keine Urkunde, und ändert sich, solange noch Einsätze
|
||||||
|
nachgetragen werden.
|
||||||
|
|
||||||
|
### Zusätzliche Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `GEOCODING_ENABLED` | `false` | Adresssuche einschalten |
|
||||||
|
| `NOMINATIM_URL` | — | Adresse des eigenen Nominatim |
|
||||||
|
| `GEOCODING_LAENDER` | `de,at,ch` | Eingrenzung der Suche |
|
||||||
|
| `PORTAL_ENABLED` | `true` | Einsatzportal für Fremdwehren |
|
||||||
|
| `PORTAL_GUELTIGKEIT_STUNDEN` | `24` | Lebensdauer eines Links |
|
||||||
|
| `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Das Terminal im Gerätehaus
|
||||||
|
|
||||||
|
Ein Wandtablet neben der Fahrzeughalle. Es muss zwei Dinge können, die eine
|
||||||
|
Verwaltungsoberfläche am Schreibtisch nicht können muss: **mit Handschuhen
|
||||||
|
bedienbar** sein und **weiterarbeiten, wenn das Netz weg ist**. Stahlbeton,
|
||||||
|
Metalltore und ein Repeater, der irgendwann ausfällt — das ist der Normalfall,
|
||||||
|
nicht die Ausnahme.
|
||||||
|
|
||||||
|
Die Oberfläche liegt unter `/kiosk` und ist eine eigene: große Kacheln statt
|
||||||
|
Navigationsleiste, Schaltflächen ab 64 Pixel Höhe, Symbol **und** Text.
|
||||||
|
|
||||||
|
### Der Weg durch das Modul
|
||||||
|
|
||||||
|
1. **Einmal einrichten** — `/kiosk/einrichten` verlangt den Kurznamen der Wehr
|
||||||
|
und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`). Beides nimmt
|
||||||
|
die Seite auch aus der Adresse an, damit am Tablet nichts abgetippt werden
|
||||||
|
muss:
|
||||||
|
|
||||||
|
```
|
||||||
|
/kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Anmelden** — Ausweis auflegen, QR-Code vom Handy oder Mitgliedsnummer und
|
||||||
|
PIN. Ohne eingerichtetes Gerät kommt niemand hinein, auch nicht mit richtiger
|
||||||
|
PIN: das Gerätegeheimnis ist die erste Schranke.
|
||||||
|
3. **Erfassen** — Mangel melden, Kommen/Gehen, Fahrt beginnen und beenden,
|
||||||
|
Wäsche abgeben, Inventurgang.
|
||||||
|
4. **Von selbst abmelden** — nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit,
|
||||||
|
mit sichtbarer Warnung vorher. Ein Terminal, an dem die letzte Anmeldung
|
||||||
|
stehen bleibt, erfasst irgendwann alles auf den Falschen.
|
||||||
|
|
||||||
|
Was dabei zusammenkommt, steht in der Verwaltung unter `/terminal`.
|
||||||
|
|
||||||
|
### Mangel melden in unter dreißig Sekunden
|
||||||
|
|
||||||
|
Der Vorgang, an dem sich das Terminal misst. Drei Schritte, kein Formular:
|
||||||
|
|
||||||
|
1. QR-Code am Gerät scannen — oder die Inventarnummer eintippen.
|
||||||
|
2. Foto aufnehmen und einen Satz dazu.
|
||||||
|
3. Dringlichkeit antippen. Damit ist es abgeschickt.
|
||||||
|
|
||||||
|
„Kritisch" sperrt das Gerät sofort. Eine unbekannte Inventarnummer hält die
|
||||||
|
Meldung **nicht** auf: sie geht raus und landet notfalls in der Konfliktliste —
|
||||||
|
lieber ein Zettel zu viel als ein Riss im Schlauch, den niemand gemeldet hat.
|
||||||
|
|
||||||
|
### Offline: wie nichts verloren geht
|
||||||
|
|
||||||
|
Drei Bausteine, und keiner davon ist optional:
|
||||||
|
|
||||||
|
**Service Worker** (`public/sw.js`, von Hand geschrieben, keine Bibliothek):
|
||||||
|
App-Shell und Symbole liegen im Zwischenspeicher, Seitenaufrufe gehen
|
||||||
|
`network-first` mit Ausweichseite `/kiosk/offline`, lesende tRPC-Aufrufe
|
||||||
|
`stale-while-revalidate`. Schreibende Anfragen und alles unter `/api/auth`
|
||||||
|
fasst er **nicht** an — eine zwischengespeicherte Anmeldung wäre eine Lücke,
|
||||||
|
kein Komfort.
|
||||||
|
|
||||||
|
**Warteschlange in der IndexedDB** (`src/lib/kiosk/outbox.ts`, Dexie): jede
|
||||||
|
schreibende Aktion geht zuerst dorthin — auch online. Sonst gäbe es zwei Wege
|
||||||
|
durch den Code, und der seltenere wäre der ungetestete. Fotos liegen als Blob
|
||||||
|
daneben und werden beim Übertragen zuerst hochgeladen.
|
||||||
|
|
||||||
|
**Idempotenzschlüssel**: die Kennung erzeugt der Browser beim Einreihen
|
||||||
|
(`crypto.randomUUID()`), nicht der Server beim Empfangen. Bricht die
|
||||||
|
Übertragung ab und wird wiederholt, erkennt der Server denselben Vorgang an
|
||||||
|
derselben Kennung wieder und antwortet mit dem Ergebnis von damals. Die Tabelle
|
||||||
|
`kiosk_vorgang` hat diese Kennung als Primärschlüssel, und die Datenbank
|
||||||
|
verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf.
|
||||||
|
|
||||||
|
Ein Vorgang verschwindet aus der Warteschlange, wenn der Server ihn angenommen
|
||||||
|
**oder** als bereits verarbeitet erkannt **oder** in die Konfliktliste gelegt
|
||||||
|
hat. In allen drei Fällen ist er angekommen. Liegen bleibt nur, was technisch
|
||||||
|
nicht durchging.
|
||||||
|
|
||||||
|
Der Zustand steht als Leiste über allem — verbunden, wie viele Änderungen
|
||||||
|
warten, kein Netz — in Klartext und nicht nur als Farbe.
|
||||||
|
|
||||||
|
**Wem ein Vorgang zugeschrieben wird.** Der Server nimmt dafür die Person, die
|
||||||
|
beim Empfang angemeldet ist. Im Regelfall ist das dieselbe, die erfasst hat —
|
||||||
|
wer offline erfasst, bleibt angemeldet, weil auch das Abmelden Netz braucht.
|
||||||
|
Damit dazwischen kein Fenster bleibt, überträgt das Terminal vor jedem
|
||||||
|
Abmelden — von Hand wie durch die Abmeldeuhr — noch einmal.
|
||||||
|
|
||||||
|
### Konflikte
|
||||||
|
|
||||||
|
Ein Vorgang, der sich nicht anwenden lässt (Gerät zwischenzeitlich ausgesondert,
|
||||||
|
Prüfung inzwischen festgeschrieben), wird nicht verworfen und nicht erzwungen,
|
||||||
|
sondern in `sync_konflikt` gelegt. Der Gerätewart sieht ihn unter `/terminal`
|
||||||
|
und entscheidet. Das ist der ehrliche Umgang mit einem Tablet, das drei Tage
|
||||||
|
offline war.
|
||||||
|
|
||||||
|
### Anmeldemarke fürs Handy
|
||||||
|
|
||||||
|
Wer den Ausweis vergessen hat, öffnet `/anmeldemarke` und hält den QR-Code vor
|
||||||
|
die Kamera des Terminals. Die Marke ist mit `AUTH_SECRET` signiert, **nicht
|
||||||
|
gespeichert**, und gilt 60 Sekunden — ein abfotografierter Code aus der Umkleide
|
||||||
|
nützt danach nichts mehr. Die Seite erneuert ihn von selbst.
|
||||||
|
|
||||||
|
### Hallenmonitor
|
||||||
|
|
||||||
|
`/kiosk/monitor` läuft im Vollbild ohne Bedienung: Uhrzeit, laufender Einsatz,
|
||||||
|
wer im Haus ist, Fahrzeuge samt laufenden Fahrten, fällige Prüfungen und die
|
||||||
|
nächsten Termine. Optional Wetterwarnungen des DWD — die einzige Verbindung,
|
||||||
|
die FLORIAN von sich aus nach außen aufbaut, und deshalb standardmäßig aus.
|
||||||
|
|
||||||
|
### Als App installieren
|
||||||
|
|
||||||
|
Das Manifest liegt unter `/manifest.webmanifest`, die Symbole entstehen mit
|
||||||
|
`npm run icons` und liegen unter `public/icons/`. Startseite ist `/kiosk`. Im
|
||||||
|
Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile.
|
||||||
|
|
||||||
|
### Zusätzliche Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `KIOSK_ENABLED` | `true` | Terminalanmeldung freischalten |
|
||||||
|
| `KIOSK_DEVICE_SECRET` | — | Geheimnis, mit dem sich ein Tablet ausweist |
|
||||||
|
| `KIOSK_IDLE_TIMEOUT` | `120` | Abmeldung nach Untätigkeit, in Sekunden |
|
||||||
|
| `WETTER_ENABLED` | `false` | Wetterwarnungen auf dem Monitor |
|
||||||
|
| `DWD_WARNCELL` | — | Warnzellen-Nummer der eigenen Gemeinde |
|
||||||
|
| `DWD_WARNUNGEN_URL` | DWD-Warnapp | Quelle der Warnungen |
|
||||||
|
| `WETTER_TIMEOUT` | `5` | Zeitlimit des Abrufs, in Sekunden |
|
||||||
|
| `WETTER_ZWISCHENSPEICHER` | `300` | Gültigkeit einer Antwort, in Sekunden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
|
||||||
|
Das sicherheitsrelevanteste Modul des Projekts. Über jeder Ansicht steht
|
||||||
|
deshalb dauerhaft:
|
||||||
|
|
||||||
|
> **Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.**
|
||||||
|
|
||||||
|
Der Hinweis lässt sich abschalten, aber nur zusammen mit einer Fundstelle:
|
||||||
|
`ATEMSCHUTZ_HINWEIS=false` wirkt erst, wenn unter `ATEMSCHUTZ_FREIGABE` steht,
|
||||||
|
worauf sich die Freigabe stützt — etwa „Kommandantenbeschluss vom 12.03.2026".
|
||||||
|
Fehlt sie, bleibt der Hinweis stehen. Eine Freigabe ohne Beschluss ist keine.
|
||||||
|
|
||||||
|
### Die Zeit gehört dem Server
|
||||||
|
|
||||||
|
Das ist die eine Entscheidung, aus der alles Weitere folgt. **Nirgends wird
|
||||||
|
eine Restzeit heruntergezählt.** In der Datenbank stehen Zeitstempel; wie lange
|
||||||
|
ein Trupp drin ist, rechnet jede Ansicht bei jedem Bild neu aus dem
|
||||||
|
Einstiegszeitpunkt und der aktuellen Uhr.
|
||||||
|
|
||||||
|
Daraus ergibt sich von selbst, was Phase-5.md als wichtigste Anforderung nennt:
|
||||||
|
|
||||||
|
| Was passiert | Was die Anzeige macht |
|
||||||
|
|---|---|
|
||||||
|
| Browser neu laden | Zeigt dieselbe Zeit weiter — es gab nichts zu verlieren |
|
||||||
|
| Zweiter Bildschirm daneben | Zeigt dieselbe Zeit, ohne Abgleich |
|
||||||
|
| Verbindung bricht ab | Die Uhr läuft weiter, und der Abriss steht sichtbar da |
|
||||||
|
| Server neu gestartet | Nach der ersten Antwort ist alles wieder da |
|
||||||
|
|
||||||
|
Server und Browser rechnen mit **denselben** Funktionen
|
||||||
|
(`src/lib/atemschutz/berechnung.ts`). Was das Tablet anzeigt, weist das
|
||||||
|
Protokoll später genauso aus.
|
||||||
|
|
||||||
|
### Rückzugsdruck
|
||||||
|
|
||||||
|
Gerechnet wird nach dem **doppelten Anmarschverbrauch**: was der Weg hinein an
|
||||||
|
Luft gekostet hat, kostet der Weg zurück noch einmal.
|
||||||
|
|
||||||
|
```
|
||||||
|
Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag
|
||||||
|
```
|
||||||
|
|
||||||
|
Dafür meldet der Trupp beim Erreichen des Einsatzziels einmal seinen Druck.
|
||||||
|
Solange das nicht geschehen ist, greift die **Zwei-Drittel-Regel** — sie liegt
|
||||||
|
in aller Regel höher und ist damit die vorsichtigere Annahme; die Karte sagt
|
||||||
|
dann „vorläufig" dazu.
|
||||||
|
|
||||||
|
Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht gemeinsam
|
||||||
|
zurück, also zählt, wer am wenigsten Luft hat.
|
||||||
|
|
||||||
|
Die Ampel steht schon zehn Prozent über der Grenze auf Gelb — der Trupp soll
|
||||||
|
losgehen, bevor der Wert erreicht ist, nicht wenn er erreicht ist. Farbe **und**
|
||||||
|
Text, wie überall in FLORIAN.
|
||||||
|
|
||||||
|
### Der Weg durch eine Überwachung
|
||||||
|
|
||||||
|
1. **Eröffnen** — Bezeichnung genügt. Die Zuordnung zu einem Einsatz lässt sich
|
||||||
|
nachtragen; im Ernstfall wird zuerst überwacht.
|
||||||
|
2. **Trupp anlegen** — zwei oder drei Personen, für jede der Eingangsdruck, eine
|
||||||
|
davon Truppführer. Daneben steht, ob die Person nach FwDV 7 einsetzbar ist.
|
||||||
|
**Gesperrt wird nichts:** im Einsatz entscheidet der Einheitsführer, und ein
|
||||||
|
Programm, das ihm im entscheidenden Moment eine Person verweigert, wird
|
||||||
|
umgangen — dann steht am Ende gar nichts im Protokoll.
|
||||||
|
3. **Einstieg** — ab hier läuft die Uhr.
|
||||||
|
4. **Druckabfrage** — für den ganzen Trupp in einer Zeile. Nach
|
||||||
|
`ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden ohne Meldung wird erinnert; gerechnet
|
||||||
|
ab der letzten Meldung, nicht ab dem Einstieg.
|
||||||
|
5. **Rückweg und heraus** — beim Herauskommen die Ausgangsdrücke.
|
||||||
|
6. **Beenden** — geht erst, wenn alle Trupps als draußen gemeldet sind. Eine
|
||||||
|
beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
|
||||||
|
gefährlichste Zeile im ganzen Protokoll.
|
||||||
|
|
||||||
|
Dazu jederzeit: **Rückzug anordnen**, **Mayday** (aufzuheben, nicht
|
||||||
|
wegzuklicken) und Notizen ins Protokoll.
|
||||||
|
|
||||||
|
### Der Ereignisstrom
|
||||||
|
|
||||||
|
Änderungen kommen über **Server-Sent Events** unter `/api/atemschutz/strom` —
|
||||||
|
kein WebSocket, kein eigener Node-Server, `next start` bleibt wie es ist. Der
|
||||||
|
Strom meldet nur „es hat sich etwas geändert"; die Ansicht holt daraufhin den
|
||||||
|
Stand. Geschrieben wird wie überall über tRPC.
|
||||||
|
|
||||||
|
Zwei Dinge machen ihn belastbar:
|
||||||
|
|
||||||
|
- **Er verbindet sich selbst neu.** `EventSource` bringt das mit.
|
||||||
|
- **Stille gilt als Abriss.** Der Server schickt alle zwei Sekunden ein
|
||||||
|
Lebenszeichen; bleibt es acht Sekunden aus, steht die Verbindung sichtbar auf
|
||||||
|
„unterbrochen" — auch wenn der Browser keinen Fehler meldet. Dieser Fall ist
|
||||||
|
real: wird das Netz gekappt, während der Strom offen ist, schweigt
|
||||||
|
`EventSource` einfach.
|
||||||
|
|
||||||
|
Fällt der Strom ganz aus, bleibt die Ansicht bedienbar — sie fragt zusätzlich im
|
||||||
|
festen Takt nach. Der Strom macht sie schneller, nicht erst funktionsfähig.
|
||||||
|
|
||||||
|
### Das Protokoll
|
||||||
|
|
||||||
|
Ein PDF mit Rahmen, Trupps und dem Verlauf — Ereignisse und Druckmessungen in
|
||||||
|
**einer** Zeitachse, damit niemand zwei Spalten gleichzeitig lesen muss. Es
|
||||||
|
entsteht beim Beenden im Hintergrundprozess und, falls das nicht geklappt hat,
|
||||||
|
beim Abruf unter `/api/atemschutz/protokoll?id=…`.
|
||||||
|
|
||||||
|
Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
|
||||||
|
später in der Hand hält, soll wissen, was er da hat.
|
||||||
|
|
||||||
|
Verändern lässt sich nichts: `druckmessung` und `atemschutz_ereignis` sind für
|
||||||
|
den Anwendungszugang gegen `UPDATE` und `DELETE` gesperrt, und zusätzlich sitzt
|
||||||
|
auf beiden ein Trigger. Ein Fehler wird durch einen neuen Eintrag
|
||||||
|
richtiggestellt.
|
||||||
|
|
||||||
|
### Wer darf unter Atemschutz?
|
||||||
|
|
||||||
|
Drei Nachweise, die gleichzeitig gelten müssen:
|
||||||
|
|
||||||
|
| Nachweis | Frist |
|
||||||
|
|---|---|
|
||||||
|
| **G26.3** | nach Alter: bis 50 Jahre drei Jahre, danach eines |
|
||||||
|
| **Belastungsübung** | jährlich (Übungsstrecke) |
|
||||||
|
| **Einsatzübung unter Atemschutz** | jährlich — ein echter Einsatz zählt |
|
||||||
|
|
||||||
|
Dazu die Qualifikation als Atemschutzgeräteträger aus Phase 1. Die Übersicht
|
||||||
|
zeigt eine Ampel **mit Klartext**, was fehlt: eine rote Ampel ohne Begründung
|
||||||
|
hilft am Fahrzeug niemandem. Auf der Startseite steht die Zahl, nach der im
|
||||||
|
Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind.
|
||||||
|
|
||||||
|
### Zusätzliche Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `ATEMSCHUTZ_HINWEIS` | `true` | Sicherheitshinweis über jeder Ansicht |
|
||||||
|
| `ATEMSCHUTZ_FREIGABE` | — | Fundstelle der Freigabe; ohne sie bleibt der Hinweis |
|
||||||
|
| `ATEMSCHUTZ_ABFRAGE_INTERVALL` | `600` | Abstand der Druckabfrage in Sekunden |
|
||||||
|
| `ATEMSCHUTZ_WARNDRUCK` | `55` | Ansprechdruck der Warneinrichtung in bar |
|
||||||
|
| `ATEMSCHUTZ_ZUSCHLAG` | `10` | Sicherheitszuschlag auf den Rückzugsdruck |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Kartenmodule
|
||||||
|
|
||||||
|
Die Frage, wegen der es das Modul gibt, wird auf der Anfahrt gestellt: **Was
|
||||||
|
liegt in der Nähe, und was gibt es her?**
|
||||||
|
|
||||||
|
### Keine Kacheln aus dem Netz
|
||||||
|
|
||||||
|
Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche Objekte sie im Blick
|
||||||
|
hat, ist niemandes Geschäft. FLORIAN lädt deshalb **keine** Kartenkacheln von
|
||||||
|
einem öffentlichen Dienst — auch nicht „nur zum Ausprobieren". Der Kartenserver
|
||||||
|
läuft selbst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile karte up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Dafür wird eine MBTiles-Datei des eigenen Ausrückegebiets gebraucht; woher man
|
||||||
|
sie bekommt, steht in [docker/karten/LIESMICH.md](docker/karten/LIESMICH.md).
|
||||||
|
Danach in der `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ohne Kartenserver läuft alles trotzdem** — die Karte zeigt einen leeren Grund
|
||||||
|
mit einem Hinweis darauf. Punkte liegen an der richtigen Stelle, Entfernungen
|
||||||
|
stimmen, die Umkreissuche rechnet; es fehlen nur die Straßen darunter. Ist ein
|
||||||
|
Server eingetragen, aber nicht erreichbar, sieht es genauso aus — mit
|
||||||
|
entsprechendem Hinweis.
|
||||||
|
|
||||||
|
### Löschwasser
|
||||||
|
|
||||||
|
Hydranten, Saugstellen, Zisternen und offene Gewässer mit Typ, Nennweite,
|
||||||
|
Durchfluss, Zustand und dem, was man vor Ort wissen muss („im Winter oft
|
||||||
|
zugeschoben").
|
||||||
|
|
||||||
|
Die **Umkreissuche** ist der eigentliche Zweck: in die Karte tippen, und es
|
||||||
|
steht da, was in 500 Metern erreichbar ist — nach Entfernung sortiert, mit
|
||||||
|
Nennweite und Durchfluss. Sie läuft über PostGIS (`ST_DWithin` auf der
|
||||||
|
Geografie, GiST-Index), nicht über die Dezimalgrade: bei tausend Hydranten wäre
|
||||||
|
alles andere ein vollständiger Tabellendurchlauf, und gefragt wird während der
|
||||||
|
Fahrt.
|
||||||
|
|
||||||
|
### Import
|
||||||
|
|
||||||
|
Zwei Quellen, die eine Wehr tatsächlich bekommt:
|
||||||
|
|
||||||
|
| Quelle | Was erkannt wird |
|
||||||
|
|---|---|
|
||||||
|
| **GeoJSON** | FeatureCollection mit Punkten, Eigenschaften wie in der CSV |
|
||||||
|
| **CSV der Stadtwerke** | Spaltennamen wie `DN`, `Lage`, `Latitude`, `Pruefdatum`, `Bauart` |
|
||||||
|
|
||||||
|
Der Import ist nachsichtig, aber ehrlich: „DN 100" wird zu 100, „48,5031" zu
|
||||||
|
48.5031, „24.05.2026" zu einem Datum, und vertauschte Achsen werden erkannt und
|
||||||
|
zurückgedreht. Was nicht lesbar ist, wird mit Zeilennummer und Grund gemeldet —
|
||||||
|
und der Rest läuft durch. Eine Liste, die zu 90 Prozent stimmt, ist im Einsatz
|
||||||
|
mehr wert als keine.
|
||||||
|
|
||||||
|
**Geraten wird nichts.** Fehlt die Position, wird die Zeile übersprungen: ein
|
||||||
|
Hydrant ohne Ort ist kein Hydrant. Und ohne Statusangabe steht „ungeprüft",
|
||||||
|
nicht „einsatzbereit".
|
||||||
|
|
||||||
|
### Objekte und Feuerwehrpläne
|
||||||
|
|
||||||
|
Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom Beifahrersitz,
|
||||||
|
oft in zwanzig Sekunden. Deshalb sind **Zufahrt**, **Gefahren** und
|
||||||
|
**Schlüsseldepot** eigene Felder und stehen ganz oben, nicht in einer
|
||||||
|
Fließtextnotiz.
|
||||||
|
|
||||||
|
Dazu Ansprechpartner mit der einzigen Angabe, die im Einsatz zählt — wer nachts
|
||||||
|
ans Telefon geht — und die Planblätter nach DIN 14095 mit ihrem Stand.
|
||||||
|
|
||||||
|
Gesucht wird über alles: Name, Straße, Gefahrenstichwort, Ansprechpartner. Wer
|
||||||
|
im Einsatz sucht, weiß selten, wie das Objekt im System heißt.
|
||||||
|
|
||||||
|
### Lagekarte
|
||||||
|
|
||||||
|
Eine je Einsatz, unterhalb der Einsatzdaten. 22 taktische Zeichen nach
|
||||||
|
FwDV 102, selbst gezeichnet als SVG — fertige Sätze sind selten frei
|
||||||
|
verwendbar, und nachgeladen wird ohnehin nichts.
|
||||||
|
|
||||||
|
Ein Werkzeug, ein Klick: Zeichen wählen, in die Karte tippen. Dazu Linienzüge
|
||||||
|
(Schlauchleitung, Absperrung), Flächen (Bereitstellungsraum) und
|
||||||
|
Beschriftungen.
|
||||||
|
|
||||||
|
Der Abgleich zwischen Geräten läuft über denselben Ereignisstrom wie die
|
||||||
|
Atemschutzüberwachung, und wie dort steht der Zustand der Verbindung sichtbar
|
||||||
|
da. Zurückgenommene Elemente verschwinden von der Karte, nicht aus dem
|
||||||
|
Datenbestand: eine Lagekarte ist die Mitschrift einer Entscheidung.
|
||||||
|
|
||||||
|
### Wasserförderung
|
||||||
|
|
||||||
|
Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht.
|
||||||
|
Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Reibungsverlust je 100 m = Beiwert × (Fördermenge / 100)²
|
||||||
|
Höhenverlust = Höhenmeter × 0,1 bar
|
||||||
|
```
|
||||||
|
|
||||||
|
Daraus: Zahl der Verstärkerpumpen, Schlauchlängen je Abschnitt, Restdruck und
|
||||||
|
der Druck, der an der Einsatzstelle ankommt. Die Beiwerte treffen die
|
||||||
|
Tabellenwerte der Ausbildung — 1,5 bar je 100 m im B-Schlauch bei 800 l/min.
|
||||||
|
|
||||||
|
Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
|
||||||
|
statt eine Zahl auszuwerfen, die niemand halten kann. Und unter jeder Rechnung
|
||||||
|
steht, was sie ist: **eine Planungshilfe. Was zählt, ist der Druck am
|
||||||
|
Verteiler.**
|
||||||
|
|
||||||
|
### Zusätzliche Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `KARTE_STIL_URL` | — | Stil des eigenen Kartenservers; leer = leerer Grund |
|
||||||
|
| `KARTE_BREITENGRAD` | `48.5` | Mittelpunkt, falls kein Standort verortet ist |
|
||||||
|
| `KARTE_LAENGENGRAD` | `12.15` | dito |
|
||||||
|
| `KARTE_ZOOM` | `13` | Zoomstufe beim Öffnen |
|
||||||
|
| `KARTE_PORT` | `8085` | Port des tileserver-gl (Profil `karte`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ x-app-env: &app-env
|
|||||||
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
||||||
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
||||||
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
|
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
|
||||||
|
KARTE_STIL_URL: ${KARTE_STIL_URL:-}
|
||||||
|
KARTE_BREITENGRAD: ${KARTE_BREITENGRAD:-48.5}
|
||||||
|
KARTE_LAENGENGRAD: ${KARTE_LAENGENGRAD:-12.15}
|
||||||
|
KARTE_ZOOM: ${KARTE_ZOOM:-13}
|
||||||
|
ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true}
|
||||||
|
ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-}
|
||||||
|
ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600}
|
||||||
|
ATEMSCHUTZ_WARNDRUCK: ${ATEMSCHUTZ_WARNDRUCK:-55}
|
||||||
|
ATEMSCHUTZ_ZUSCHLAG: ${ATEMSCHUTZ_ZUSCHLAG:-10}
|
||||||
|
WETTER_ENABLED: ${WETTER_ENABLED:-false}
|
||||||
|
DWD_WARNCELL: ${DWD_WARNCELL:-}
|
||||||
|
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
|
||||||
|
WETTER_TIMEOUT: ${WETTER_TIMEOUT:-5}
|
||||||
|
WETTER_ZWISCHENSPEICHER: ${WETTER_ZWISCHENSPEICHER:-300}
|
||||||
MAIL_ENABLED: ${MAIL_ENABLED:-false}
|
MAIL_ENABLED: ${MAIL_ENABLED:-false}
|
||||||
MAIL_HOST: ${MAIL_HOST:-}
|
MAIL_HOST: ${MAIL_HOST:-}
|
||||||
MAIL_PORT: ${MAIL_PORT:-587}
|
MAIL_PORT: ${MAIL_PORT:-587}
|
||||||
@@ -61,6 +75,16 @@ x-app-env: &app-env
|
|||||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||||
LLM_MODELL: ${LLM_MODELL:-}
|
LLM_MODELL: ${LLM_MODELL:-}
|
||||||
|
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
|
||||||
|
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
||||||
|
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
||||||
|
GEOCODING_LAENDER: ${GEOCODING_LAENDER:-de,at,ch}
|
||||||
|
PORTAL_ENABLED: ${PORTAL_ENABLED:-true}
|
||||||
|
PORTAL_GUELTIGKEIT_STUNDEN: ${PORTAL_GUELTIGKEIT_STUNDEN:-24}
|
||||||
|
PORTAL_MAX_ZUGRIFFE: ${PORTAL_MAX_ZUGRIFFE:-120}
|
||||||
|
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
|
||||||
|
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
|
||||||
|
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
|
||||||
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
|
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
|
||||||
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
||||||
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
||||||
@@ -252,6 +276,36 @@ services:
|
|||||||
- intern
|
- intern
|
||||||
logging: *logging
|
logging: *logging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Kartenserver (Phase 6).
|
||||||
|
#
|
||||||
|
# Nur mit Profil: "docker compose --profile karte up -d". FLORIAN läuft ohne
|
||||||
|
# ihn — die Karte zeigt dann einen leeren Hintergrund, Hydranten und
|
||||||
|
# Lagekarte funktionieren trotzdem.
|
||||||
|
#
|
||||||
|
# Gebraucht wird eine MBTiles-Datei des Ausrückegebiets unter
|
||||||
|
# docker/karten/. Woher man sie bekommt, steht in der README; sie liegt
|
||||||
|
# bewusst nicht im Repository — das sind je nach Zuschnitt hunderte Megabyte
|
||||||
|
# bis Gigabyte, und jede Wehr braucht einen anderen Ausschnitt.
|
||||||
|
#
|
||||||
|
# Der Dienst hängt im internen Netz und im Edge-Netz: der Browser des
|
||||||
|
# Anwenders holt die Kacheln direkt, nicht über die Anwendung.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
tileserver:
|
||||||
|
image: maptiler/tileserver-gl:v4.11.1
|
||||||
|
container_name: florian-tileserver
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [karte]
|
||||||
|
command: ["--port", "8080", "--public_url", "${KARTE_OEFFENTLICH:-http://localhost:8085/}"]
|
||||||
|
volumes:
|
||||||
|
- ./docker/karten:/data:ro
|
||||||
|
ports:
|
||||||
|
- "${KARTE_PORT:-8085}:8080"
|
||||||
|
networks:
|
||||||
|
- edge
|
||||||
|
- intern
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Die Anwendung selbst (Next.js, Standalone-Build).
|
# Die Anwendung selbst (Next.js, Standalone-Build).
|
||||||
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
||||||
@@ -298,7 +352,11 @@ services:
|
|||||||
image: florian-app:${IMAGE_TAG:-latest}
|
image: florian-app:${IMAGE_TAG:-latest}
|
||||||
container_name: florian-worker
|
container_name: florian-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["node", "--enable-source-maps", "dist/worker/index.js"]
|
# --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
|
||||||
|
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
|
||||||
|
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
|
||||||
|
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
|
||||||
|
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
|
||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
ROLLE: worker
|
ROLLE: worker
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Kartenkacheln
|
||||||
|
|
||||||
|
Hier gehört die MBTiles-Datei des Ausrückegebiets hinein. Sie liegt **nicht**
|
||||||
|
im Repository: je nach Zuschnitt sind das hunderte Megabyte bis Gigabyte, und
|
||||||
|
jede Wehr braucht einen anderen Ausschnitt.
|
||||||
|
|
||||||
|
## Woher
|
||||||
|
|
||||||
|
Zwei Wege, beide ohne laufende Kosten und ohne Anmeldung:
|
||||||
|
|
||||||
|
1. **Fertiger Auszug.** Bei [maptiler.com/data](https://data.maptiler.com/downloads/planet/)
|
||||||
|
oder aus einem OpenMapTiles-Auszug den eigenen Landkreis herunterladen.
|
||||||
|
2. **Selbst schneiden.** Aus einem Geofabrik-Auszug (`.osm.pbf`) mit
|
||||||
|
[planetiler](https://github.com/onthegomap/planetiler) den eigenen
|
||||||
|
Ausschnitt erzeugen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -Xmx4g -jar planetiler.jar --download --area=oberbayern \
|
||||||
|
--output=/pfad/zu/docker/karten/ausrueckegebiet.mbtiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Dazu gehört ein Stil. tileserver-gl bringt `basic` mit; ein eigener Stil kommt
|
||||||
|
als `styles/<name>/style.json` daneben.
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile karte up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach in der `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ohne Kacheln
|
||||||
|
|
||||||
|
FLORIAN läuft auch ohne. Die Karte zeigt dann einen leeren Hintergrund mit
|
||||||
|
Gitternetz — Hydranten, Objekte und die Lagekarte liegen trotzdem an der
|
||||||
|
richtigen Stelle, nur ohne Straßen darunter. Für den Anfang reicht das, um
|
||||||
|
Daten zu erfassen; für den Einsatz nicht.
|
||||||
|
|
||||||
|
**Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt, wo ihre Hydranten
|
||||||
|
liegen und welche Objekte sie im Blick hat, ist niemandes Geschäft. Deshalb
|
||||||
|
gibt es in FLORIAN keinen Rückfall auf einen öffentlichen Kartendienst.
|
||||||
@@ -31,9 +31,9 @@ Faktoren, die in die Schätzung eingehen:
|
|||||||
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|
||||||
|---|---|---:|---:|---:|
|
|---|---|---:|---:|---:|
|
||||||
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
|
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
|
||||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | offen |
|
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
||||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen |
|
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
|
||||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
|
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
|
||||||
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
|
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
|
||||||
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
||||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
||||||
@@ -72,7 +72,7 @@ das nachträglich einbaut, zahlt ein Vielfaches.
|
|||||||
|
|
||||||
## Phase 1 — Stammdaten
|
## Phase 1 — Stammdaten
|
||||||
|
|
||||||
**Vergleichsaufwand: 10–14 PT**
|
**Vergleichsaufwand: 10–14 PT** · **Tatsächlich: 2:05 h Sitzungszeit** (1:15 h Hauptteil, 0:50 h Nachtrag)
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -89,9 +89,23 @@ Der Import ist der Posten, der erfahrungsgemäß aus dem Ruder läuft. Jede Wehr
|
|||||||
bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist
|
bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist
|
||||||
kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut.
|
kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut.
|
||||||
|
|
||||||
|
**Rückblick.** Die Schätzung hat gut getroffen — mit einer Verschiebung: Der
|
||||||
|
Import war teurer als angesetzt (deutsche Datumsformate, Semikolon-CSV,
|
||||||
|
Windows-1252, Excel-Seriennummern, Dubletten in der Datei), die Tabellen
|
||||||
|
dagegen billiger, weil Sortierung und Blättern serverseitig laufen und keine
|
||||||
|
Tabellenbibliothek nötig war. Nicht eingeplant war die Zeitzonen-Falle in der
|
||||||
|
Fristenrechnung; sie hätte in Phase 2 deutlich mehr gekostet, wenn sie erst
|
||||||
|
dort aufgefallen wäre.
|
||||||
|
|
||||||
|
Das zunächst offen gebliebene Formular zum einzelnen Anlegen von Geräten wurde
|
||||||
|
als Nachtrag ergänzt — geschätzt waren 0,5 PT, tatsächlich 0:50 h Sitzungszeit.
|
||||||
|
Die Hälfte davon ging für zwei Dinge drauf, die nichts mit dem Formular zu tun
|
||||||
|
hatten: eine Server/Client-Grenze, die stillschweigend `undefined` liefert, und
|
||||||
|
Datumsfelder, die sich in Chromium nicht automatisiert füllen lassen.
|
||||||
|
|
||||||
## Phase 2 — Wartung und Prüfung
|
## Phase 2 — Wartung und Prüfung
|
||||||
|
|
||||||
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase
|
**Vergleichsaufwand: 18–24 PT** · **Tatsächlich: 2:25 h Sitzungszeit** — die aufwendigste Phase
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -110,9 +124,32 @@ der Unfallkasse. Das heißt aber auch, dass jeder Fehler teuer ist. Die
|
|||||||
Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
|
Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
|
||||||
warum der Audit-Log schon in Phase 0 append-only ist.
|
warum der Audit-Log schon in Phase 0 append-only ist.
|
||||||
|
|
||||||
|
**Rückblick.** Deutlich unter der geschätzten Sitzungszeit, und das aus einem
|
||||||
|
Grund, der sich benennen lässt: die Fristenrechnung aus Phase 1 war schon da
|
||||||
|
und musste nur exportiert werden. Wäre sie hier entstanden — samt der
|
||||||
|
Zeitzonen-Falle, über die Phase 1 gestolpert ist —, sähe die Zahl anders aus.
|
||||||
|
|
||||||
|
Teurer als gedacht war das, was zwischen den Bausteinen liegt. Drei Beispiele:
|
||||||
|
|
||||||
|
- Die Reihenfolge beim Abschluss einer Prüfung ist nicht beliebig.
|
||||||
|
Unterschriften und Mängel müssen vor dem Statuswechsel entstehen, denn danach
|
||||||
|
lässt die Datenbank am Protokoll nichts mehr zu. Derselbe Stolperstein traf
|
||||||
|
auch den Seed, der die Beispielprüfung zunächst fertig anlegen wollte.
|
||||||
|
- Die Protokollnummer muss unter einer Sperre vergeben werden. Aufgefallen ist
|
||||||
|
das erst, weil die E2E-Tests Desktop und Tablet gleichzeitig prüfen ließen —
|
||||||
|
zwei Prüfer, die im selben Moment abschließen, sind im Gerätehaus kein
|
||||||
|
konstruierter Fall.
|
||||||
|
- Das PDF entsteht zwischen zwei Datenbanktransaktionen, nicht in einer. Fotos
|
||||||
|
aus MinIO holen und Gotenberg rendern lassen dauert länger, als eine offene
|
||||||
|
Transaktion warten darf.
|
||||||
|
|
||||||
|
Billiger als angesetzt war das Unterschriftenfeld: Pointer-Events decken Finger,
|
||||||
|
Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und
|
||||||
|
`touch-action: none` braucht es nicht.
|
||||||
|
|
||||||
## Phase 3 — Einsätze und Übungen
|
## Phase 3 — Einsätze und Übungen
|
||||||
|
|
||||||
**Vergleichsaufwand: 12–16 PT**
|
**Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 2:10 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -129,9 +166,33 @@ Das externe Portal für Fremdwehren ist sicherheitsrelevant: ein Zugang ohne
|
|||||||
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
|
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
|
||||||
steckt weniger in der Funktion als in der Absicherung.
|
steckt weniger in der Funktion als in der Absicherung.
|
||||||
|
|
||||||
|
**Rückblick.** Die Schätzung lag richtig darin, wo der Aufwand steckt — nur
|
||||||
|
nicht in welcher Reihenfolge.
|
||||||
|
|
||||||
|
Billiger als gedacht war das Portal. Der Grund liegt in Phase 0 und 2: die
|
||||||
|
Mandantentrennung, das Hashen von Geheimnissen und die Ablage von Dateien gab
|
||||||
|
es schon, und der Zugang war am Ende ein Datensatz mit Hash, ein Zeitfenster
|
||||||
|
und ein Zähler in Redis. Was blieb, war die Frage, was das Portal **nicht**
|
||||||
|
zeigen darf — und die ist fachlicher Natur, nicht technischer.
|
||||||
|
|
||||||
|
Teurer war die Statistik. Nicht das Rechnen, sondern die Entscheidung, welche
|
||||||
|
Zahl gilt: der Durchschnitt der Eintreffzeiten sagt nichts, weil ein einzelner
|
||||||
|
Einsatz auf der Autobahn ihn verschiebt. Es zählt der 80-Prozent-Wert, weil die
|
||||||
|
Gemeinde danach fragt — und das steht in keinem Pflichtenheft, das muss man
|
||||||
|
wissen.
|
||||||
|
|
||||||
|
Zwei Dinge fielen erst beim Tun auf:
|
||||||
|
|
||||||
|
- Koordinaten müssen doppelt liegen. Prisma kann den PostGIS-Typ nicht lesen;
|
||||||
|
ohne die Dezimalgrade daneben wäre jede Anzeige eine Rohabfrage geworden. Ein
|
||||||
|
Trigger hält beides zusammen.
|
||||||
|
- Der Nummernkreis war schon zweimal da (Prüfprotokolle, jetzt Einsätze und
|
||||||
|
Übungen). Beim dritten Mal wurde daraus ein eigener Baustein — die passende
|
||||||
|
Gelegenheit war genau diese, keine früher.
|
||||||
|
|
||||||
## Phase 4 — Kiosk-PWA
|
## Phase 4 — Kiosk-PWA
|
||||||
|
|
||||||
**Vergleichsaufwand: 16–22 PT**
|
**Vergleichsaufwand: 16–22 PT** · **Tatsächlich: 3:00 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -150,9 +211,45 @@ unter Verbindungsabbruch, Doppelklick und parallelem Zugriff von zwei
|
|||||||
Terminals nichts verliert und nichts doppelt schreibt, ist die eigentliche
|
Terminals nichts verliert und nichts doppelt schreibt, ist die eigentliche
|
||||||
Arbeit. Die Idempotenzschlüssel sind dafür der Schlüssel, nicht die Synchronisation.
|
Arbeit. Die Idempotenzschlüssel sind dafür der Schlüssel, nicht die Synchronisation.
|
||||||
|
|
||||||
|
**Rückblick.** Der Satz oben hat sich bestätigt, nur anders als gedacht. Die
|
||||||
|
Outbox selbst war in einer halben Stunde geschrieben und mit zwölf Unit-Tests
|
||||||
|
belegt. Zwei Stellen kosteten die Zeit, und keine davon steht in der
|
||||||
|
Aufstellung:
|
||||||
|
|
||||||
|
- **Der Zeitgeber, der nie ablief.** Die Warteschlange hatte einen
|
||||||
|
Wiederholungstakt von 20 Sekunden — und der wurde bei jedem Rendern neu
|
||||||
|
aufgezogen, weil das Mutationsobjekt von React Query in den Abhängigkeiten
|
||||||
|
stand. Alles sah richtig aus, alle Unit-Tests waren grün, und der liegen
|
||||||
|
gebliebene Vorgang wäre im Gerätehaus trotzdem liegen geblieben. Gefunden hat
|
||||||
|
das nur der Playwright-Test, der `context.setOffline(true)` setzt und dann
|
||||||
|
wartet. Die 1,5 PT für „Playwright-Tests für den Offline-Fall" waren der
|
||||||
|
bestangelegte Posten der Phase.
|
||||||
|
- **Die Frage, wann ein Vorgang aus der Warteschlange darf.** Nicht „wenn
|
||||||
|
erfolgreich", sondern „wenn er beim Server angekommen ist" — dazu gehört auch
|
||||||
|
„bereits verarbeitet" und „in der Konfliktliste". Diese Unterscheidung ist die
|
||||||
|
ganze Idempotenz; ohne sie kreist ein Vorgang für immer.
|
||||||
|
|
||||||
|
Billiger als angesetzt war die Anmeldung per RFID: die Aufstellung nennt WebHID,
|
||||||
|
gebraucht wurde es nicht. Handelsübliche Leser geben die Kartenkennung als
|
||||||
|
Tastatureingabe aus. Ein Zuhörer auf dem Fenster, der am Anschlagstempo
|
||||||
|
erkennt, dass da keine Hand tippt, ersetzt eine ganze Geräteschnittstelle.
|
||||||
|
|
||||||
|
Billiger war auch der Service Worker — 80 Zeilen von Hand, ohne Workbox. Die
|
||||||
|
Regeln sind einfach, solange man sie sich traut aufzuschreiben: Seiten aus dem
|
||||||
|
Netz mit Ausweichseite, lesende Abfragen aus dem Zwischenspeicher mit
|
||||||
|
Nachladen, schreibende **überhaupt nicht** anfassen. Eine Bibliothek hätte hier
|
||||||
|
mehr Fragen aufgeworfen als beantwortet.
|
||||||
|
|
||||||
|
Teurer war die Terminaloberfläche selbst. Nicht die Kacheln, sondern die
|
||||||
|
Zustände dahinter: Wer schon eingecheckt ist, sieht „Gehen" statt „Kommen". Wer
|
||||||
|
mit einem Fahrzeug unterwegs ist, sieht „Fahrt beenden". Wer keine Rechte für
|
||||||
|
die Inventur hat, sieht die Kachel nicht. Jede dieser Verzweigungen ist
|
||||||
|
trivial — sechs davon in einem Schirm, der ohne Nachdenken bedienbar bleiben
|
||||||
|
soll, sind es nicht.
|
||||||
|
|
||||||
## Phase 5 — Atemschutz nach FwDV 7
|
## Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
|
||||||
**Vergleichsaufwand: 14–18 PT**
|
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:40 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -171,9 +268,36 @@ Timer, der einen Reload überlebt, ist Pflicht — deshalb muss die Zeit
|
|||||||
serverseitig geführt werden und der Browser nur anzeigen. Diese Phase bekommt
|
serverseitig geführt werden und der Browser nur anzeigen. Diese Phase bekommt
|
||||||
den höchsten Testanteil im Projekt.
|
den höchsten Testanteil im Projekt.
|
||||||
|
|
||||||
|
**Rückblick.** Der Satz stimmt, aber die Schlussfolgerung war die falsche. Die
|
||||||
|
Aufstellung veranschlagt 3,5 PT für „serverseitig autoritative Timer" — als
|
||||||
|
wäre ein Timer zu bauen. Gebraucht wurde das Gegenteil: **kein** Timer. Wer die
|
||||||
|
Zeit als Zeitstempel in die Datenbank schreibt und jede Ansicht selbst rechnen
|
||||||
|
lässt, hat nichts, was einen Reload nicht überlebt. Der teuerste Posten der
|
||||||
|
Schätzung war der billigste der Umsetzung, weil die richtige Entscheidung ihn
|
||||||
|
auflöst statt ihn zu lösen.
|
||||||
|
|
||||||
|
Auch Socket.IO fiel weg. Es geht nur in eine Richtung — der Server meldet eine
|
||||||
|
Änderung, der Browser holt den Stand über den Weg, den es ohnehin gibt. Ein
|
||||||
|
Route Handler mit Server-Sent Events ist siebzig Zeilen und braucht kein
|
||||||
|
verändertes Deployment.
|
||||||
|
|
||||||
|
Teurer war das, was in keiner Zeile der Aufstellung steht: **die Frage, was
|
||||||
|
passiert, wenn nichts passiert.** Ein abgerissener Ereignisstrom meldet sich
|
||||||
|
nicht — `EventSource` schweigt einfach. Eine Anzeige, die deshalb weiter
|
||||||
|
„Verbunden" behauptet, ist gefährlicher als eine, die gar nichts anzeigt: sie
|
||||||
|
lässt einen alten Stand wie einen aktuellen aussehen. Aufgefallen ist das nur,
|
||||||
|
weil der E2E-Test das Netz gekappt und danach nachgesehen hat — die 2,0 PT für
|
||||||
|
„Tests: Reload und Verbindungsabbruch überstehen" waren wieder der
|
||||||
|
bestangelegte Posten der Phase.
|
||||||
|
|
||||||
|
Billiger als gedacht war die Tauglichkeit. Nicht das Rechnen — die
|
||||||
|
Entscheidung, **nicht** zu sperren. Damit entfielen alle Sonderfälle, die eine
|
||||||
|
Sperre nach sich zieht: Übersteuern, Begründungspflicht, Freigabe durch wen.
|
||||||
|
Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert.
|
||||||
|
|
||||||
## Phase 6 — Kartenmodule
|
## Phase 6 — Kartenmodule
|
||||||
|
|
||||||
**Vergleichsaufwand: 14–18 PT**
|
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:50 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -189,6 +313,30 @@ Das SVG-Set für die taktischen Zeichen ist Fleißarbeit, aber unvermeidbar —
|
|||||||
fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
|
fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
|
||||||
Physik mit klaren Formeln und deshalb gut testbar.
|
Physik mit klaren Formeln und deshalb gut testbar.
|
||||||
|
|
||||||
|
**Rückblick.** Beide Sätze stimmen. Das Zeichenset war Fleißarbeit — 22 Zeichen
|
||||||
|
als SVG-Pfade, keine davon schwierig, alle zusammen eine Stunde. Und die
|
||||||
|
Wasserförderung war tatsächlich der am besten testbare Teil des ganzen
|
||||||
|
Projekts: 21 Tests, die alle etwas über die Physik aussagen statt über den Code.
|
||||||
|
|
||||||
|
Dabei fiel auf, dass die Beiwerte im ersten Wurf um den Faktor zehn danebenlagen
|
||||||
|
— aufgefallen ist es nur, weil die Tests gegen die **Tabellenwerte der
|
||||||
|
Ausbildung** prüfen und nicht gegen das, was die Funktion gerade ausrechnet.
|
||||||
|
Ein Test, der die eigene Implementierung bestätigt, hätte hier fröhlich grün
|
||||||
|
gemeldet.
|
||||||
|
|
||||||
|
Billiger als angesetzt war die Umkreissuche. 3,0 PT für „Hydranten mit PostGIS,
|
||||||
|
Cluster, Filter, Umkreissuche" — davon entfiel der Löwenanteil auf eine einzige
|
||||||
|
SQL-Abfrage mit `ST_DWithin` und einen GiST-Index. PostGIS war seit Phase 0 im
|
||||||
|
Container; genutzt wurde es erst jetzt.
|
||||||
|
|
||||||
|
Teurer war die Frage, was passiert, wenn es **keine Karte** gibt. Eine MBTiles-
|
||||||
|
Datei des Ausrückegebiets ist hunderte Megabyte groß, gehört niemandem außer
|
||||||
|
der Wehr und kann nicht im Repository liegen. Das heißt: der Normalfall beim
|
||||||
|
ersten Start ist eine Karte ohne Hintergrund — und sie muss trotzdem etwas
|
||||||
|
taugen. Aus dieser Anforderung kam der leere Stil mit Hinweis, die Erkennung
|
||||||
|
eines nicht erreichbaren Servers und die Zusicherung im E2E-Test, dass genau
|
||||||
|
das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
|
||||||
|
|
||||||
## Phase 7 — Benachrichtigungen und Termine
|
## Phase 7 — Benachrichtigungen und Termine
|
||||||
|
|
||||||
**Vergleichsaufwand: 10–13 PT**
|
**Vergleichsaufwand: 10–13 PT**
|
||||||
|
|||||||
@@ -11,7 +11,618 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
|||||||
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
|
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
|
||||||
| **Summe** | **1:00 h** | **1** | |
|
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
|
||||||
|
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
|
||||||
|
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
|
||||||
|
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||||||
|
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||||||
|
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
||||||
|
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
||||||
|
| **Summe** | **16:10 h** | **8** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-04 · Phase 6 — Kartenmodule
|
||||||
|
|
||||||
|
**22:10 – 01:00 · 2:50 h · Version 0.7.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Alles, was einen Ort hat: Löschwasser, Objekte mit Feuerwehrplan, die Lagekarte
|
||||||
|
eines Einsatzes und die geplante Förderstrecke.
|
||||||
|
|
||||||
|
| Bereich | Dateien |
|
||||||
|
|---|---|
|
||||||
|
| Rechnen | `lib/karte/wasserfoerderung.ts`, `hydrant-import.ts`, `taktische-zeichen.ts` |
|
||||||
|
| Server | `server/routers/karte/hydrant.ts`, `objekt.ts`, `lagekarte.ts` |
|
||||||
|
| Karte | `components/karte/karte.tsx` (MapLibre), `api/lagekarte/strom` |
|
||||||
|
| Oberfläche | `app/(app)/karte/` mit Karte, Hydranten, Objekten, Wasserförderung; Lagekarte im Einsatz |
|
||||||
|
| Betrieb | tileserver-gl im Compose-Profil `karte`, `docker/karten/LIESMICH.md` |
|
||||||
|
| Tests | 48 Unit, 11 E2E je Browser |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Kein öffentlicher Kartendienst — auch nicht als Rückfall.** Wo eine Wehr
|
||||||
|
ausrückt und wo ihre Hydranten liegen, ist niemandes Geschäft. Die Kacheln
|
||||||
|
kommen aus dem eigenen tileserver-gl. Ohne ihn zeigt die Karte einen leeren
|
||||||
|
Grund **und sagt das**; Punkte, Entfernungen und Umkreissuche funktionieren
|
||||||
|
weiter. Ein stiller leerer Kasten wäre die schlechtere Lösung gewesen: er sieht
|
||||||
|
aus wie ein Fehler, ohne einer zu sein.
|
||||||
|
|
||||||
|
**Die Umkreissuche gehört in die Datenbank.** `ST_DWithin` auf der Geografie
|
||||||
|
mit GiST-Index rechnet in Metern und nutzt den Index. In der Anwendung wäre es
|
||||||
|
ein vollständiger Tabellendurchlauf mit Grad-Arithmetik — und gefragt wird
|
||||||
|
während der Fahrt.
|
||||||
|
|
||||||
|
**Der Import rät nicht.** Er erkennt viel („DN 100", „48,5031", „24.05.2026",
|
||||||
|
vertauschte Achsen), aber wo die Position fehlt, wird die Zeile übersprungen
|
||||||
|
statt geschätzt. Und ohne Statusangabe steht „ungeprüft" — nicht
|
||||||
|
„einsatzbereit". Eine Liste, die tausend Hydranten als geprüft ausweist, weil
|
||||||
|
die Spalte fehlte, wäre schlimmer als keine Liste.
|
||||||
|
|
||||||
|
**Ein Ereignisstrom für beides.** Die Lagekarte benutzt denselben Hook wie die
|
||||||
|
Atemschutzüberwachung aus Phase 5, nur mit anderer Adresse. Zwei Ströme mit
|
||||||
|
unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||||||
|
verstecken kann.
|
||||||
|
|
||||||
|
**Das Ergebnis der Wasserförderung wird nicht gespeichert.** Nur die Eingaben.
|
||||||
|
Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne falsch
|
||||||
|
auszusehen.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Der Druckverlust lag um den Faktor zehn daneben | Die Beiwerte waren aus dem Kopf gegriffen statt aus der Formel abgeleitet: gebraucht wird `verlust = beiwert × (menge/100)²`, also 0,0234 für den B-Schlauch, nicht 0,23 | Beiwerte aus den Tabellenwerten zurückgerechnet. Aufgefallen ist es nur, weil die Tests gegen die Ausbildungstabelle prüfen und nicht gegen die eigene Implementierung |
|
||||||
|
| Ein nicht erreichbarer Kartenserver ließ die Karte kaputt aussehen | Der Hinweis „kein Hintergrund" hing an `stilUrl === null`, nicht daran, ob der Stil geladen wurde | MapLibre meldet den Fehlschlag; die Karte fällt jetzt in denselben ehrlichen Zustand wie ohne Server |
|
||||||
|
| Die Klicks in die Karte gingen im E2E-Test ins Leere | Die Lagekarte liegt unten auf der Einsatzseite, außerhalb des Fensters. `page.mouse` klickt in Fensterkoordinaten | Karte vor dem Klick ins Bild holen |
|
||||||
|
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1 | DropIndex-Block aus der Migration entfernt, im Kopf vermerkt |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Keine Cluster-Darstellung.** Phase-6.md nennt sie. Bei 24 Löschwasserstellen
|
||||||
|
im Beispiel und einigen hundert in einer echten Wehr sind Cluster eine Lösung
|
||||||
|
für ein Problem, das erst bei Zehntausenden entsteht — und sie verstecken genau
|
||||||
|
das, was man sehen will: die Lücke im Netz. Nachrüstbar, wenn eine Wehr mit
|
||||||
|
großem Ausrückegebiet es vermisst.
|
||||||
|
|
||||||
|
**Kein Höhenprofil aus einem Geländemodell.** Die Höhe je Punkt wird von Hand
|
||||||
|
eingetragen. Ein Höhenmodell (SRTM, DGM) wäre ein weiterer Datensatz von
|
||||||
|
hunderten Megabyte, den jede Wehr selbst beschaffen müsste. Die Rechnung ist
|
||||||
|
ohne Höhe zu günstig — und das steht auch so an der Eingabe.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe
|
||||||
|
- `npm run test` — 354 Tests grün
|
||||||
|
- `npx playwright test tests/e2e/karte.spec.ts` — 22 grün (Desktop und Tablet)
|
||||||
|
- `npm run build` — 44 Routen
|
||||||
|
- `npm run db:seed` — 24 Löschwasserstellen, 3 Objekte, Standorte verortet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-03 · Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
|
||||||
|
**19:40 – 22:20 · 2:40 h · Version 0.6.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Die Atemschutzüberwachung. Sieben Tabellen, ein Ereignisstrom, ein
|
||||||
|
lückenloses Protokoll — und der Hinweis über allem, dass die analoge
|
||||||
|
Überwachung führend bleibt.
|
||||||
|
|
||||||
|
| Bereich | Dateien |
|
||||||
|
|---|---|
|
||||||
|
| Rechnen | `lib/atemschutz/berechnung.ts`, `tauglichkeit.ts`, `verfuegbarkeit.ts` |
|
||||||
|
| Server | `server/routers/atemschutz/ueberwachung.ts`, `traeger.ts` |
|
||||||
|
| Strom | `app/api/atemschutz/strom/route.ts`, `components/atemschutz/stand.ts` |
|
||||||
|
| Protokoll | `lib/atemschutz/protokoll.ts`, `protokoll-erzeugen.ts`, `api/atemschutz/protokoll` |
|
||||||
|
| Oberfläche | `app/(app)/atemschutz/`, `components/atemschutz/` |
|
||||||
|
| Tests | 51 Unit, 17 Datenbank, 8 E2E je Browser |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Server-Sent Events statt Socket.IO.** Phase-5.md nennt Socket.IO; das
|
||||||
|
verlangt in Next.js einen eigenen Node-Server und damit ein anderes
|
||||||
|
Deployment. Gebraucht wird aber nur eine Richtung: der Server meldet „es hat
|
||||||
|
sich etwas geändert", der Browser holt den Stand über tRPC. Ein Route Handler
|
||||||
|
genügt, `EventSource` verbindet nach einem Abbruch von selbst neu, und
|
||||||
|
`next start` bleibt wie es ist. Nach Rücksprache so entschieden.
|
||||||
|
|
||||||
|
**Es gibt keinen Timer.** Kein Zähler, kein Intervall, das Zeit führt. Jede
|
||||||
|
Dauer entsteht aus zwei Zeitstempeln — einem aus der Datenbank, einem von der
|
||||||
|
Uhr. Damit ist die Anforderung „übersteht Reload und Verbindungsabbruch" keine
|
||||||
|
Funktion, die man testen muss, sondern eine Eigenschaft, die sich nicht
|
||||||
|
verlieren lässt. Getestet wird sie trotzdem.
|
||||||
|
|
||||||
|
**Der Rückzugsdruck nach dem doppelten Anmarschverbrauch.** Nach Rücksprache:
|
||||||
|
die Wehr rechnet mit dem tatsächlichen Verbrauch auf dem Hinweg. Solange keine
|
||||||
|
Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt höher, ist also
|
||||||
|
die vorsichtigere Annahme, und das steht auch so auf der Karte.
|
||||||
|
|
||||||
|
**Die Tauglichkeit sperrt niemanden.** Neben jedem Namen steht, ob die Person
|
||||||
|
nach FwDV 7 einsetzbar wäre und was fehlt. Verweigert wird nichts. Ein
|
||||||
|
Programm, das im Einsatz eine Person blockiert, wird umgangen — und dann steht
|
||||||
|
am Ende gar nichts im Protokoll. Der Hinweis nützt, die Sperre schadet.
|
||||||
|
|
||||||
|
**Das Protokoll ist doppelt gesichert.** Entzogene Rechte **und** ein Trigger
|
||||||
|
auf `druckmessung` und `atemschutz_ereignis`. Eine Sperre hätte gereicht; bei
|
||||||
|
einer Aufzeichnung, an der Menschenleben hängen, ist die zweite ihren Preis
|
||||||
|
wert.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Ein gekapptes Netz ließ die Anzeige „Verbunden" melden | `context.setOffline()` trennt einen **offenen** SSE-Strom in Chromium nicht — er liefert weiter. Der Test prüfte damit etwas anderes als gedacht. Schlimmer: bei einem echten Abriss schweigt `EventSource`, ohne einen Fehler zu melden | Der Server schickt alle zwei Sekunden ein Lebenszeichen; bleibt es acht Sekunden aus, gilt die Verbindung als tot. Der Test schneidet den Strom jetzt über eine abgewiesene Route ab, statt das Netz zu kappen |
|
||||||
|
| Prisma meldete den Teilindex nicht beim Namen | Ein `UNIQUE INDEX … WHERE` erscheint als verletzte Eindeutigkeit auf der Spalte | Zusicherung im Test daran angepasst, mit Verweis auf den Index |
|
||||||
|
| Die Bildschirmfotos zeigten Testleichen | 34 Überwachungen aus den E2E-Läufen standen über der einen echten aus dem Seed | Rückstände in der Entwicklungsdatenbank abgeräumt, Auswahl im Skript auf Nicht-Testdaten eingegrenzt. Die Ursache bleibt: die E2E-Suite schreibt in die Demowehr |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Kein Socket.IO.** Siehe oben — nach Rücksprache durch Server-Sent Events
|
||||||
|
ersetzt. Die Roadmap ist entsprechend berichtigt.
|
||||||
|
|
||||||
|
**Kein RFID-Scan beim Truppanlegen.** Phase-5.md nennt „Truppanlage per
|
||||||
|
RFID-Scan oder Auswahl". Umgesetzt ist die Auswahl. Der Kartenleser hängt am
|
||||||
|
Terminal im Gerätehaus (Phase 4) und meldet dort Personen an; am
|
||||||
|
Überwachungsschirm steht der Überwacher mit einem Tablet und wählt zwei Namen
|
||||||
|
aus einer Liste, auf der ohnehin die Tauglichkeit steht. Der Scan wäre dort
|
||||||
|
ein zweiter Weg zum selben Ziel — nachrüstbar, wenn er im Betrieb vermisst
|
||||||
|
wird.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe
|
||||||
|
- `npm run test` — 306 Tests grün, davon 36 gegen eine echte Datenbank
|
||||||
|
- `npx playwright test tests/e2e/atemschutz.spec.ts` — 16 grün (Desktop und Tablet)
|
||||||
|
- `npm run db:seed` — Nachweise und eine abgeschlossene Überwachung angelegt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-03 · Phase 4 — Kiosk-PWA im Gerätehaus
|
||||||
|
|
||||||
|
**14:40 – 17:40 · 3:00 h · Version 0.5.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Das Terminal an der Wand. Sieben neue Tabellen, eine eigene Oberfläche unter
|
||||||
|
`/kiosk`, ein Service Worker und eine Warteschlange, die den Betrieb ohne Netz
|
||||||
|
trägt.
|
||||||
|
|
||||||
|
| Bereich | Dateien |
|
||||||
|
|---|---|
|
||||||
|
| Datenmodell | `Fahrt`, `Anwesenheit`, `Waescheabgabe`, `Inventurgang`, `Inventurposten`, `KioskVorgang`, `SyncKonflikt` |
|
||||||
|
| Offline | `public/sw.js`, `lib/kiosk/outbox.ts`, `components/kiosk/warteschlange.tsx` |
|
||||||
|
| Anmeldung | `lib/kiosk/anmeldemarke.ts`, Anbieter `terminal-marke` in `lib/auth.ts`, `components/kiosk/anmeldung.tsx` |
|
||||||
|
| Oberfläche | `app/kiosk/` mit Kacheln, sechs Wizards, Monitor und Einrichtung |
|
||||||
|
| Verwaltung | `app/(app)/terminal/`, `app/(app)/anmeldemarke/` |
|
||||||
|
| Server | `server/routers/kiosk/kiosk.ts`, `lib/integrations/wetter.ts` |
|
||||||
|
| Tests | `tests/unit/kiosk-marke.test.ts`, `kiosk-outbox.test.ts`, `kiosk-datenbank.test.ts`, `tests/e2e/kiosk.spec.ts` |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Warteschlange ist der einzige Weg, auch online.** Es wäre naheliegend,
|
||||||
|
online direkt zu senden und nur im Fehlerfall zu puffern. Dann gäbe es zwei
|
||||||
|
Wege durch den Code — und der seltenere wäre der ungetestete, also genau der,
|
||||||
|
der im Gerätehaus gebraucht wird. Jetzt läuft alles durch dieselbe Bahn, und
|
||||||
|
der Offline-Fall ist der Normalfall mit längerer Wartezeit.
|
||||||
|
|
||||||
|
**Die Kennung erzeugt der Browser, nicht der Server.** `crypto.randomUUID()`
|
||||||
|
beim Einreihen. Damit ist sie derselbe Wert über beliebig viele Sendeversuche
|
||||||
|
hinweg, und der Server kann einen wiederholten Vorgang erkennen, statt ihn
|
||||||
|
doppelt zu buchen. Die Kennung ist der Primärschlüssel von `kiosk_vorgang`, und
|
||||||
|
die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf —
|
||||||
|
sonst ließe sich die Spur nachträglich glätten.
|
||||||
|
|
||||||
|
**Konflikte werden nicht erzwungen und nicht verworfen.** Ein Tablet, das drei
|
||||||
|
Tage offline war, schickt Vorgänge, deren Ziel es nicht mehr gibt oder das
|
||||||
|
inzwischen festgeschrieben ist. Beides ist kein Fehler des Anwenders. Der
|
||||||
|
Vorgang landet in `sync_konflikt`, verschwindet aus der Warteschlange (er ist
|
||||||
|
angekommen) und wartet unter `/terminal` auf eine Entscheidung.
|
||||||
|
|
||||||
|
**Die Prüfung bleibt online.** Der Wizard aus Phase 2 mit Checkliste, Messwerten,
|
||||||
|
Fotos und Unterschrift lässt sich nicht sinnvoll in eine Warteschlange legen —
|
||||||
|
und ein halb erfasstes Protokoll ist schlechter als keins. Die Kachel zeigt
|
||||||
|
deshalb „braucht Netz" und ist ohne Verbindung gesperrt, statt ins Leere zu
|
||||||
|
führen. `PRUEFUNG_ERFASSEN` steht trotzdem im Datenmodell: die Atemschutzprüfung
|
||||||
|
in Phase 5 ist kurz genug dafür.
|
||||||
|
|
||||||
|
**Die Anmeldemarke wird signiert, nicht gespeichert.** HMAC über Person, Wehr
|
||||||
|
und Zeitstempel mit `AUTH_SECRET`, gültig 60 Sekunden. Keine Tabelle, kein
|
||||||
|
Aufräumjob, kein zweiter Ort mit einem Geheimnis — und ein abfotografierter
|
||||||
|
Code aus der Umkleide ist nach einer Minute wertlos.
|
||||||
|
|
||||||
|
**Der Kartenleser hängt am Fenster, nicht an einem Eingabefeld.** Ein RFID-Leser
|
||||||
|
tippt die Kennung in Millisekunden und schließt mit Enter ab. Wer die Karte
|
||||||
|
auflegt, soll vorher nichts antippen müssen; unterschieden wird am
|
||||||
|
Anschlagstempo (60 ms), nicht am Fokus.
|
||||||
|
|
||||||
|
**Wetterwarnungen sind aus.** Es ist die einzige Verbindung, die FLORIAN von
|
||||||
|
sich aus nach außen aufbaut. Wer sie einschaltet, soll das bewusst tun; ohne
|
||||||
|
`DWD_WARNCELL` passiert nichts, und bei Ausfall bleibt die Kachel leer statt
|
||||||
|
den Monitor aufzuhalten.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Die Warteschlange schickte einen liegen gebliebenen Vorgang nie ein zweites Mal | `jetztSenden` hatte das Mutationsobjekt von React Query in den Abhängigkeiten. Das ist bei jedem Rendern ein neues, also änderte sich die Identität der Funktion dauernd, der Effekt lief neu, zog den 20-Sekunden-Zeitgeber neu auf — und der lief nie ab. Dazu wurde der `online`-Zuhörer ständig neu registriert und verpasste das Ereignis manchmal | Mutation, tRPC-Werkzeuge und das „läuft gerade"-Kennzeichen über Referenzen statt über Abhängigkeiten. Damit ist `jetztSenden` stabil |
|
||||||
|
| Der Produktionsbuild brach bei `/kiosk/einrichten` ab | `useSearchParams()` ohne Suspense-Grenze lässt sich nicht vorab erzeugen | `<Suspense>` um das Formular |
|
||||||
|
| Der Build brach bei `wetter.ts` ab | Der Hallenmonitor ist eine Client-Komponente und importierte die Warnstufentexte aus einem `server-only`-Modul | Texte nach `lib/integrations/wetter-texte.ts`, dieselbe Trennung wie bei `qr.ts` / `qr-parser.ts` |
|
||||||
|
| Der Outbox-Test lief ins Leere | `speicherVerfuegbar()` fragt `window`, und die Testsuite läuft in Node | Diese Datei unter jsdom (`@vitest-environment`) mit `fake-indexeddb` |
|
||||||
|
| Das Einrichtungsformular ließ sich im Test nicht abschicken | Playwright klickte, bevor der Effekt die Felder aus der Adresse gefüllt hatte — das Pflichtfeld war leer | Im Test auf die Feldwerte warten |
|
||||||
|
| Der Idempotenztest sah in der Verwaltung kein Anmeldeformular | Im selben Browserkontext lief noch die Terminalsitzung, und `/anmelden` leitet Angemeldete weiter | Prüfung in einen eigenen Test mit frischem Kontext |
|
||||||
|
| `getByText("B 3 Wohnhausbrand")` in den Einsatztests wurde mehrdeutig | Frühere E2E-Läufe haben Einsätze mit ähnlichem Stichwort in der Demowehr angelegt | `exact: true`; die eigentliche Ursache bleibt offen (siehe unten) |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Kein Auto-Logout nach 90 Sekunden.** Die Roadmap nennt 90 Sekunden, die
|
||||||
|
Konfiguration hatte schon `KIOSK_IDLE_TIMEOUT=120`. Der konfigurierte Wert
|
||||||
|
gewinnt — 90 Sekunden sind knapp, wenn jemand mitten in einer Mangelmeldung
|
||||||
|
noch das Foto machen muss. Die Roadmap ist entsprechend berichtigt.
|
||||||
|
|
||||||
|
**`npm run lint` läuft in diesem Projekt nicht.** Es gibt keine
|
||||||
|
ESLint-Konfiguration; `next lint` fragt interaktiv nach einer. Das ist älter als
|
||||||
|
diese Phase und in ihr nicht behoben — es würde quer über alle bisherigen
|
||||||
|
Dateien Befunde erzeugen und gehört in einen eigenen Schritt.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe
|
||||||
|
- `npm run test` — 258 Tests grün, davon 19 gegen eine echte Datenbank
|
||||||
|
- `npx playwright test tests/e2e/kiosk.spec.ts` — 18 Tests grün (Desktop und Tablet)
|
||||||
|
- `npm run build` — 34 Routen, 103 kB gemeinsames JavaScript
|
||||||
|
- `npm run db:seed` — Fahrtenbuch, Anwesenheiten und Wäsche angelegt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-03 · Phase 3 — Einsätze und Übungen
|
||||||
|
|
||||||
|
**12:25 – 14:35 · 2:10 h · Version 0.4.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Ab hier dokumentiert die Wehr, was sie tut. Acht neue Tabellen, ein öffentlich
|
||||||
|
erreichbares Portal und die Zahlen, nach denen die Gemeinde fragt.
|
||||||
|
|
||||||
|
**Schnellerfassung.** Im Alarmfall fragt FLORIAN drei Dinge: Stichwort, Ort,
|
||||||
|
welche Fahrzeuge raus sind. Alarmzeit ist der Moment des Anlegens. Alles
|
||||||
|
Weitere wird nachgetragen, wenn wieder Ruhe ist.
|
||||||
|
|
||||||
|
**Zeitstempel und Kennzahlen.** Sechs Stempel, je ein Knopf. Daraus rechnet
|
||||||
|
sich Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
|
||||||
|
|
||||||
|
**Stärke nach FwDV 3.** Aus den Funktionen der Mannschaft, in der üblichen
|
||||||
|
Schreibweise, mit Zuordnung zur taktischen Einheit.
|
||||||
|
|
||||||
|
**Einsatzbericht.** Als PDF über Gotenberg, mit der Freigabe festgeschrieben.
|
||||||
|
Dazu der Jahresexport quer gedruckt für die Gemeinde.
|
||||||
|
|
||||||
|
**Statistik.** Kategorien nach Landesstatistik, Verteilung der Hilfsfristen mit
|
||||||
|
dem 80-Prozent-Wert, Beteiligung je Person, Auslastung je Fahrzeug.
|
||||||
|
|
||||||
|
**Portal für Fremdwehren.** Ein signierter Link ohne Anmeldung: Stand sehen,
|
||||||
|
Lagemeldung mit Foto absetzen. Zeitlich begrenzt, mengenbegrenzt, widerrufbar.
|
||||||
|
|
||||||
|
**Übungen.** Thema, Lernziele, Anwesenheit zum Abhaken — die Grundlage für die
|
||||||
|
Teilnahmequote in der Jahreshauptversammlung.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Hilfsfrist wird als 80-Prozent-Wert ausgewiesen, nicht als Durchschnitt.**
|
||||||
|
Der Durchschnitt sagt bei Eintreffzeiten nichts: ein einzelner Einsatz auf der
|
||||||
|
Autobahn zieht ihn nach oben, während neunzig Prozent der Fälle im Ort liegen.
|
||||||
|
In Bayern gilt als Ziel, in 80 Prozent der Fälle binnen zehn Minuten da zu
|
||||||
|
sein — also ist genau das die Zahl.
|
||||||
|
|
||||||
|
**Hinweise statt Sperren bei Zeiten und Stärke.** Eine verdrehte Reihenfolge
|
||||||
|
oder eine fehlende Führungskraft wird angezeigt, aber nicht verhindert.
|
||||||
|
Einsätze werden Tage später aus dem Gedächtnis nachgetragen; ein Einsatz mit
|
||||||
|
unsicheren Zeiten ist immer noch besser als einer, den niemand erfasst, weil
|
||||||
|
das Formular ihn nicht angenommen hat.
|
||||||
|
|
||||||
|
**Der freigegebene Bericht ist unveränderbar — Lagemeldungen nicht.** Der
|
||||||
|
Bericht geht an die Gemeinde, die Meldungen sind das Protokoll des Verlaufs.
|
||||||
|
Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
|
||||||
|
geschrieben ist; sie nachträglich abzuweisen wäre falsch.
|
||||||
|
|
||||||
|
**Das Portal zeigt bewusst wenig.** Stichwort, Ort, Zeitstempel und Meldungen —
|
||||||
|
also das, was an der Einsatzstelle ohnehin jeder sieht. Keine Namen von
|
||||||
|
Einsatzkräften, kein Bericht, keine Navigation. Der Link ist ein Fenster auf
|
||||||
|
einen Einsatz, keine Tür in die Anwendung.
|
||||||
|
|
||||||
|
**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 wäre jede Schreiboperation Roh-SQL geworden, und ein über einen anderen
|
||||||
|
Weg angelegter Einsatz fehlte später auf der Karte.
|
||||||
|
|
||||||
|
**Geocoding nur gegen einen eigenen Nominatim.** Einsatzadressen sagen, wo es
|
||||||
|
gebrannt hat. Solche Anfragen gehören nicht an einen fremden Dienst — und die
|
||||||
|
Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin nicht.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Prisma wollte wieder die Suchindizes löschen | wie in Phase 2: gin_trgm und gist kennt das Schema nicht | DROP INDEX-Zeilen aus der erzeugten Migration entfernt, mit Kommentar |
|
||||||
|
| Der Prisma-Client ließ sich nicht neu erzeugen | die Engine-DLL war vom laufenden Entwicklungsserver gesperrt | Server und Worker beenden, dann erzeugen — unter Windows unvermeidlich |
|
||||||
|
| Die Anwesenheitsliste blieb leer | sie forderte 300 Mitglieder an, erlaubt sind 200 je Seite | auf 200 gesetzt; aufgefallen in der Server-Ausgabe während der E2E-Tests |
|
||||||
|
| Zwei E2E-Tests schlugen im Gesamtlauf fehl, einzeln aber nicht | die erste Übersetzung einer Route dauert unter Last länger als das Erwartungslimit | Limit von 15 auf 25 Sekunden — Wartezeit des Entwicklungsservers, kein Fehler der Anwendung |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Keine.** Alle acht Tabellen tragen `organisationId` und stehen unter Row
|
||||||
|
Level Security, jede schreibende Operation protokolliert, gelöscht wird weich.
|
||||||
|
|
||||||
|
Eine Ergänzung: die Portalprozeduren sind die **ersten öffentlich erreichbaren
|
||||||
|
Endpunkte** in FLORIAN. Sie prüfen den Token selbst, laufen für die Suche des
|
||||||
|
Zugangs bewusst ohne Mandantenfilter (begründet im Code) und arbeiten danach im
|
||||||
|
Mandantenkontext der gefundenen Wehr.
|
||||||
|
|
||||||
|
### Offen und bewusst so gelassen
|
||||||
|
|
||||||
|
Die Zuordnung der Funktionen zur Stärke ist **regional unterschiedlich**. Hier
|
||||||
|
zählt der Maschinist zur Mannschaft und nur der Truppführer als Unterführer;
|
||||||
|
eine Gruppe ergibt damit 1/3/5/9. Andere Wehren rechnen anders. Die Zuordnung
|
||||||
|
steht an einer Stelle in `lib/einsatz/staerke.ts` und ist leicht zu ändern —
|
||||||
|
bestätigt ist sie noch nicht.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run test` — 207 Tests grün, davon 32 gegen eine echte Datenbank
|
||||||
|
- `npm run test:e2e` — 95 Tests grün, 2 übersprungen
|
||||||
|
- `npm run build` — 38 Routen
|
||||||
|
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-03 · Phase 2 — Wartungs- und Prüfmanagement
|
||||||
|
|
||||||
|
**09:55 – 12:20 · 2:25 h · Version 0.3.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt. Ab hier
|
||||||
|
ersetzt FLORIAN den Ordner mit den Prüfnachweisen.
|
||||||
|
|
||||||
|
**Datenmodell.** Elf neue Tabellen: Wartungsvorlage und Checklistenpunkt als
|
||||||
|
Regel, Wartungstermin als deren Anwendung auf ein einzelnes Gerät, Prüfung mit
|
||||||
|
Einzelergebnissen und Unterschriften als Nachweis, dazu Mangel, Rückruf mit
|
||||||
|
betroffener Bauart und je Gerät ein Bearbeitungsstand, und schließlich
|
||||||
|
Erinnerung als Nachweis und Sperre gegen Doppelversand in einem.
|
||||||
|
|
||||||
|
**Fristen-Dashboard.** Vier Töpfe nach Dringlichkeit, jede Zahl gleichzeitig
|
||||||
|
ein Filter, darunter die Verteilung nach Fahrzeug, Standort, Gerätetyp oder
|
||||||
|
Zuständigem. Daneben die Lebensdauerüberwachung mit Aussonderungsvorschlag.
|
||||||
|
|
||||||
|
**Prüfwizard.** Ein Punkt je Bildschirm, Foto aus der Kamera, Zeiterfassung im
|
||||||
|
Hintergrund, Unterschrift auf einem Zeichenfeld. Jede Antwort geht sofort zum
|
||||||
|
Server; ein abgebrochener Durchgang wird beim nächsten Öffnen fortgesetzt.
|
||||||
|
|
||||||
|
**Protokoll.** Der Worker rendert über Gotenberg ein PDF mit Kopf, Checkliste,
|
||||||
|
eingebetteten Fotos und Unterschriften und legt es in MinIO ab. Laufende
|
||||||
|
Nummer je Wehr und Jahr.
|
||||||
|
|
||||||
|
**Nächtlicher Lauf.** Termine planen, Gerätestatus nachziehen, erinnern bei
|
||||||
|
T-30, T-7, T-0 und einmal bei Überfälligkeit — als Sammelnachricht je
|
||||||
|
Empfänger.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Unveränderbarkeit sitzt in der Datenbank, nicht im Code.** Ein Trigger
|
||||||
|
weist jede Änderung an einer abgeschlossenen Prüfung ab, auch für den
|
||||||
|
Eigentümer-Zugang. Zulässig bleiben drei Dinge, die das Protokoll ergänzen
|
||||||
|
statt es zu verfälschen: der Wechsel auf „ersetzt", das Nachtragen des PDF,
|
||||||
|
solange keines hinterlegt ist, und `geaendertAm`. Dasselbe gilt für die
|
||||||
|
Bestandteile — sonst ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||||||
|
der Einzelergebnisse verändern.
|
||||||
|
|
||||||
|
Der Grund ist derselbe wie beim Audit-Log in Phase 0: ein vergessener
|
||||||
|
Zustandscheck in einem Router reicht sonst, um den Nachweis wertlos zu machen.
|
||||||
|
|
||||||
|
**„Überfällig" wird gerechnet, nicht gespeichert.** Ein gespeicherter Zustand
|
||||||
|
läuft der Uhr hinterher — ein Termin, der heute Nacht fällig geworden ist,
|
||||||
|
stünde bis zum nächsten Lauf als „offen" da. Aus demselben Grund kennt
|
||||||
|
`WartungsterminStatus` nur offen, erledigt und entfallen.
|
||||||
|
|
||||||
|
**Pflichtpunkt heißt: davon hängt die Einsatzbereitschaft ab.** Ein
|
||||||
|
beanstandeter Pflichtpunkt bedeutet „nicht bestanden" und sperrt das Gerät, ein
|
||||||
|
beanstandeter Kann-Punkt „bestanden mit Mängeln". Damit ist das Ergebnis eine
|
||||||
|
fachliche Aussage und keine Zählübung. Übersteuern darf der Prüfer trotzdem —
|
||||||
|
er trägt die Verantwortung, nicht die Software.
|
||||||
|
|
||||||
|
**Bestanden hebt eine bestehende Sperre nicht auf.** Wer ein Gerät gesperrt
|
||||||
|
hat, hatte einen Grund, und der kann außerhalb dieser Prüfung liegen. Von
|
||||||
|
selbst löst sich nur „Prüfung fällig".
|
||||||
|
|
||||||
|
**Melden darf jedes Mitglied.** Der Mangel fällt dem auf, der das Gerät
|
||||||
|
benutzt. Eine Hürde beim Melden führt dazu, dass gar nicht gemeldet wird — und
|
||||||
|
ein nicht gemeldeter Riss im Schlauch ist gefährlicher als eine Fehlmeldung,
|
||||||
|
die jemand wieder verwirft.
|
||||||
|
|
||||||
|
**Die Rückrufsuche ist ein Vorschlag.** Seriennummern werden als Text
|
||||||
|
verglichen; das stimmt bei gleicher Stellenzahl und sonst nicht immer. Eine
|
||||||
|
automatische Sperre auf dieser Grundlage wäre gefährlicher als hilfreich.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Prisma wollte die Suchindizes aus Phase 1 löschen | gin_trgm- und gist-Indizes kennt das Schema nicht | die `DROP INDEX`-Zeilen aus der erzeugten Migration entfernt, mit Kommentar für den nächsten, der sie neu erzeugt |
|
||||||
|
| Der Seed brach beim Beispielprotokoll ab | er legte die Prüfung fertig an, und der Trigger verbot die Ergebnisse dazu | erst Entwurf, dann abschließen — genau wie im Wizard |
|
||||||
|
| Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer | die Nummer kam aus einer Zählung ohne Sperre | Transaktionssperre je Wehr und Jahr, Nummer aus dem Höchstwert statt aus der Anzahl |
|
||||||
|
| Der Worker startete außerhalb des Containers nicht | `.env` wurde nicht geladen, und `server-only` wirft unter Node | `dotenv/config` und `--conditions=react-server` in beiden Startbefehlen |
|
||||||
|
| Der Wizard-Test klickte ins Leere | die Mauskoordinaten beziehen sich auf das Fenster, das Zeichenfeld lag darunter | vor dem Zeichnen ins Bild holen |
|
||||||
|
| Zwei E2E-Projekte teilten sich denselben Prüfentwurf | Desktop und Tablet laufen parallel unter demselben Benutzer | je Projekt ein eigenes Gerät |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Keine.** Alle elf Tabellen tragen `organisationId` und stehen unter Row Level
|
||||||
|
Security, jede schreibende Operation protokolliert, gelöscht wird weich.
|
||||||
|
|
||||||
|
Eine Ergänzung zur Regel „Prüfdaten werden nie hart gelöscht": ein
|
||||||
|
**Prüfentwurf** darf verschwinden. Er ist noch kein Nachweis, sondern ein
|
||||||
|
abgebrochener Vorgang; ihn aufzuheben würde die Liste der offenen Prüfungen
|
||||||
|
zumüllen. Ab dem Abschluss greift die Sperre.
|
||||||
|
|
||||||
|
### Was aufgefallen ist
|
||||||
|
|
||||||
|
Der Navigationseintrag „Protokoll" (`/protokoll`) zeigt seit Phase 1 auf eine
|
||||||
|
Seite, die es nicht gibt. Das Audit-Log ist über tRPC abrufbar, hat aber keine
|
||||||
|
Ansicht. Gehört in ein eigenes Issue.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run test` — 161 Tests grün, davon 18 gegen eine echte Datenbank
|
||||||
|
- `npm run test:e2e` — 71 Tests grün, 2 begründet übersprungen (Desktop und Tablet)
|
||||||
|
- `npm run build` — 30 Routen
|
||||||
|
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund; Worker
|
||||||
|
richtet die Zeitpläne ein und erzeugt die Protokoll-PDFs in gut einer Sekunde
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-03 · Phase 1 — Nachtrag: Geräteformular
|
||||||
|
|
||||||
|
**08:30 – 09:20 · 0:50 h**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Die letzte offene Lücke aus Phase 1: ein Formular zum Anlegen und Bearbeiten
|
||||||
|
eines einzelnen Geräts. Bis dahin konnte man Geräte nur über den Import
|
||||||
|
erfassen — für einen Bestand richtig, für den einen nachbeschafften Gerät
|
||||||
|
umständlich.
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `components/stammdaten/geraet-formular.tsx` | das Formular, geteilt von Anlegen und Bearbeiten |
|
||||||
|
| `components/stammdaten/geraet-formular-werte.ts` | Typ und Vorgabewerte, bewusst ohne `"use client"` |
|
||||||
|
| `components/ui/textarea.tsx` | fehlender Baustein |
|
||||||
|
| `app/(app)/geraete/neu/page.tsx` | Anlegen |
|
||||||
|
| `app/(app)/geraete/[id]/bearbeiten/page.tsx` | Bearbeiten |
|
||||||
|
| `tests/e2e/geraet-formular.spec.ts` | 7 Tests |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Lagerung ist ein Umschalter, keine zwei Auswahlfelder.** Standort und
|
||||||
|
Fahrzeug schließen sich aus. Zwei nebeneinanderstehende Felder laden dazu ein,
|
||||||
|
beide zu füllen; die Datenbank weist das ab, aber eine Fehlermeldung nach dem
|
||||||
|
Absenden ist schlechter als ein Formular, in dem der Fehler gar nicht möglich
|
||||||
|
ist. Beim Wechsel des Fahrzeugs wird der Geräteraum geleert — er gehört zum
|
||||||
|
Fahrzeug.
|
||||||
|
|
||||||
|
**Die Fristen werden schon beim Tippen vorgerechnet.** Wer ein Prüfintervall
|
||||||
|
einträgt, sieht sofort, wann die nächste Prüfung fällig wird. Gerechnet wird
|
||||||
|
mit denselben Funktionen wie auf dem Server; beim Speichern rechnet der Server
|
||||||
|
erneut und behält das letzte Wort. Die Vorschau ist Anzeige, keine Quelle.
|
||||||
|
|
||||||
|
**Bei nur einem Standort wird er vorbelegt.** Das ist bei den meisten Wehren
|
||||||
|
der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Formular stürzte beim ersten Rechnen ab | `LEERES_GERAET` lag im `"use client"`-Modul; eine Server-Komponente bekommt beim Import nur eine Client-Referenz, das Spreaden ergibt `undefined` | Werte in ein eigenes Modul ohne `"use client"` |
|
||||||
|
| E2E fand die Felder nicht | `getByLabel` vergleicht den Text des `<label>`, nicht den zugänglichen Namen — am Pflichtfeld steht dort ein Sternchen | Tests suchen über die Rolle; das Sternchen bekam zusätzlich `aria-hidden`, weil es sonst vorgelesen wird |
|
||||||
|
| `fill()` blieb bei Datumsfeldern wirkungslos | Chromium nimmt bei `input[type=date]` keinen gesetzten Wert über `fill()` an | Datum wird getippt, wie es ein Mensch auch täte |
|
||||||
|
| Zwei Tests scheiterten unter Touch-Emulation | dort öffnet Chromium den Auswähler des Betriebssystems statt Zeichen anzunehmen | begründet übersprungen; die Rechnung selbst deckt der Unit-Test ab |
|
||||||
|
| Anmeldung im Screenshot-Skript lief ins Zeitlimit | bei kaltem `.next` übersetzt `next dev` die Route erst beim ersten Aufruf | Zeitlimit von 20 auf 90 Sekunden |
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run test` — 104 Tests grün
|
||||||
|
- `npm run test:e2e` — 56 grün, 2 begründet übersprungen
|
||||||
|
- `npm run build` — 19 Routen
|
||||||
|
- Testgeräte aus dem E2E-Lauf wurden aus der Entwicklungsdatenbank entfernt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-03 · Phase 1 — Stammdaten
|
||||||
|
|
||||||
|
**07:10 – 08:25 · 1:15 h · Version 0.2.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
41 neue Quelldateien, der Umfang wächst von rund 6.100 auf 15.600 Zeilen.
|
||||||
|
|
||||||
|
| Bereich | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Datenmodell | 5 neue Tabellen (Standort, Geraeteraum, Geraetetyp, Qualifikationstyp, Qualifikation), Fahrzeug und Geraet deutlich erweitert, 2 Migrationen |
|
||||||
|
| Fachlogik | `fristen.ts` (Prüftermine, Lebensdauer, Statusableitung), `qr.ts` und `qr-parser.ts`, `etiketten.ts`, `pdf.ts`, `import.ts` und `import-felder.ts` |
|
||||||
|
| Server | 8 tRPC-Router mit Liste, Detail, Anlegen, Ändern, Massenbearbeitung und Verlauf |
|
||||||
|
| Oberfläche | Geräteliste mit Suche und Spaltenauswahl, Gerätedetail mit QR, Fahrzeugliste und -detail mit Beladung, Mitgliederliste, Mitgliedsdetail, Qualifikationen, Import-Assistent, QR-Scanner |
|
||||||
|
| Daten | Seed erweitert: 3 Standorte, 20 Geräteräume, 14 Gerätetypen, 49 Geräte, 14 Qualifikationstypen, 107 Qualifikationen |
|
||||||
|
| Tests | 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10) |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Der Gerätetyp ist Vorgabe, das Gerät darf abweichen.** Vierzig Pressluftatmer
|
||||||
|
haben dasselbe Prüfintervall — das gehört einmal an den Typ und nicht
|
||||||
|
vierzigmal an das Gerät. Wo der Hersteller für eine Charge etwas anderes
|
||||||
|
vorschreibt, gewinnt der Wert am Gerät. Umgesetzt in `effektiverWert()`, das
|
||||||
|
bewusst zwischen `null` und `0` unterscheidet: ein Intervall von 0 ist eine
|
||||||
|
Angabe, kein fehlender Wert.
|
||||||
|
|
||||||
|
**Ein Gerät liegt entweder an einem Standort oder auf einem Fahrzeug.** Beides
|
||||||
|
gleichzeitig wäre eine Falschangabe, keines von beidem auch. Das steht als
|
||||||
|
Prüfbedingung in der Datenbank, nicht nur im Formular — und ein Trigger prüft
|
||||||
|
zusätzlich, dass ein angegebener Geräteraum wirklich zu dem angegebenen
|
||||||
|
Fahrzeug gehört. Das ließe sich als CHECK nicht ausdrücken.
|
||||||
|
|
||||||
|
**Der QR-Code enthält die Inventarnummer, nicht die UUID.** Eine UUID wäre
|
||||||
|
36 Zeichen lang; der Code bekäme ein feines Muster, und auf einem 38 × 21 mm
|
||||||
|
großen Etikett im staubigen Geräteraum wird das unzuverlässig. Die
|
||||||
|
Inventarnummer steht ohnehin lesbar daneben, und wer den Code mit einem
|
||||||
|
beliebigen Scanner liest, sieht sofort, um welches Gerät es geht.
|
||||||
|
|
||||||
|
**Der Import liest die Datei zweimal.** Die Vorschau zeigt, was passieren
|
||||||
|
würde; beim Übernehmen wird die Datei erneut hochgeladen und serverseitig neu
|
||||||
|
eingelesen. Es wandert kein einziger Wert aus dem Browser in die Datenbank.
|
||||||
|
Der Preis ist eine zweite Übertragung — bei den Größen, um die es geht, kein
|
||||||
|
Thema.
|
||||||
|
|
||||||
|
**Der Etikettenbogen kann an beliebiger Position beginnen.** Wer drei Etiketten
|
||||||
|
braucht, soll nicht einen ganzen Bogen opfern.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Fristen um einen Tag verschoben | `startOfDay` aus date-fns rechnet in der Zeitzone des Servers; ein DATE aus der Datenbank kommt als UTC-Mitternacht und landete dadurch auf dem Vortag | eigene Kalenderarithmetik auf UTC-Bestandteilen, „heute“ über Europe/Berlin bestimmt |
|
||||||
|
| `ctx.sitzung` blieb nullbar | `t.middleware()` erzeugt eine Middleware auf dem Wurzelkontext; die Verengung aus der vorigen Middleware ging verloren | Middleware direkt als Funktion an `.use()` übergeben |
|
||||||
|
| Import-Tests brachen beim Laden ab | `import "server-only"` wirft beim blossen Import, und Vitest ist weder Server noch Browser | im Testlauf durch ein leeres Modul ersetzt (`tests/stubs/server-only.ts`) |
|
||||||
|
| 15 E2E-Tests im Zeitlimit | `next dev` übersetzt jede Route erst beim ersten Aufruf; zu viele parallele Erstaufrufe | Worker auf 4 begrenzt, Zeitlimit angehoben |
|
||||||
|
| 5 weitere E2E-Tests rot | `CardTitle` war ein `div` und damit keine Überschrift | in der Komponente auf ein echtes `h3` umgestellt — behebt zugleich einen Zugänglichkeitsmangel |
|
||||||
|
| Erste Bilder zeigten Ladebalken | „networkidle“ greift zu früh: die Listen holen ihre Daten erst nach, wenn React im Browser gelaufen ist | Screenshot-Skript wartet, bis keine Ladeanzeige mehr im Baum ist |
|
||||||
|
| Demodaten inkonsistent | die 40 Geräte aus Phase 0 hatten keinen Gerätetyp, und der Seed ist idempotent | Geräte-Zeilen der Entwicklungsdatenbank gezielt geleert und neu eingespielt |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**`prisma migrate reset` wurde nicht ausgeführt.** Der naheliegende Weg, die
|
||||||
|
Demodaten sauber neu aufzubauen, verlangt eine ausdrückliche Freigabe — Prisma
|
||||||
|
verweigert den Befehl gegenüber einem KI-Agenten ohne Zustimmung. Statt zu
|
||||||
|
fragen wurde der schmalere Weg gewählt: nur die Tabelle `geraet` der
|
||||||
|
Entwicklungsdatenbank geleert und neu eingespielt.
|
||||||
|
|
||||||
|
**Kein eigenes Formular zum Anlegen von Geräten.** Die Router können anlegen,
|
||||||
|
ändern, umlagern und aussondern; die Oberfläche dazu deckt Liste, Detail,
|
||||||
|
Massenbearbeitung und Import ab. Ein einzelnes Anlegeformular fehlt noch — in
|
||||||
|
der Praxis kommen Bestände als Tabelle herein, und einzelne Neuzugänge lassen
|
||||||
|
sich bis dahin über den Import erfassen. Nachzuholen in Phase 2, wo ohnehin
|
||||||
|
Formulare für Prüfungen entstehen.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run test` — 104 Tests grün, davon 8 gegen eine echte Datenbank
|
||||||
|
- `npm run test:e2e` — 44 Tests grün (Desktop und Tablet)
|
||||||
|
- `npm run build` — 17 Routen, 103 kB gemeinsames JavaScript
|
||||||
|
- `npm run screenshots` — 18 Bilder, hell und dunkel
|
||||||
|
- Keine Konsolenfehler und keine Hydratationswarnungen im Browser
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
|
|||||||
|
|
||||||
```
|
```
|
||||||
Phase 0 ████████████████████ fertig 0.1.0
|
Phase 0 ████████████████████ fertig 0.1.0
|
||||||
Phase 1 ░░░░░░░░░░░░░░░░░░░░ offen 0.2.0
|
Phase 1 ████████████████████ fertig 0.2.0
|
||||||
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0
|
Phase 2 ████████████████████ fertig 0.3.0
|
||||||
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
|
Phase 3 ████████████████████ fertig 0.4.0
|
||||||
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
|
Phase 4 ████████████████████ fertig 0.5.0
|
||||||
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
|
Phase 5 ████████████████████ fertig 0.6.0
|
||||||
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
Phase 6 ████████████████████ fertig 0.7.0
|
||||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
||||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
||||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
||||||
@@ -39,7 +39,7 @@ Alles, worauf der Rest aufsetzt.
|
|||||||
- Basis-Layout mit Seitenleiste und dunkler Darstellung
|
- Basis-Layout mit Seitenleiste und dunkler Darstellung
|
||||||
- Seed mit einer vollständigen Beispielwehr
|
- Seed mit einer vollständigen Beispielwehr
|
||||||
|
|
||||||
## Phase 1 — Stammdaten · als Nächstes
|
## Phase 1 — Stammdaten · fertig
|
||||||
|
|
||||||
Die Wehr trägt ein, was sie hat.
|
Die Wehr trägt ein, was sie hat.
|
||||||
|
|
||||||
@@ -53,27 +53,37 @@ Die Wehr trägt ein, was sie hat.
|
|||||||
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
|
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
|
||||||
- Gerätehistorie aus dem Audit-Log
|
- Gerätehistorie aus dem Audit-Log
|
||||||
- Qualifikationen mit Ablaufwarnung auf der Übersicht
|
- Qualifikationen mit Ablaufwarnung auf der Übersicht
|
||||||
|
- Formular zum Anlegen und Bearbeiten eines einzelnen Geräts, mit
|
||||||
|
Fristenvorschau beim Ausfüllen
|
||||||
|
|
||||||
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
|
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
|
||||||
QR-Code am Regal wiederfinden.
|
QR-Code am Regal wiederfinden.
|
||||||
|
|
||||||
## Phase 2 — Wartung und Prüfung
|
## Phase 2 — Wartung und Prüfung · fertig
|
||||||
|
|
||||||
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
|
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
|
||||||
|
|
||||||
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
|
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
|
||||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage
|
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage,
|
||||||
- Nächtlicher Job erzeugt Termine, setzt Status, erinnert bei T-30, T-7 und T-0
|
gebündelt nach Fahrzeug, Standort, Gerätetyp oder Zuständigem
|
||||||
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift
|
- Nächtlicher Lauf erzeugt Termine, zieht den Gerätestatus nach und erinnert bei
|
||||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO
|
T-30, T-7, T-0 und einmal bei Überfälligkeit
|
||||||
|
- Prüfwizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift auf
|
||||||
|
dem Zeichenfeld
|
||||||
|
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO — die Sperre setzt
|
||||||
|
ein Datenbanktrigger durch, nicht der Anwendungscode
|
||||||
|
- Korrekturen erzeugen ein neues Protokoll mit Verweis auf das alte
|
||||||
- Lebensdauerüberwachung mit Aussonderungsvorschlag
|
- Lebensdauerüberwachung mit Aussonderungsvorschlag
|
||||||
- Rückrufe markieren betroffene Geräte automatisch
|
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben
|
und Herstellungszeitraum
|
||||||
|
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben, mit
|
||||||
|
Gerätesperre bei kritischen Mängeln
|
||||||
|
- Externe Wartung: Fachfirma, Kosten und Fremdprotokoll als PDF
|
||||||
|
|
||||||
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
|
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
|
||||||
die Unfallkasse standhält.
|
die Unfallkasse standhält.
|
||||||
|
|
||||||
## Phase 3 — Einsätze und Übungen
|
## Phase 3 — Einsätze und Übungen · fertig
|
||||||
|
|
||||||
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
|
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
|
||||||
Einsatzende, eingerückt, einsatzbereit
|
Einsatzende, eingerückt, einsatzbereit
|
||||||
@@ -87,28 +97,35 @@ die Unfallkasse standhält.
|
|||||||
|
|
||||||
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
|
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
|
||||||
|
|
||||||
## Phase 4 — Kiosk-PWA
|
## Phase 4 — Kiosk-PWA · fertig
|
||||||
|
|
||||||
Für die Wandtablets in der Fahrzeughalle.
|
Für die Wandtablets in der Fahrzeughalle.
|
||||||
|
|
||||||
- Eigenes Layout: große Kacheln, mindestens 64px, mit Handschuhen bedienbar
|
- Eigenes Layout: große Kacheln, mindestens 64px, mit Handschuhen bedienbar
|
||||||
- Anmeldung per RFID, PIN oder QR vom Handy, Auto-Logout nach 90 Sekunden
|
- Anmeldung per RFID, PIN oder QR vom Handy, Abmeldung nach Untätigkeit
|
||||||
- Wizards: Wartung, Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit
|
(`KIOSK_IDLE_TIMEOUT`, Vorgabe 120 Sekunden)
|
||||||
|
- Wizards: Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit. Die
|
||||||
|
Prüfung bleibt beim Wizard aus Phase 2 — Checkliste, Fotos und Unterschrift
|
||||||
|
lassen sich nicht sinnvoll in eine Warteschlange legen; die Kachel sagt
|
||||||
|
„braucht Netz"
|
||||||
- Hallen-Monitor: Vollbild mit Terminen, überfälligen Prüfungen,
|
- Hallen-Monitor: Vollbild mit Terminen, überfälligen Prüfungen,
|
||||||
Fahrzeugstatus, Wetterwarnung
|
Fahrzeugstatus, Wetterwarnung
|
||||||
- Offline-Betrieb mit IndexedDB-Outbox und Synchronisation bei Reconnect
|
- Offline-Betrieb mit IndexedDB-Outbox, Idempotenzschlüssel und
|
||||||
|
Konfliktliste für alles, was sich beim Nachtragen nicht anwenden lässt
|
||||||
|
|
||||||
**Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den
|
**Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den
|
||||||
Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist.
|
Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist.
|
||||||
|
|
||||||
## Phase 5 — Atemschutz nach FwDV 7
|
## Phase 5 — Atemschutz nach FwDV 7 · fertig
|
||||||
|
|
||||||
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
|
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
|
||||||
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
|
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
|
||||||
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
||||||
|
|
||||||
- Truppanlage per RFID, Eingangsdruck erfassen
|
- Truppanlage per RFID, Eingangsdruck erfassen
|
||||||
- Serverseitig autoritative Timer, per Socket.IO verteilt
|
- Serverseitig autoritative Zeitstempel; verteilt über Server-Sent Events
|
||||||
|
statt Socket.IO — es geht nur in eine Richtung, und so bleibt es bei
|
||||||
|
`next start` ohne eigenen Node-Server
|
||||||
- Rückzugsdruck-Berechnung mit Ampel und akustischem Alarm
|
- Rückzugsdruck-Berechnung mit Ampel und akustischem Alarm
|
||||||
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
|
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
|
||||||
- Mayday-Knopf je Trupp
|
- Mayday-Knopf je Trupp
|
||||||
@@ -118,19 +135,20 @@ eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
|||||||
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
|
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
|
||||||
analogen, bis die Wehr ausdrücklich anders entscheidet.
|
analogen, bis die Wehr ausdrücklich anders entscheidet.
|
||||||
|
|
||||||
## Phase 6 — Kartenmodule
|
## Phase 6 — Kartenmodule · fertig
|
||||||
|
|
||||||
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
|
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
|
||||||
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
||||||
GeoJSON und Stadtwerke-CSV
|
GeoJSON und Stadtwerke-CSV
|
||||||
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar
|
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar
|
||||||
- Lagekarte mit taktischen Zeichen nach FwDV 102, Echtzeit-Sync
|
- Lagekarte mit taktischen Zeichen nach FwDV 102, Abgleich über denselben
|
||||||
|
Ereignisstrom wie der Atemschutz
|
||||||
- Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
|
- Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
|
||||||
|
|
||||||
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
||||||
und was ihn hergibt.
|
und was ihn hergibt.
|
||||||
|
|
||||||
## Phase 7 — Benachrichtigungen und Termine
|
## Phase 7 — Benachrichtigungen und Termine · als Nächstes
|
||||||
|
|
||||||
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
||||||
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 830 KiB |
|
After Width: | Height: | Size: 846 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -8,6 +8,9 @@ const nextConfig: NextConfig = {
|
|||||||
images: { remotePatterns: [] },
|
images: { remotePatterns: [] },
|
||||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
|
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
// Die Entwicklungsanzeige unten links wuerde in den Dokumentationsbildern
|
||||||
|
// mitlaufen. Sie betrifft ohnehin nur "next dev".
|
||||||
|
devIndicators: false,
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.1.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"dev": "next dev -p 3100",
|
"dev": "next dev -p 3100",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3100",
|
"start": "next start -p 3100",
|
||||||
"worker": "tsx src/worker/index.ts",
|
"worker": "tsx --conditions=react-server src/worker/index.ts",
|
||||||
"worker:dev": "tsx watch src/worker/index.ts",
|
"worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -23,31 +23,46 @@
|
|||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"screenshots": "tsx scripts/screenshots.ts"
|
"screenshots": "tsx scripts/screenshots.ts",
|
||||||
|
"icons": "tsx scripts/icons.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tanstack/react-table": "^9.2.4",
|
||||||
"@trpc/client": "^11.6.0",
|
"@trpc/client": "^11.6.0",
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
|
"@zxing/browser": "^0.2.1",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"bullmq": "^5.61.0",
|
"bullmq": "^5.61.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
|
"dexie": "^4.4.5",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.8.1",
|
"ioredis": "^5.8.1",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"maplibre-gl": "^6.7.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"papaparse": "^5.7.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
@@ -61,6 +76,8 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "^22.18.6",
|
"@types/node": "^22.18.6",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
@@ -68,6 +85,7 @@
|
|||||||
"esbuild": "^0.25.10",
|
"esbuild": "^0.25.10",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-config-next": "^15.5.4",
|
"eslint-config-next": "^15.5.4",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
|
|||||||
@@ -13,11 +13,40 @@ export default defineConfig({
|
|||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
retries: process.env.CI ? 2 : 0,
|
/**
|
||||||
workers: process.env.CI ? 1 : undefined,
|
* Ein Wiederholungsversuch auch lokal.
|
||||||
|
*
|
||||||
|
* Der Grund ist derselbe wie bei den großzügigen Zeitlimits unten: "next
|
||||||
|
* dev" übersetzt jede Route beim ersten Aufruf, und mit vier gleichzeitigen
|
||||||
|
* Arbeitern kommt dazu die Anmeldung mit bcrypt (12 Runden, wie in
|
||||||
|
* Produktion). Unter dieser Last läuft gelegentlich ein Test ins Zeitlimit,
|
||||||
|
* der einzeln zuverlässig grün ist — verstreut über alle Dateien, nicht an
|
||||||
|
* einer Stelle. Ein echter Fehler fällt zweimal durch und bleibt sichtbar.
|
||||||
|
*/
|
||||||
|
retries: process.env.CI ? 2 : 1,
|
||||||
|
/**
|
||||||
|
* Bewusst wenige Worker: "next dev" übersetzt jede Route erst beim ersten
|
||||||
|
* Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange
|
||||||
|
* kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann
|
||||||
|
* nach einem Fehler in der Anwendung aus, ist aber keiner.
|
||||||
|
*
|
||||||
|
* Ab Phase 5 von vier auf zwei: mit über 130 Tests, bcrypt bei jeder
|
||||||
|
* Anmeldung und einer Datenbank daneben fielen bei vier Arbeitern in jedem
|
||||||
|
* Gesamtlauf zwei bis fünf Tests ins Zeitlimit — jedes Mal andere, jeder
|
||||||
|
* einzeln grün. Ein Ergebnis, das man nicht glauben kann, ist wertlos; der
|
||||||
|
* Lauf dauert dafür etwa ein Drittel länger.
|
||||||
|
*/
|
||||||
|
workers: process.env.CI ? 1 : 2,
|
||||||
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||||
timeout: 30_000,
|
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
|
||||||
expect: { timeout: 10_000 },
|
timeout: 90_000,
|
||||||
|
/**
|
||||||
|
* Ab Phase 3 großzügiger: mit fünfzig Routen dauert die erste Übersetzung
|
||||||
|
* einer bisher unbesuchten Seite unter Last auch mal zwanzig Sekunden. Das
|
||||||
|
* sind Wartezeiten des Entwicklungsservers, keine der Anwendung — vorher
|
||||||
|
* schlugen dadurch gelegentlich Tests fehl, die einzeln laufen und grün sind.
|
||||||
|
*/
|
||||||
|
expect: { timeout: 25_000 },
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: BASIS_URL,
|
baseURL: BASIS_URL,
|
||||||
@@ -44,6 +73,6 @@ export default defineConfig({
|
|||||||
command: "npm run dev",
|
command: "npm run dev",
|
||||||
url: BASIS_URL,
|
url: BASIS_URL,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StandortArt" AS ENUM ('GERAETEHAUS', 'LAGER', 'AUSSENSTELLE', 'WERKSTATT', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GeraeteKategorie" AS ENUM ('ATEMSCHUTZ', 'BELEUCHTUNG', 'BRANDBEKAEMPFUNG', 'ELEKTRO', 'FUNK', 'HYGIENE', 'MEDIZIN', 'MESSTECHNIK', 'PERSOENLICHE_SCHUTZAUSRUESTUNG', 'RETTUNG', 'SCHLAUCHMATERIAL', 'TECHNISCHE_HILFELEISTUNG', 'UMWELTSCHUTZ', 'WASSERFOERDERUNG', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LebensdauerKategorie" AS ENUM ('KEINE', 'HELM', 'SCHUTZKLEIDUNG', 'HANDSCHUHE', 'STIEFEL', 'SEIL_GURT', 'PRESSLUFTATMER', 'ATEMANSCHLUSS', 'DRUCKBEHAELTER', 'SCHLAUCH', 'LEITER', 'SONSTIGE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LebensdauerBezug" AS ENUM ('HERSTELLUNG', 'INDIENSTSTELLUNG');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "QualifikationsArt" AS ENUM ('LEHRGANG', 'BEFAEHIGUNG', 'UNTERSUCHUNG', 'FUEHRERSCHEIN', 'UNTERWEISUNG', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "fahrzeug" ADD COLUMN "aufbauhersteller" VARCHAR(120),
|
||||||
|
ADD COLUMN "bemerkung" VARCHAR(1000),
|
||||||
|
ADD COLUMN "dinTyp" VARCHAR(40),
|
||||||
|
ADD COLUMN "erstzulassung" DATE,
|
||||||
|
ADD COLUMN "fahrgestellnummer" VARCHAR(30),
|
||||||
|
ADD COLUMN "gesamtmasse" INTEGER,
|
||||||
|
ADD COLUMN "indienststellung" DATE,
|
||||||
|
ADD COLUMN "kilometerstand" INTEGER,
|
||||||
|
ADD COLUMN "naechsteWartung" DATE,
|
||||||
|
ADD COLUMN "sitzplaetze" INTEGER,
|
||||||
|
ADD COLUMN "standortId" UUID,
|
||||||
|
ADD COLUMN "tankinhalt" INTEGER,
|
||||||
|
ADD COLUMN "uvvBis" DATE;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "geraet" ADD COLUMN "anschaffungspreis" DECIMAL(12,2),
|
||||||
|
ADD COLUMN "ausgesondertAm" DATE,
|
||||||
|
ADD COLUMN "ausgesondertGrund" VARCHAR(500),
|
||||||
|
ADD COLUMN "bemerkung" VARCHAR(1000),
|
||||||
|
ADD COLUMN "geraeteraumId" UUID,
|
||||||
|
ADD COLUMN "geraetetypId" UUID,
|
||||||
|
ADD COLUMN "lebensdauerBis" DATE,
|
||||||
|
ADD COLUMN "lebensdauerKategorie" "LebensdauerKategorie",
|
||||||
|
ADD COLUMN "letztePruefung" DATE,
|
||||||
|
ADD COLUMN "modell" VARCHAR(120),
|
||||||
|
ADD COLUMN "standortId" UUID;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "standort" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"kuerzel" VARCHAR(20),
|
||||||
|
"art" "StandortArt" NOT NULL DEFAULT 'GERAETEHAUS',
|
||||||
|
"strasse" VARCHAR(200),
|
||||||
|
"plz" VARCHAR(10),
|
||||||
|
"ort" VARCHAR(120),
|
||||||
|
"koordinaten" geography(Point, 4326),
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "standort_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "geraeteraum" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"fahrzeugId" UUID NOT NULL,
|
||||||
|
"bezeichnung" VARCHAR(40) NOT NULL,
|
||||||
|
"beschreibung" VARCHAR(200),
|
||||||
|
"sortierung" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "geraeteraum_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "geraetetyp" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"kategorie" "GeraeteKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||||
|
"hersteller" VARCHAR(120),
|
||||||
|
"modell" VARCHAR(120),
|
||||||
|
"pruefintervallMonate" INTEGER,
|
||||||
|
"pruefvorschrift" VARCHAR(200),
|
||||||
|
"lebensdauerKategorie" "LebensdauerKategorie" NOT NULL DEFAULT 'KEINE',
|
||||||
|
"lebensdauerJahre" INTEGER,
|
||||||
|
"lebensdauerBezug" "LebensdauerBezug" NOT NULL DEFAULT 'INDIENSTSTELLUNG',
|
||||||
|
"vorwarnzeitMonate" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"beschreibung" VARCHAR(1000),
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "geraetetyp_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "qualifikationstyp" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"kuerzel" VARCHAR(20),
|
||||||
|
"art" "QualifikationsArt" NOT NULL DEFAULT 'LEHRGANG',
|
||||||
|
"gueltigkeitMonate" INTEGER,
|
||||||
|
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 90,
|
||||||
|
"einsatzrelevant" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"beschreibung" VARCHAR(1000),
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "qualifikationstyp_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "qualifikation" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"typId" UUID NOT NULL,
|
||||||
|
"erworbenAm" DATE NOT NULL,
|
||||||
|
"gueltigBis" DATE,
|
||||||
|
"ausstellendeStelle" VARCHAR(200),
|
||||||
|
"nachweisnummer" VARCHAR(80),
|
||||||
|
"urkundeDateiId" UUID,
|
||||||
|
"bestaetigtVonId" UUID,
|
||||||
|
"bestaetigtAm" TIMESTAMPTZ(3),
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "qualifikation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "standort_organisationId_aktiv_idx" ON "standort"("organisationId", "aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "standort_organisationId_name_key" ON "standort"("organisationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "geraeteraum_organisationId_fahrzeugId_idx" ON "geraeteraum"("organisationId", "fahrzeugId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "geraeteraum_fahrzeugId_bezeichnung_key" ON "geraeteraum"("fahrzeugId", "bezeichnung");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "geraetetyp_organisationId_kategorie_idx" ON "geraetetyp"("organisationId", "kategorie");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "geraetetyp_organisationId_aktiv_idx" ON "geraetetyp"("organisationId", "aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "geraetetyp_organisationId_name_key" ON "geraetetyp"("organisationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "qualifikationstyp_organisationId_aktiv_idx" ON "qualifikationstyp"("organisationId", "aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "qualifikationstyp_organisationId_name_key" ON "qualifikationstyp"("organisationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "qualifikation_organisationId_personId_idx" ON "qualifikation"("organisationId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "qualifikation_organisationId_typId_idx" ON "qualifikation"("organisationId", "typId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "qualifikation_organisationId_gueltigBis_idx" ON "qualifikation"("organisationId", "gueltigBis");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "fahrzeug_organisationId_tuevBis_idx" ON "fahrzeug"("organisationId", "tuevBis");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "fahrzeug_organisationId_kennzeichen_key" ON "fahrzeug"("organisationId", "kennzeichen");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "geraet_organisationId_lebensdauerBis_idx" ON "geraet"("organisationId", "lebensdauerBis");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "geraet_organisationId_standortId_idx" ON "geraet"("organisationId", "standortId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "geraet_organisationId_geraetetypId_idx" ON "geraet"("organisationId", "geraetetypId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "geraet_organisationId_seriennummer_idx" ON "geraet"("organisationId", "seriennummer");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "standort" ADD CONSTRAINT "standort_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "geraetetyp" ADD CONSTRAINT "geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraeteraumId_fkey" FOREIGN KEY ("geraeteraumId") REFERENCES "geraeteraum"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "qualifikationstyp" ADD CONSTRAINT "qualifikationstyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_typId_fkey" FOREIGN KEY ("typId") REFERENCES "qualifikationstyp"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_bestaetigtVonId_fkey" FOREIGN KEY ("bestaetigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 1: Mandantentrennung, Lagerungsregel und Suchindizes.
|
||||||
|
--
|
||||||
|
-- Die vorige Migration hat die Tabellen angelegt. Ohne Row Level Security wären
|
||||||
|
-- sie für die Anwendungsrolle offen — jede neue Tabelle muss deshalb dieselbe
|
||||||
|
-- Behandlung bekommen wie die aus Phase 0.
|
||||||
|
--
|
||||||
|
-- Dazu kommen zwei Dinge, die sich nur in der Datenbank sauber durchsetzen
|
||||||
|
-- lassen: die Regel, wo ein Gerät liegen darf, und schnelle Volltextsuche.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security für die neuen Tabellen
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'standort',
|
||||||
|
'geraeteraum',
|
||||||
|
'geraetetyp',
|
||||||
|
'qualifikationstyp',
|
||||||
|
'qualifikation'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Bestandsdaten auf die neue Lagerungsregel bringen.
|
||||||
|
--
|
||||||
|
-- Vor Phase 1 konnte ein Gerät nur einen Freitext-Lagerort haben. Damit die
|
||||||
|
-- Prüfbedingung weiter unten greift, braucht jedes Gerät ohne Fahrzeug jetzt
|
||||||
|
-- einen echten Standort. Je Wehr wird dafür ein Gerätehaus angelegt, sofern
|
||||||
|
-- noch keines existiert.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
INSERT INTO "standort" ("id", "organisationId", "name", "kuerzel", "art", "erstelltAm", "geaendertAm")
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
o."id",
|
||||||
|
'Gerätehaus',
|
||||||
|
'GH',
|
||||||
|
'GERAETEHAUS',
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
FROM "organisation" o
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "standort" s WHERE s."organisationId" = o."id"
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE "geraet" g
|
||||||
|
SET "standortId" = (
|
||||||
|
SELECT s."id"
|
||||||
|
FROM "standort" s
|
||||||
|
WHERE s."organisationId" = g."organisationId"
|
||||||
|
AND s."art" = 'GERAETEHAUS'
|
||||||
|
ORDER BY s."erstelltAm"
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE g."fahrzeugId" IS NULL
|
||||||
|
AND g."standortId" IS NULL;
|
||||||
|
|
||||||
|
-- Fahrzeuge dem Gerätehaus zuordnen, sofern noch kein Standort gesetzt ist.
|
||||||
|
UPDATE "fahrzeug" f
|
||||||
|
SET "standortId" = (
|
||||||
|
SELECT s."id"
|
||||||
|
FROM "standort" s
|
||||||
|
WHERE s."organisationId" = f."organisationId"
|
||||||
|
AND s."art" = 'GERAETEHAUS'
|
||||||
|
ORDER BY s."erstelltAm"
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE f."standortId" IS NULL;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Wo ein Gerät liegen darf.
|
||||||
|
--
|
||||||
|
-- Entweder es steht an einem Standort — dann ist kein Fahrzeug gesetzt. Oder es
|
||||||
|
-- ist auf einem Fahrzeug verlastet — dann ist kein Standort gesetzt, und der
|
||||||
|
-- Geräteraum darf angegeben werden, muss aber nicht (manches liegt lose in der
|
||||||
|
-- Kabine). Beides gleichzeitig oder gar nichts ist eine Falschangabe.
|
||||||
|
--
|
||||||
|
-- Dass ein angegebener Geräteraum auch wirklich zum angegebenen Fahrzeug
|
||||||
|
-- gehört, lässt sich als CHECK nicht ausdrücken — das prüft der Trigger
|
||||||
|
-- darunter.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_lagerung_eindeutig" CHECK (
|
||||||
|
("standortId" IS NOT NULL AND "fahrzeugId" IS NULL AND "geraeteraumId" IS NULL)
|
||||||
|
OR ("standortId" IS NULL AND "fahrzeugId" IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION geraeteraum_gehoert_zum_fahrzeug()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
raum_fahrzeug uuid;
|
||||||
|
BEGIN
|
||||||
|
IF NEW."geraeteraumId" IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT "fahrzeugId" INTO raum_fahrzeug
|
||||||
|
FROM "geraeteraum"
|
||||||
|
WHERE "id" = NEW."geraeteraumId";
|
||||||
|
|
||||||
|
IF raum_fahrzeug IS DISTINCT FROM NEW."fahrzeugId" THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Der Geraeteraum gehoert nicht zu dem angegebenen Fahrzeug.'
|
||||||
|
USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER geraet_raum_pruefen
|
||||||
|
BEFORE INSERT OR UPDATE OF "fahrzeugId", "geraeteraumId" ON "geraet"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION geraeteraum_gehoert_zum_fahrzeug();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Volltextsuche.
|
||||||
|
--
|
||||||
|
-- Die Listen sollen auf Tippen reagieren, auch bei einigen tausend Geräten.
|
||||||
|
-- pg_trgm macht ILIKE '%text%' indexfähig — ohne diese Indizes läuft jede
|
||||||
|
-- Suche als vollständiger Tabellendurchlauf.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "geraet_suche_bezeichnung" ON "geraet" USING gin ("bezeichnung" gin_trgm_ops);
|
||||||
|
CREATE INDEX "geraet_suche_inventarnummer" ON "geraet" USING gin ("inventarnummer" gin_trgm_ops);
|
||||||
|
CREATE INDEX "geraet_suche_seriennummer" ON "geraet" USING gin ("seriennummer" gin_trgm_ops);
|
||||||
|
CREATE INDEX "geraet_suche_hersteller" ON "geraet" USING gin ("hersteller" gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX "fahrzeug_suche_funkrufname" ON "fahrzeug" USING gin ("funkrufname" gin_trgm_ops);
|
||||||
|
CREATE INDEX "fahrzeug_suche_bezeichnung" ON "fahrzeug" USING gin ("bezeichnung" gin_trgm_ops);
|
||||||
|
CREATE INDEX "fahrzeug_suche_kennzeichen" ON "fahrzeug" USING gin ("kennzeichen" gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX "person_suche_nachname" ON "person" USING gin ("nachname" gin_trgm_ops);
|
||||||
|
CREATE INDEX "person_suche_vorname" ON "person" USING gin ("vorname" gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX "geraetetyp_suche_name" ON "geraetetyp" USING gin ("name" gin_trgm_ops);
|
||||||
|
CREATE INDEX "standort_suche_name" ON "standort" USING gin ("name" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- Räumliche Abfragen auf Standorte, ab Phase 6 gebraucht.
|
||||||
|
CREATE INDEX "standort_koordinaten" ON "standort" USING gist ("koordinaten");
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
|
||||||
|
|
||||||
|
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 2: Tabellen für Wartung und Prüfung.
|
||||||
|
--
|
||||||
|
-- Erzeugt mit `prisma migrate dev --create-only` und danach an einer Stelle von
|
||||||
|
-- Hand bereinigt: Prisma wollte die Volltext- und Geoindizes aus Phase 1
|
||||||
|
-- (gin_trgm_ops, gist auf Koordinaten) löschen, weil es sie im Schema nicht
|
||||||
|
-- kennt — Prisma kann diese Indexarten nicht abbilden. Die DROP INDEX-Zeilen
|
||||||
|
-- sind deshalb entfernt. Wer diese Migration neu erzeugt, muss das wieder tun.
|
||||||
|
--
|
||||||
|
-- Mandantentrennung, Unveränderbarkeit der Protokolle und die Suchindizes für
|
||||||
|
-- die neuen Tabellen stehen in der nachfolgenden Migration.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WartungsterminStatus" AS ENUM ('OFFEN', 'ERLEDIGT', 'ENTFALLEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PruefungStatus" AS ENUM ('ENTWURF', 'ABGESCHLOSSEN', 'ERSETZT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PruefErgebnis" AS ENUM ('BESTANDEN', 'BESTANDEN_MIT_MAENGELN', 'NICHT_BESTANDEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PruefungArt" AS ENUM ('INTERN', 'EXTERN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ChecklistenpunktTyp" AS ENUM ('JA_NEIN', 'MESSWERT', 'TEXT', 'FOTO');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SignaturRolle" AS ENUM ('PRUEFER', 'GERAETEWART', 'FREMDFIRMA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MangelPrioritaet" AS ENUM ('NIEDRIG', 'MITTEL', 'HOCH', 'KRITISCH');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MangelStatus" AS ENUM ('GEMELDET', 'ZUGEWIESEN', 'IN_ARBEIT', 'BEHOBEN', 'VERWORFEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RueckrufStatus" AS ENUM ('OFFEN', 'IN_BEARBEITUNG', 'ERLEDIGT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BetroffenheitStatus" AS ENUM ('OFFEN', 'NICHT_BETROFFEN', 'MASSNAHME_LAEUFT', 'ERLEDIGT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ErinnerungStufe" AS ENUM ('T_30', 'T_7', 'T_0', 'UEBERFAELLIG');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "wartungsvorlage" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"geraetetypId" UUID,
|
||||||
|
"intervallMonate" INTEGER NOT NULL,
|
||||||
|
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"pruefvorschrift" VARCHAR(200),
|
||||||
|
"standardDauerMinuten" INTEGER,
|
||||||
|
"standardExtern" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"automatisch" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"beschreibung" VARCHAR(1000),
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "wartungsvorlage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "checklistenpunkt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"vorlageId" UUID NOT NULL,
|
||||||
|
"text" VARCHAR(300) NOT NULL,
|
||||||
|
"typ" "ChecklistenpunktTyp" NOT NULL DEFAULT 'JA_NEIN',
|
||||||
|
"pflicht" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sollwert" VARCHAR(120),
|
||||||
|
"einheit" VARCHAR(20),
|
||||||
|
"minWert" DECIMAL(12,3),
|
||||||
|
"maxWert" DECIMAL(12,3),
|
||||||
|
"hinweis" VARCHAR(500),
|
||||||
|
"sortierung" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "checklistenpunkt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "wartungstermin" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"geraetId" UUID NOT NULL,
|
||||||
|
"vorlageId" UUID,
|
||||||
|
"faelligAm" DATE NOT NULL,
|
||||||
|
"status" "WartungsterminStatus" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"automatischErzeugt" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"zugewiesenAnId" UUID,
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"erledigtAm" TIMESTAMPTZ(3),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "wartungstermin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pruefung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"geraetId" UUID NOT NULL,
|
||||||
|
"terminId" UUID,
|
||||||
|
"vorlageId" UUID,
|
||||||
|
"art" "PruefungArt" NOT NULL DEFAULT 'INTERN',
|
||||||
|
"status" "PruefungStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||||
|
"prueferId" UUID,
|
||||||
|
"prueferName" VARCHAR(200),
|
||||||
|
"durchgefuehrtAm" DATE NOT NULL,
|
||||||
|
"begonnenUm" TIMESTAMPTZ(3),
|
||||||
|
"dauerMinuten" INTEGER,
|
||||||
|
"ergebnis" "PruefErgebnis",
|
||||||
|
"externeFirma" VARCHAR(200),
|
||||||
|
"externeProtokollDateiId" UUID,
|
||||||
|
"kosten" DECIMAL(10,2),
|
||||||
|
"naechstePruefungAm" DATE,
|
||||||
|
"protokollDateiId" UUID,
|
||||||
|
"protokollNummer" VARCHAR(40),
|
||||||
|
"abgeschlossenAm" TIMESTAMPTZ(3),
|
||||||
|
"ersetztPruefungId" UUID,
|
||||||
|
"korrekturGrund" VARCHAR(500),
|
||||||
|
"bemerkung" VARCHAR(2000),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "pruefung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pruefergebnis" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"pruefungId" UUID NOT NULL,
|
||||||
|
"punktId" UUID NOT NULL,
|
||||||
|
"jaNein" BOOLEAN,
|
||||||
|
"zahlwert" DECIMAL(12,3),
|
||||||
|
"text" VARCHAR(1000),
|
||||||
|
"fotoDateiId" UUID,
|
||||||
|
"inOrdnung" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pruefergebnis_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "signatur" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"pruefungId" UUID NOT NULL,
|
||||||
|
"rolle" "SignaturRolle" NOT NULL DEFAULT 'PRUEFER',
|
||||||
|
"personId" UUID,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"bildDateiId" UUID,
|
||||||
|
"unterzeichnetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "signatur_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "mangel" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"geraetId" UUID NOT NULL,
|
||||||
|
"pruefungId" UUID,
|
||||||
|
"melderId" UUID,
|
||||||
|
"melderName" VARCHAR(200),
|
||||||
|
"beschreibung" VARCHAR(2000) NOT NULL,
|
||||||
|
"fotoDateiId" UUID,
|
||||||
|
"prioritaet" "MangelPrioritaet" NOT NULL DEFAULT 'MITTEL',
|
||||||
|
"status" "MangelStatus" NOT NULL DEFAULT 'GEMELDET',
|
||||||
|
"zugewiesenAnId" UUID,
|
||||||
|
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"behobenAm" TIMESTAMPTZ(3),
|
||||||
|
"behobenVonId" UUID,
|
||||||
|
"behebung" VARCHAR(2000),
|
||||||
|
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "mangel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "rueckruf" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"hersteller" VARCHAR(160) NOT NULL,
|
||||||
|
"titel" VARCHAR(200) NOT NULL,
|
||||||
|
"referenz" VARCHAR(80),
|
||||||
|
"beschreibung" VARCHAR(4000),
|
||||||
|
"massnahme" VARCHAR(2000),
|
||||||
|
"veroeffentlichtAm" DATE,
|
||||||
|
"quelle" VARCHAR(500),
|
||||||
|
"sperrtGeraete" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" "RueckrufStatus" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"erledigtAm" TIMESTAMPTZ(3),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "rueckruf_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "rueckruf_geraetetyp" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"rueckrufId" UUID NOT NULL,
|
||||||
|
"geraetetypId" UUID,
|
||||||
|
"modell" VARCHAR(160),
|
||||||
|
"seriennummerVon" VARCHAR(120),
|
||||||
|
"seriennummerBis" VARCHAR(120),
|
||||||
|
"herstellungVon" DATE,
|
||||||
|
"herstellungBis" DATE,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "rueckruf_geraetetyp_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "rueckruf_betroffenheit" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"rueckrufId" UUID NOT NULL,
|
||||||
|
"geraetId" UUID NOT NULL,
|
||||||
|
"status" "BetroffenheitStatus" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"festgestelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"erledigtAm" TIMESTAMPTZ(3),
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "rueckruf_betroffenheit_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "erinnerung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"terminId" UUID NOT NULL,
|
||||||
|
"stufe" "ErinnerungStufe" NOT NULL,
|
||||||
|
"versendetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"kanal" VARCHAR(40) NOT NULL DEFAULT 'mail',
|
||||||
|
"empfaenger" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"meldung" VARCHAR(500),
|
||||||
|
|
||||||
|
CONSTRAINT "erinnerung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wartungsvorlage_organisationId_geraetetypId_idx" ON "wartungsvorlage"("organisationId", "geraetetypId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wartungsvorlage_organisationId_aktiv_idx" ON "wartungsvorlage"("organisationId", "aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "wartungsvorlage_organisationId_name_key" ON "wartungsvorlage"("organisationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "checklistenpunkt_organisationId_vorlageId_idx" ON "checklistenpunkt"("organisationId", "vorlageId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wartungstermin_organisationId_faelligAm_idx" ON "wartungstermin"("organisationId", "faelligAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wartungstermin_organisationId_status_faelligAm_idx" ON "wartungstermin"("organisationId", "status", "faelligAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wartungstermin_organisationId_geraetId_idx" ON "wartungstermin"("organisationId", "geraetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wartungstermin_organisationId_zugewiesenAnId_idx" ON "wartungstermin"("organisationId", "zugewiesenAnId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "wartungstermin_geraetId_vorlageId_faelligAm_key" ON "wartungstermin"("geraetId", "vorlageId", "faelligAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pruefung_organisationId_geraetId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "geraetId", "durchgefuehrtAm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pruefung_organisationId_status_idx" ON "pruefung"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pruefung_organisationId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "durchgefuehrtAm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pruefung_organisationId_prueferId_idx" ON "pruefung"("organisationId", "prueferId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pruefung_organisationId_protokollNummer_key" ON "pruefung"("organisationId", "protokollNummer");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pruefergebnis_organisationId_pruefungId_idx" ON "pruefergebnis"("organisationId", "pruefungId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pruefergebnis_pruefungId_punktId_key" ON "pruefergebnis"("pruefungId", "punktId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "signatur_organisationId_pruefungId_idx" ON "signatur"("organisationId", "pruefungId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "mangel_organisationId_status_prioritaet_idx" ON "mangel"("organisationId", "status", "prioritaet");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "mangel_organisationId_geraetId_idx" ON "mangel"("organisationId", "geraetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "mangel_organisationId_zugewiesenAnId_idx" ON "mangel"("organisationId", "zugewiesenAnId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "rueckruf_organisationId_status_idx" ON "rueckruf"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "rueckruf_organisationId_hersteller_idx" ON "rueckruf"("organisationId", "hersteller");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "rueckruf_geraetetyp_organisationId_rueckrufId_idx" ON "rueckruf_geraetetyp"("organisationId", "rueckrufId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "rueckruf_betroffenheit_organisationId_rueckrufId_idx" ON "rueckruf_betroffenheit"("organisationId", "rueckrufId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "rueckruf_betroffenheit_organisationId_geraetId_idx" ON "rueckruf_betroffenheit"("organisationId", "geraetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "rueckruf_betroffenheit_rueckrufId_geraetId_key" ON "rueckruf_betroffenheit"("rueckrufId", "geraetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "erinnerung_organisationId_versendetAm_idx" ON "erinnerung"("organisationId", "versendetAm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "erinnerung_terminId_stufe_key" ON "erinnerung"("terminId", "stufe");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_externeProtokollDateiId_fkey" FOREIGN KEY ("externeProtokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_ersetztPruefungId_fkey" FOREIGN KEY ("ersetztPruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_punktId_fkey" FOREIGN KEY ("punktId") REFERENCES "checklistenpunkt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_bildDateiId_fkey" FOREIGN KEY ("bildDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_melderId_fkey" FOREIGN KEY ("melderId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_behobenVonId_fkey" FOREIGN KEY ("behobenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "rueckruf" ADD CONSTRAINT "rueckruf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
|
||||||
|
--
|
||||||
|
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
|
||||||
|
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
|
||||||
|
--
|
||||||
|
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
|
||||||
|
-- für die Tabellen aus Phase 0 und 1.
|
||||||
|
--
|
||||||
|
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
|
||||||
|
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
|
||||||
|
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
|
||||||
|
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
|
||||||
|
-- der Datenbank, nicht nur in lib/ und server/.
|
||||||
|
--
|
||||||
|
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
|
||||||
|
-- Rückrufe.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'wartungsvorlage',
|
||||||
|
'checklistenpunkt',
|
||||||
|
'wartungstermin',
|
||||||
|
'pruefung',
|
||||||
|
'pruefergebnis',
|
||||||
|
'signatur',
|
||||||
|
'mangel',
|
||||||
|
'rueckruf',
|
||||||
|
'rueckruf_geraetetyp',
|
||||||
|
'rueckruf_betroffenheit',
|
||||||
|
'erinnerung'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Unveränderbarkeit abgeschlossener Prüfungen
|
||||||
|
--
|
||||||
|
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
|
||||||
|
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
|
||||||
|
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
|
||||||
|
--
|
||||||
|
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
|
||||||
|
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
|
||||||
|
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
|
||||||
|
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
|
||||||
|
--
|
||||||
|
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
|
||||||
|
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
vergleich "pruefung"%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
-- Entwürfe sind frei änderbar.
|
||||||
|
IF OLD."status" = 'ENTWURF' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
|
||||||
|
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
|
||||||
|
vergleich := NEW;
|
||||||
|
vergleich."geaendertAm" := OLD."geaendertAm";
|
||||||
|
|
||||||
|
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
|
||||||
|
vergleich."status" := OLD."status";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF OLD."protokollDateiId" IS NULL THEN
|
||||||
|
vergleich."protokollDateiId" := OLD."protokollDateiId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF OLD."protokollNummer" IS NULL THEN
|
||||||
|
vergleich."protokollNummer" := OLD."protokollNummer";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF vergleich IS DISTINCT FROM OLD THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
|
||||||
|
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
|
||||||
|
OLD."id"
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
|
||||||
|
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
|
||||||
|
|
||||||
|
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
|
||||||
|
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
|
||||||
|
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD."status" = 'ENTWURF' THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
|
||||||
|
'(nur im Aufbewahrungslauf).',
|
||||||
|
OLD."id"
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER pruefung_abschluss_schuetzen
|
||||||
|
BEFORE UPDATE ON "pruefung"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
|
||||||
|
|
||||||
|
CREATE TRIGGER pruefung_loeschschutz
|
||||||
|
BEFORE DELETE ON "pruefung"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Dasselbe für die Bestandteile eines Protokolls.
|
||||||
|
--
|
||||||
|
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||||||
|
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
|
||||||
|
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
|
||||||
|
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
betroffene_pruefung uuid;
|
||||||
|
zustand "PruefungStatus";
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
betroffene_pruefung := OLD."pruefungId";
|
||||||
|
ELSE
|
||||||
|
betroffene_pruefung := NEW."pruefungId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
|
||||||
|
|
||||||
|
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
|
||||||
|
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
|
||||||
|
betroffene_pruefung, TG_OP, TG_TABLE_NAME
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER pruefergebnis_gesperrt
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||||
|
|
||||||
|
CREATE TRIGGER signatur_gesperrt
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
|
||||||
|
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
|
||||||
|
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
|
||||||
|
-- erzeugen.
|
||||||
|
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
|
||||||
|
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Indizes
|
||||||
|
--
|
||||||
|
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
|
||||||
|
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
|
||||||
|
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
|
||||||
|
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
|
||||||
|
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
|
||||||
|
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
|
||||||
|
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
|
||||||
|
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
|
||||||
|
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
|
||||||
|
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
|
||||||
|
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
|
||||||
|
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
|
||||||
|
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 3: Tabellen für Einsätze und Übungen.
|
||||||
|
--
|
||||||
|
-- Erzeugt mit `prisma migrate dev --create-only` und wie in Phase 2 an einer
|
||||||
|
-- Stelle bereinigt: die DROP INDEX-Zeilen für die Volltext- und Geoindizes
|
||||||
|
-- sind entfernt. Prisma kennt gin_trgm_ops und gist nicht und würde sie bei
|
||||||
|
-- jeder Migration erneut löschen wollen.
|
||||||
|
--
|
||||||
|
-- Mandantentrennung, Koordinaten-Trigger, Unveränderbarkeit freigegebener
|
||||||
|
-- Berichte und die Suchindizes stehen in der nachfolgenden Migration.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EinsatzKategorie" AS ENUM ('BRAND_KLEIN', 'BRAND_MITTEL', 'BRAND_GROSS', 'TECHNISCHE_HILFE_KLEIN', 'TECHNISCHE_HILFE_MITTEL', 'TECHNISCHE_HILFE_GROSS', 'GEFAHRGUT', 'RETTUNGSDIENST', 'BRANDMELDEANLAGE', 'BOESWILLIGER_ALARM', 'SICHERHEITSWACHE', 'UNTERSTUETZUNG', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EinsatzStatus" AS ENUM ('ENTWURF', 'LAUFEND', 'ABGESCHLOSSEN', 'FREIGEGEBEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Alarmierungsart" AS ENUM ('SIRENE', 'MELDEEMPFAENGER', 'TELEFON', 'APP', 'PERSOENLICH', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EinsatzFunktion" AS ENUM ('EINSATZLEITER', 'ZUGFUEHRER', 'GRUPPENFUEHRER', 'STAFFELFUEHRER', 'TRUPPFUEHRER', 'MASCHINIST', 'MELDER', 'TRUPPMANN', 'ATEMSCHUTZTRAEGER', 'SICHERHEITSTRUPP', 'SANITAETER', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UebungsArt" AS ENUM ('PRAXIS', 'THEORIE', 'ATEMSCHUTZ', 'MASCHINIST', 'FUNK', 'ERSTE_HILFE', 'GEMEINSCHAFTSUEBUNG', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LagemeldungQuelle" AS ENUM ('INTERN', 'PORTAL');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "einsatz" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatznummer" VARCHAR(40) NOT NULL,
|
||||||
|
"stichwort" VARCHAR(160) NOT NULL,
|
||||||
|
"kategorie" "EinsatzKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||||
|
"status" "EinsatzStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||||
|
"ilsNummer" VARCHAR(60),
|
||||||
|
"alarmierungsart" "Alarmierungsart",
|
||||||
|
"strasse" VARCHAR(200),
|
||||||
|
"hausnummer" VARCHAR(20),
|
||||||
|
"plz" VARCHAR(10),
|
||||||
|
"ort" VARCHAR(120),
|
||||||
|
"ortsteil" VARCHAR(120),
|
||||||
|
"objekt" VARCHAR(200),
|
||||||
|
"breitengrad" DECIMAL(9,6),
|
||||||
|
"laengengrad" DECIMAL(9,6),
|
||||||
|
"koordinaten" geography(Point, 4326),
|
||||||
|
"koordinatenQuelle" VARCHAR(40),
|
||||||
|
"alarmiertUm" TIMESTAMPTZ(3),
|
||||||
|
"ausgeruecktUm" TIMESTAMPTZ(3),
|
||||||
|
"eingetroffenUm" TIMESTAMPTZ(3),
|
||||||
|
"einsatzendeUm" TIMESTAMPTZ(3),
|
||||||
|
"eingeruecktUm" TIMESTAMPTZ(3),
|
||||||
|
"einsatzbereitUm" TIMESTAMPTZ(3),
|
||||||
|
"einsatzleiterId" UUID,
|
||||||
|
"beschreibung" VARCHAR(4000),
|
||||||
|
"bericht" TEXT,
|
||||||
|
"wetter" VARCHAR(200),
|
||||||
|
"temperatur" INTEGER,
|
||||||
|
"weitereKraefte" VARCHAR(500),
|
||||||
|
"geretteteePersonen" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"verletztePersonen" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totePersonen" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sachschaden" DECIMAL(12,2),
|
||||||
|
"kostenpflichtig" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"berichtDateiId" UUID,
|
||||||
|
"freigegebenAm" TIMESTAMPTZ(3),
|
||||||
|
"freigegebenVonId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "einsatz_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "einsatzfahrzeug" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID NOT NULL,
|
||||||
|
"fahrzeugId" UUID NOT NULL,
|
||||||
|
"ausgeruecktUm" TIMESTAMPTZ(3),
|
||||||
|
"eingetroffenUm" TIMESTAMPTZ(3),
|
||||||
|
"eingeruecktUm" TIMESTAMPTZ(3),
|
||||||
|
"kilometerAbfahrt" INTEGER,
|
||||||
|
"kilometerRueckkehr" INTEGER,
|
||||||
|
"wasserabgabe" INTEGER,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "einsatzfahrzeug_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "einsatzkraft" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"funktion" "EinsatzFunktion" NOT NULL DEFAULT 'TRUPPMANN',
|
||||||
|
"fahrzeugId" UUID,
|
||||||
|
"vonUm" TIMESTAMPTZ(3),
|
||||||
|
"bisUm" TIMESTAMPTZ(3),
|
||||||
|
"atemschutz" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "einsatzkraft_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "materialverbrauch" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID NOT NULL,
|
||||||
|
"geraetId" UUID,
|
||||||
|
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||||
|
"menge" DECIMAL(10,2) NOT NULL,
|
||||||
|
"einheit" VARCHAR(20) NOT NULL,
|
||||||
|
"kosten" DECIMAL(10,2),
|
||||||
|
"beschaedigt" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "materialverbrauch_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lagemeldung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID NOT NULL,
|
||||||
|
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"text" VARCHAR(2000) NOT NULL,
|
||||||
|
"quelle" "LagemeldungQuelle" NOT NULL DEFAULT 'INTERN',
|
||||||
|
"verfasserId" UUID,
|
||||||
|
"verfasserName" VARCHAR(200),
|
||||||
|
"fotoDateiId" UUID,
|
||||||
|
"wichtig" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"portalZugangId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "lagemeldung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "portal_zugang" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID NOT NULL,
|
||||||
|
"bezeichnung" VARCHAR(160) NOT NULL,
|
||||||
|
"tokenHash" VARCHAR(120) NOT NULL,
|
||||||
|
"gueltigBis" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"widerrufenAm" TIMESTAMPTZ(3),
|
||||||
|
"aufrufe" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"letzterAufruf" TIMESTAMPTZ(3),
|
||||||
|
"letzteIp" INET,
|
||||||
|
"erstelltVonId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "portal_zugang_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "uebung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"nummer" VARCHAR(40) NOT NULL,
|
||||||
|
"thema" VARCHAR(200) NOT NULL,
|
||||||
|
"art" "UebungsArt" NOT NULL DEFAULT 'PRAXIS',
|
||||||
|
"datum" DATE NOT NULL,
|
||||||
|
"beginnUm" TIMESTAMPTZ(3),
|
||||||
|
"endeUm" TIMESTAMPTZ(3),
|
||||||
|
"ort" VARCHAR(200),
|
||||||
|
"leiterId" UUID,
|
||||||
|
"lernziele" VARCHAR(2000),
|
||||||
|
"beschreibung" VARCHAR(4000),
|
||||||
|
"bericht" TEXT,
|
||||||
|
"berichtDateiId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "uebung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "uebungsteilnahme" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"uebungId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"funktion" "EinsatzFunktion",
|
||||||
|
"vonUm" TIMESTAMPTZ(3),
|
||||||
|
"bisUm" TIMESTAMPTZ(3),
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "uebungsteilnahme_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "einsatz_organisationId_alarmiertUm_idx" ON "einsatz"("organisationId", "alarmiertUm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "einsatz_organisationId_status_idx" ON "einsatz"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "einsatz_organisationId_kategorie_idx" ON "einsatz"("organisationId", "kategorie");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "einsatz_organisationId_einsatznummer_key" ON "einsatz"("organisationId", "einsatznummer");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "einsatzfahrzeug_organisationId_einsatzId_idx" ON "einsatzfahrzeug"("organisationId", "einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "einsatzfahrzeug_organisationId_fahrzeugId_idx" ON "einsatzfahrzeug"("organisationId", "fahrzeugId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "einsatzfahrzeug_einsatzId_fahrzeugId_key" ON "einsatzfahrzeug"("einsatzId", "fahrzeugId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "einsatzkraft_organisationId_einsatzId_idx" ON "einsatzkraft"("organisationId", "einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "einsatzkraft_organisationId_personId_idx" ON "einsatzkraft"("organisationId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "einsatzkraft_einsatzId_personId_key" ON "einsatzkraft"("einsatzId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "materialverbrauch_organisationId_einsatzId_idx" ON "materialverbrauch"("organisationId", "einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "lagemeldung_organisationId_einsatzId_zeitpunkt_idx" ON "lagemeldung"("organisationId", "einsatzId", "zeitpunkt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "portal_zugang_organisationId_einsatzId_idx" ON "portal_zugang"("organisationId", "einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "portal_zugang_gueltigBis_idx" ON "portal_zugang"("gueltigBis");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "uebung_organisationId_datum_idx" ON "uebung"("organisationId", "datum" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "uebung_organisationId_art_idx" ON "uebung"("organisationId", "art");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "uebung_organisationId_nummer_key" ON "uebung"("organisationId", "nummer");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "uebungsteilnahme_organisationId_personId_idx" ON "uebungsteilnahme"("organisationId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "uebungsteilnahme_uebungId_personId_key" ON "uebungsteilnahme"("uebungId", "personId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_einsatzleiterId_fkey" FOREIGN KEY ("einsatzleiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_freigegebenVonId_fkey" FOREIGN KEY ("freigegebenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_verfasserId_fkey" FOREIGN KEY ("verfasserId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_portalZugangId_fkey" FOREIGN KEY ("portalZugangId") REFERENCES "portal_zugang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_leiterId_fkey" FOREIGN KEY ("leiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 3: Mandantentrennung, Koordinaten, Berichtsfreigabe, Indizes.
|
||||||
|
--
|
||||||
|
-- Vier Dinge, die in der Datenbank besser aufgehoben sind als im Code:
|
||||||
|
--
|
||||||
|
-- 1. Row Level Security für die acht neuen Tabellen.
|
||||||
|
--
|
||||||
|
-- 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt. Die Anwendung
|
||||||
|
-- schreibt Breite und Länge als Zahl; den geography-Punkt setzt ein
|
||||||
|
-- Trigger. Sonst müsste jede Schreiboperation Roh-SQL sein, und die
|
||||||
|
-- Karte ab Phase 6 fände Einsätze, die über einen anderen Weg entstanden
|
||||||
|
-- sind, nicht wieder.
|
||||||
|
--
|
||||||
|
-- 3. Die Unveränderbarkeit freigegebener Einsatzberichte. Der Bericht geht
|
||||||
|
-- an die Gemeinde und im Schadensfall an die Versicherung. Dieselbe Logik
|
||||||
|
-- wie beim Prüfprotokoll aus Phase 2.
|
||||||
|
--
|
||||||
|
-- 4. Suchindizes und ein Teilindex auf die laufenden Einsätze.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'einsatz',
|
||||||
|
'einsatzfahrzeug',
|
||||||
|
'einsatzkraft',
|
||||||
|
'materialverbrauch',
|
||||||
|
'lagemeldung',
|
||||||
|
'portal_zugang',
|
||||||
|
'uebung',
|
||||||
|
'uebungsteilnahme'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION einsatz_koordinaten_setzen()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW."breitengrad" IS NULL OR NEW."laengengrad" IS NULL THEN
|
||||||
|
NEW."koordinaten" := NULL;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Reihenfolge beachten: ST_MakePoint nimmt erst die Länge, dann die Breite.
|
||||||
|
NEW."koordinaten" := ST_SetSRID(
|
||||||
|
ST_MakePoint(NEW."laengengrad"::double precision, NEW."breitengrad"::double precision),
|
||||||
|
4326
|
||||||
|
)::geography;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION einsatz_koordinaten_setzen() IS
|
||||||
|
'Haelt den PostGIS-Punkt eines Einsatzes mit den Dezimalgraden im Gleichlauf.';
|
||||||
|
|
||||||
|
CREATE TRIGGER einsatz_koordinaten
|
||||||
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "einsatz"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION einsatz_koordinaten_setzen();
|
||||||
|
|
||||||
|
-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler.
|
||||||
|
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_koordinaten_gueltig" CHECK (
|
||||||
|
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||||
|
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Freigegebene Einsatzberichte sind unveränderbar
|
||||||
|
--
|
||||||
|
-- Bis zur Freigabe darf alles nachgetragen werden — ein Einsatz wird tagelang
|
||||||
|
-- ergänzt, und genau dafür ist der Entwurfsstand da. Mit der Freigabe ist
|
||||||
|
-- Schluss: der Bericht ist dann ein Dokument, kein Arbeitsstand.
|
||||||
|
--
|
||||||
|
-- Zwei Ausnahmen bleiben, beide nachtragend statt verändernd: das PDF, solange
|
||||||
|
-- keines hinterlegt ist, und geaendertAm.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION einsatz_freigabe_schuetzen()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
vergleich "einsatz"%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
IF OLD."status" <> 'FREIGEGEBEN' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
vergleich := NEW;
|
||||||
|
vergleich."geaendertAm" := OLD."geaendertAm";
|
||||||
|
|
||||||
|
IF OLD."berichtDateiId" IS NULL THEN
|
||||||
|
vergleich."berichtDateiId" := OLD."berichtDateiId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF vergleich IS DISTINCT FROM OLD THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Der Einsatzbericht % ist freigegeben und damit unveraenderlich.',
|
||||||
|
OLD."einsatznummer"
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION einsatz_kein_loeschen()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD."status" <> 'FREIGEGEBEN' THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Der Einsatzbericht % ist freigegeben und darf nicht geloescht werden '
|
||||||
|
'(nur im Aufbewahrungslauf).',
|
||||||
|
OLD."einsatznummer"
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER einsatz_freigabe
|
||||||
|
BEFORE UPDATE ON "einsatz"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION einsatz_freigabe_schuetzen();
|
||||||
|
|
||||||
|
CREATE TRIGGER einsatz_loeschschutz
|
||||||
|
BEFORE DELETE ON "einsatz"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION einsatz_kein_loeschen();
|
||||||
|
|
||||||
|
-- Dasselbe für die Bestandteile: sonst ließe sich ein freigegebener Bericht
|
||||||
|
-- über die Einsatzkräfte oder den Materialverbrauch nachträglich verändern.
|
||||||
|
CREATE OR REPLACE FUNCTION einsatzinhalt_gesperrt()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
betroffener_einsatz uuid;
|
||||||
|
zustand "EinsatzStatus";
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
betroffener_einsatz := OLD."einsatzId";
|
||||||
|
ELSE
|
||||||
|
betroffener_einsatz := NEW."einsatzId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT "status" INTO zustand FROM "einsatz" WHERE "id" = betroffener_einsatz;
|
||||||
|
|
||||||
|
IF zustand IS NULL OR zustand <> 'FREIGEGEBEN' THEN
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Der Einsatz % ist freigegeben. % auf % ist nicht mehr zulaessig.',
|
||||||
|
betroffener_einsatz, TG_OP, TG_TABLE_NAME
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER einsatzfahrzeug_gesperrt
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzfahrzeug"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
||||||
|
|
||||||
|
CREATE TRIGGER einsatzkraft_gesperrt
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzkraft"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
||||||
|
|
||||||
|
CREATE TRIGGER materialverbrauch_gesperrt
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE ON "materialverbrauch"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
||||||
|
|
||||||
|
-- Lagemeldungen bleiben ausdrücklich ausgenommen: sie sind ein Protokoll des
|
||||||
|
-- Verlaufs, kein Bestandteil des Berichts, und eine Meldung aus dem Portal
|
||||||
|
-- kann eintreffen, während der Bericht schon freigegeben ist.
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Portalzugänge
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Ein Zugang, der nie gültig war, ist ein Fehler in der Erzeugung.
|
||||||
|
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_gueltigkeit" CHECK (
|
||||||
|
"gueltigBis" > "erstelltAm"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Indizes
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Der Alarmmonitor fragt immer dasselbe: was läuft gerade.
|
||||||
|
CREATE INDEX "einsatz_laufend" ON "einsatz" ("organisationId", "alarmiertUm" DESC)
|
||||||
|
WHERE "status" = 'LAUFEND' AND "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
-- Räumliche Abfragen ab Phase 6.
|
||||||
|
CREATE INDEX "einsatz_koordinaten_idx" ON "einsatz" USING gist ("koordinaten");
|
||||||
|
|
||||||
|
-- Volltextsuche wie in den Phasen davor.
|
||||||
|
CREATE INDEX "einsatz_suche_stichwort" ON "einsatz" USING gin ("stichwort" gin_trgm_ops);
|
||||||
|
CREATE INDEX "einsatz_suche_ort" ON "einsatz" USING gin ("ort" gin_trgm_ops);
|
||||||
|
CREATE INDEX "einsatz_suche_nummer" ON "einsatz" USING gin ("einsatznummer" gin_trgm_ops);
|
||||||
|
CREATE INDEX "uebung_suche_thema" ON "uebung" USING gin ("thema" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
|
||||||
|
-- Die Einschraenkungen aus den Phasen davor gelten unveraendert weiter.
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 4: Tabellen für das Terminal im Gerätehaus und den Offline-Betrieb.
|
||||||
|
--
|
||||||
|
-- Erzeugt mit `prisma migrate dev --create-only` und wie in den Phasen davor
|
||||||
|
-- um die DROP INDEX-Zeilen bereinigt: Prisma kennt die Trigram- und
|
||||||
|
-- Geoindizes nicht und würde sie sonst bei jeder Migration löschen wollen.
|
||||||
|
--
|
||||||
|
-- Mandantentrennung, die Sperre gegen doppelte Vorgänge und die Indizes für
|
||||||
|
-- den Terminalbetrieb stehen in der nachfolgenden Migration.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FahrtZweck" AS ENUM ('EINSATZ', 'UEBUNG', 'AUSBILDUNG', 'WERKSTATT', 'BESCHAFFUNG', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WaescheStatus" AS ENUM ('ABGEGEBEN', 'IN_REINIGUNG', 'ZURUECK', 'VERSCHOLLEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AnwesenheitAnlass" AS ENUM ('DIENST', 'UEBUNG', 'EINSATZ', 'ARBEITSDIENST', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InventurStatus" AS ENUM ('LAEUFT', 'ABGESCHLOSSEN', 'ABGEBROCHEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "KioskVorgangArt" AS ENUM ('MANGEL_MELDEN', 'FAHRT_BEGINNEN', 'FAHRT_BEENDEN', 'ANWESENHEIT_KOMMEN', 'ANWESENHEIT_GEHEN', 'WAESCHE_ABGEBEN', 'INVENTUR_SCANNEN', 'PRUEFUNG_ERFASSEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "KonfliktGrund" AS ENUM ('ZIEL_FEHLT', 'ZIEL_GESPERRT', 'DOPPELT', 'VERALTET', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "fahrt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"fahrzeugId" UUID NOT NULL,
|
||||||
|
"fahrerId" UUID NOT NULL,
|
||||||
|
"zweck" "FahrtZweck" NOT NULL DEFAULT 'SONSTIGES',
|
||||||
|
"einsatzId" UUID,
|
||||||
|
"uebungId" UUID,
|
||||||
|
"abfahrtUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"rueckkehrUm" TIMESTAMPTZ(3),
|
||||||
|
"kilometerAbfahrt" INTEGER NOT NULL,
|
||||||
|
"kilometerRueckkehr" INTEGER,
|
||||||
|
"ziel" VARCHAR(200),
|
||||||
|
"tankmenge" DECIMAL(8,2),
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "fahrt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "anwesenheit" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"anlass" "AnwesenheitAnlass" NOT NULL DEFAULT 'DIENST',
|
||||||
|
"angemeldetUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"abgemeldetUm" TIMESTAMPTZ(3),
|
||||||
|
"einsatzId" UUID,
|
||||||
|
"uebungId" UUID,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "anwesenheit_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "waescheabgabe" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"geraetId" UUID,
|
||||||
|
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||||
|
"menge" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"status" "WaescheStatus" NOT NULL DEFAULT 'ABGEGEBEN',
|
||||||
|
"grund" VARCHAR(200),
|
||||||
|
"abgegebenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zurueckAm" TIMESTAMPTZ(3),
|
||||||
|
"angenommenVonId" UUID,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "waescheabgabe_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "inventurgang" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"bezeichnung" VARCHAR(160) NOT NULL,
|
||||||
|
"status" "InventurStatus" NOT NULL DEFAULT 'LAEUFT',
|
||||||
|
"standortId" UUID,
|
||||||
|
"fahrzeugId" UUID,
|
||||||
|
"begonnenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"abgeschlossenAm" TIMESTAMPTZ(3),
|
||||||
|
"begonnenVonId" UUID,
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "inventurgang_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "inventurposten" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"inventurId" UUID NOT NULL,
|
||||||
|
"geraetId" UUID NOT NULL,
|
||||||
|
"gescanntAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"gefunden" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"vorgefundenAn" VARCHAR(200),
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erfasstVonId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "inventurposten_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "kiosk_vorgang" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"art" "KioskVorgangArt" NOT NULL,
|
||||||
|
"personId" UUID,
|
||||||
|
"erfasstAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"empfangenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"ergebnisId" UUID,
|
||||||
|
"nutzlast" JSONB,
|
||||||
|
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"meldung" VARCHAR(500),
|
||||||
|
|
||||||
|
CONSTRAINT "kiosk_vorgang_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sync_konflikt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"vorgangId" UUID NOT NULL,
|
||||||
|
"art" "KioskVorgangArt" NOT NULL,
|
||||||
|
"grund" "KonfliktGrund" NOT NULL DEFAULT 'SONSTIGES',
|
||||||
|
"beschreibung" VARCHAR(500) NOT NULL,
|
||||||
|
"nutzlast" JSONB,
|
||||||
|
"personId" UUID,
|
||||||
|
"entstandenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"erledigtAm" TIMESTAMPTZ(3),
|
||||||
|
"erledigtVonId" UUID,
|
||||||
|
"erledigung" VARCHAR(500),
|
||||||
|
|
||||||
|
CONSTRAINT "sync_konflikt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "fahrt_organisationId_fahrzeugId_abfahrtUm_idx" ON "fahrt"("organisationId", "fahrzeugId", "abfahrtUm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "fahrt_organisationId_fahrerId_idx" ON "fahrt"("organisationId", "fahrerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "anwesenheit_organisationId_personId_angemeldetUm_idx" ON "anwesenheit"("organisationId", "personId", "angemeldetUm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "anwesenheit_organisationId_abgemeldetUm_idx" ON "anwesenheit"("organisationId", "abgemeldetUm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "waescheabgabe_organisationId_status_idx" ON "waescheabgabe"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "waescheabgabe_organisationId_personId_idx" ON "waescheabgabe"("organisationId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "inventurgang_organisationId_status_idx" ON "inventurgang"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "inventurposten_organisationId_inventurId_idx" ON "inventurposten"("organisationId", "inventurId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "inventurposten_inventurId_geraetId_key" ON "inventurposten"("inventurId", "geraetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "kiosk_vorgang_organisationId_empfangenAm_idx" ON "kiosk_vorgang"("organisationId", "empfangenAm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "kiosk_vorgang_organisationId_art_idx" ON "kiosk_vorgang"("organisationId", "art");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sync_konflikt_organisationId_erledigtAm_idx" ON "sync_konflikt"("organisationId", "erledigtAm");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrerId_fkey" FOREIGN KEY ("fahrerId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_angenommenVonId_fkey" FOREIGN KEY ("angenommenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_begonnenVonId_fkey" FOREIGN KEY ("begonnenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_inventurId_fkey" FOREIGN KEY ("inventurId") REFERENCES "inventurgang"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_erledigtVonId_fkey" FOREIGN KEY ("erledigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 4: Mandantentrennung und Regeln für den Terminalbetrieb.
|
||||||
|
--
|
||||||
|
-- Drei Dinge:
|
||||||
|
--
|
||||||
|
-- 1. Row Level Security für die sieben neuen Tabellen.
|
||||||
|
--
|
||||||
|
-- 2. Regeln, die verhindern, dass ein offline erfasster Vorgang beim
|
||||||
|
-- Nachtragen Unsinn anrichtet: kein Kilometerstand, der rückwärts läuft,
|
||||||
|
-- keine Rückkehr vor der Abfahrt, keine Anwesenheit, die vor dem Kommen
|
||||||
|
-- endet.
|
||||||
|
--
|
||||||
|
-- 3. Indizes für die Abfragen, die das Terminal und der Hallenmonitor
|
||||||
|
-- dauernd stellen: wer ist gerade da, welches Fahrzeug ist unterwegs.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'fahrt',
|
||||||
|
'anwesenheit',
|
||||||
|
'waescheabgabe',
|
||||||
|
'inventurgang',
|
||||||
|
'inventurposten',
|
||||||
|
'kiosk_vorgang',
|
||||||
|
'sync_konflikt'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Was am Terminal nicht passieren darf
|
||||||
|
--
|
||||||
|
-- Diese Bedingungen fangen ab, was beim Eintippen auf einem Tablet mit
|
||||||
|
-- Handschuhen tatsächlich passiert: eine vertippte Ziffer im Kilometerstand,
|
||||||
|
-- ein Rückkehrzeitpunkt vor der Abfahrt. Im Gerätehaus schaut niemand ein
|
||||||
|
-- zweites Mal hin, und offline erfasste Vorgänge kommen unter Umständen erst
|
||||||
|
-- Stunden später an.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_kilometer_vorwaerts" CHECK (
|
||||||
|
"kilometerRueckkehr" IS NULL OR "kilometerRueckkehr" >= "kilometerAbfahrt"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_rueckkehr_nach_abfahrt" CHECK (
|
||||||
|
"rueckkehrUm" IS NULL OR "rueckkehrUm" >= "abfahrtUm"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_gehen_nach_kommen" CHECK (
|
||||||
|
"abgemeldetUm" IS NULL OR "abgemeldetUm" >= "angemeldetUm"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_menge_positiv" CHECK (
|
||||||
|
"menge" > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Indizes für den laufenden Betrieb
|
||||||
|
--
|
||||||
|
-- Der Hallenmonitor fragt im Sekundentakt dasselbe: wer ist da, welches
|
||||||
|
-- Fahrzeug ist draußen. Beides sind Teilmengen, die klein bleiben, während
|
||||||
|
-- die Tabellen über Jahre wachsen — also Teilindizes.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "anwesenheit_anwesend" ON "anwesenheit" ("organisationId", "angemeldetUm" DESC)
|
||||||
|
WHERE "abgemeldetUm" IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX "fahrt_unterwegs" ON "fahrt" ("organisationId", "fahrzeugId", "abfahrtUm" DESC)
|
||||||
|
WHERE "rueckkehrUm" IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX "waesche_offen" ON "waescheabgabe" ("organisationId", "personId")
|
||||||
|
WHERE "status" <> 'ZURUECK';
|
||||||
|
|
||||||
|
CREATE INDEX "sync_konflikt_offen" ON "sync_konflikt" ("organisationId", "entstandenAm" DESC)
|
||||||
|
WHERE "erledigtAm" IS NULL;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
|
||||||
|
-- Ein empfangener Terminalvorgang wird nie geaendert oder geloescht:
|
||||||
|
-- er ist der Beleg dafuer, dass etwas schon einmal angekommen ist.
|
||||||
|
-- Ohne diese Sperre koennte ein zweiter Sendeversuch den ersten
|
||||||
|
-- ueberschreiben und derselbe Vorgang doch zweimal wirken.
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
-- Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
--
|
||||||
|
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert:
|
||||||
|
-- die Volltext- und GiST-Indizes aus den Phasen 1 und 3 stehen nicht im
|
||||||
|
-- Schema, weil Prisma sie nicht abbilden kann. Ohne diesen Eingriff wuerde
|
||||||
|
-- jede neue Migration sie abraeumen. Rechte, Mandantentrennung und die
|
||||||
|
-- Unveraenderbarkeit des Protokolls stehen in der Migration daneben.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TruppStatus" AS ENUM ('BEREIT', 'IM_EINSATZ', 'RUECKWEG', 'DRAUSSEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DruckArt" AS ENUM ('EINGANG', 'ZWISCHEN', 'AUSGANG');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EreignisArt" AS ENUM ('TRUPP_ANGELEGT', 'EINSTIEG', 'DRUCKABFRAGE', 'RUECKZUG_ANGEORDNET', 'RUECKZUG_ANGETRETEN', 'HERAUSGEKOMMEN', 'MAYDAY', 'MAYDAY_AUFGEHOBEN', 'HINWEIS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "G26Ergebnis" AS ENUM ('TAUGLICH', 'BEFRISTET_TAUGLICH', 'NACHUNTERSUCHUNG', 'NICHT_TAUGLICH');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UebungsartAtemschutz" AS ENUM ('UEBUNGSSTRECKE', 'EINSATZUEBUNG', 'UNTERWEISUNG');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "atemschutzeinsatz" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID,
|
||||||
|
"uebungId" UUID,
|
||||||
|
"ueberwacherId" UUID NOT NULL,
|
||||||
|
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||||
|
"beginnUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"endeUm" TIMESTAMPTZ(3),
|
||||||
|
"abfrageIntervall" INTEGER NOT NULL DEFAULT 600,
|
||||||
|
"sicherheitszuschlag" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"bemerkung" VARCHAR(2000),
|
||||||
|
"protokollDateiId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "atemschutzeinsatz_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "atemschutztrupp" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"atemschutzeinsatzId" UUID NOT NULL,
|
||||||
|
"funkrufname" VARCHAR(60) NOT NULL,
|
||||||
|
"auftrag" VARCHAR(500),
|
||||||
|
"status" "TruppStatus" NOT NULL DEFAULT 'BEREIT',
|
||||||
|
"angelegtUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"einstiegUm" TIMESTAMPTZ(3),
|
||||||
|
"rueckzugUm" TIMESTAMPTZ(3),
|
||||||
|
"drausenUm" TIMESTAMPTZ(3),
|
||||||
|
"rueckzugsdruck" INTEGER,
|
||||||
|
"maydayUm" TIMESTAMPTZ(3),
|
||||||
|
"maydayAufgehobenUm" TIMESTAMPTZ(3),
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "atemschutztrupp_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "truppmitglied" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"truppId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"truppfuehrer" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"geraetId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "truppmitglied_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "druckmessung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"truppId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"druck" INTEGER NOT NULL,
|
||||||
|
"art" "DruckArt" NOT NULL DEFAULT 'ZWISCHEN',
|
||||||
|
"erfasstVonId" UUID,
|
||||||
|
"bemerkung" VARCHAR(300),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "druckmessung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "atemschutz_ereignis" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"atemschutzeinsatzId" UUID NOT NULL,
|
||||||
|
"truppId" UUID,
|
||||||
|
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"art" "EreignisArt" NOT NULL,
|
||||||
|
"text" VARCHAR(500) NOT NULL,
|
||||||
|
"personId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "atemschutz_ereignis_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "g26_untersuchung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"untersuchtAm" DATE NOT NULL,
|
||||||
|
"gueltigBis" DATE NOT NULL,
|
||||||
|
"ergebnis" "G26Ergebnis" NOT NULL DEFAULT 'TAUGLICH',
|
||||||
|
"arzt" VARCHAR(200),
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"nachweisDateiId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "g26_untersuchung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "belastungsuebung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"datum" DATE NOT NULL,
|
||||||
|
"art" "UebungsartAtemschutz" NOT NULL DEFAULT 'UEBUNGSSTRECKE',
|
||||||
|
"ort" VARCHAR(200),
|
||||||
|
"dauerMinuten" INTEGER,
|
||||||
|
"uebungId" UUID,
|
||||||
|
"bemerkung" VARCHAR(1000),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "belastungsuebung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "atemschutzeinsatz_organisationId_endeUm_idx" ON "atemschutzeinsatz"("organisationId", "endeUm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "atemschutzeinsatz_organisationId_beginnUm_idx" ON "atemschutzeinsatz"("organisationId", "beginnUm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "atemschutztrupp_organisationId_atemschutzeinsatzId_idx" ON "atemschutztrupp"("organisationId", "atemschutzeinsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "atemschutztrupp_organisationId_status_idx" ON "atemschutztrupp"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "truppmitglied_organisationId_personId_idx" ON "truppmitglied"("organisationId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "truppmitglied_truppId_personId_key" ON "truppmitglied"("truppId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "druckmessung_organisationId_truppId_zeitpunkt_idx" ON "druckmessung"("organisationId", "truppId", "zeitpunkt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "atemschutz_ereignis_organisationId_atemschutzeinsatzId_zeit_idx" ON "atemschutz_ereignis"("organisationId", "atemschutzeinsatzId", "zeitpunkt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "g26_untersuchung_organisationId_personId_gueltigBis_idx" ON "g26_untersuchung"("organisationId", "personId", "gueltigBis" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "g26_untersuchung_organisationId_gueltigBis_idx" ON "g26_untersuchung"("organisationId", "gueltigBis");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "belastungsuebung_organisationId_personId_datum_idx" ON "belastungsuebung"("organisationId", "personId", "datum" DESC);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_ueberwacherId_fkey" FOREIGN KEY ("ueberwacherId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "atemschutztrupp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "atemschutztrupp_atemschutzeinsatzId_fkey" FOREIGN KEY ("atemschutzeinsatzId") REFERENCES "atemschutzeinsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_truppId_fkey" FOREIGN KEY ("truppId") REFERENCES "atemschutztrupp"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_truppId_fkey" FOREIGN KEY ("truppId") REFERENCES "atemschutztrupp"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_atemschutzeinsatzId_fkey" FOREIGN KEY ("atemschutzeinsatzId") REFERENCES "atemschutzeinsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_truppId_fkey" FOREIGN KEY ("truppId") REFERENCES "atemschutztrupp"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_untersuchung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_untersuchung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_untersuchung_nachweisDateiId_fkey" FOREIGN KEY ("nachweisDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "belastungsuebung" ADD CONSTRAINT "belastungsuebung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "belastungsuebung" ADD CONSTRAINT "belastungsuebung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "belastungsuebung" ADD CONSTRAINT "belastungsuebung_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 5: Mandantentrennung und die Regeln, die das Protokoll tragen.
|
||||||
|
--
|
||||||
|
-- Dieses Modul ist sicherheitsrelevant. Was hier steht, ist im Zweifelsfall
|
||||||
|
-- das, was vor der Unfallkasse zaehlt — deshalb liegen die Regeln in der
|
||||||
|
-- Datenbank und nicht im Anwendungscode, wo ein vergessener Zweig genuegt:
|
||||||
|
--
|
||||||
|
-- 1. Row Level Security fuer die sieben neuen Tabellen.
|
||||||
|
--
|
||||||
|
-- 2. Das Protokoll ist unveraenderlich. Druckmessungen und Ereignisse
|
||||||
|
-- lassen sich anlegen, aber nicht aendern und nicht loeschen. Ein
|
||||||
|
-- Tippfehler wird durch eine neue Messung richtiggestellt, nicht durch
|
||||||
|
-- Ueberschreiben — sonst waere jede Aufzeichnung nachtraeglich
|
||||||
|
-- glaettbar, und genau das darf sie nicht sein.
|
||||||
|
--
|
||||||
|
-- 3. Bedingungen, die Unmoegliches abweisen: ein negativer Druck, ein
|
||||||
|
-- Rueckzug vor dem Einstieg, ein Trupp ohne Zeitfolge.
|
||||||
|
--
|
||||||
|
-- 4. Teilindizes fuer die einzige Abfrage, die im Einsatz im Sekundentakt
|
||||||
|
-- laeuft: welche Trupps sind gerade drin.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'atemschutzeinsatz',
|
||||||
|
'atemschutztrupp',
|
||||||
|
'truppmitglied',
|
||||||
|
'druckmessung',
|
||||||
|
'atemschutz_ereignis',
|
||||||
|
'g26_untersuchung',
|
||||||
|
'belastungsuebung'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Was es nicht geben darf
|
||||||
|
--
|
||||||
|
-- Ein Pressluftatmer haelt 300 bar; alles darueber ist ein Tippfehler, alles
|
||||||
|
-- unter null gibt es nicht. Die Zeitfolge eines Trupps ist immer dieselbe:
|
||||||
|
-- angelegt, eingestiegen, Rueckzug, draussen. Sie darf sich nicht verdrehen.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_druck_moeglich" CHECK (
|
||||||
|
"druck" >= 0 AND "druck" <= 400
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_einstieg_nach_anlage" CHECK (
|
||||||
|
"einstiegUm" IS NULL OR "einstiegUm" >= "angelegtUm"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_rueckzug_nach_einstieg" CHECK (
|
||||||
|
"rueckzugUm" IS NULL OR ("einstiegUm" IS NOT NULL AND "rueckzugUm" >= "einstiegUm")
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_draussen_nach_einstieg" CHECK (
|
||||||
|
"drausenUm" IS NULL OR ("einstiegUm" IS NOT NULL AND "drausenUm" >= "einstiegUm")
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_mayday_aufhebung_danach" CHECK (
|
||||||
|
"maydayAufgehobenUm" IS NULL OR ("maydayUm" IS NOT NULL AND "maydayAufgehobenUm" >= "maydayUm")
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutz_ende_nach_beginn" CHECK (
|
||||||
|
"endeUm" IS NULL OR "endeUm" >= "beginnUm"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutz_intervall_sinnvoll" CHECK (
|
||||||
|
"abfrageIntervall" BETWEEN 60 AND 3600
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_gueltig_nach_untersuchung" CHECK (
|
||||||
|
"gueltigBis" > "untersuchtAm"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Genau ein Truppfuehrer je Trupp
|
||||||
|
--
|
||||||
|
-- Zwei Fuehrer sind keine Kleinigkeit: im Protokoll stuende dann nicht mehr,
|
||||||
|
-- wer verantwortlich war.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE UNIQUE INDEX "truppmitglied_ein_fuehrer" ON "truppmitglied" ("truppId")
|
||||||
|
WHERE "truppfuehrer";
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Teilindizes fuer den laufenden Einsatz
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "atemschutz_laufend" ON "atemschutzeinsatz" ("organisationId", "beginnUm" DESC)
|
||||||
|
WHERE "endeUm" IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX "trupp_drinnen" ON "atemschutztrupp" ("organisationId", "atemschutzeinsatzId")
|
||||||
|
WHERE "status" IN ('IM_EINSATZ', 'RUECKWEG');
|
||||||
|
|
||||||
|
CREATE INDEX "g26_gueltig" ON "g26_untersuchung" ("organisationId", "gueltigBis" DESC)
|
||||||
|
WHERE "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Das Protokoll ist unveraenderlich
|
||||||
|
--
|
||||||
|
-- Zwei Tabellen sind reine Aufzeichnung: was gemessen und was passiert ist.
|
||||||
|
-- Beides wird angelegt und danach nie wieder angefasst. Der Trigger ist die
|
||||||
|
-- zweite Sperre neben dem entzogenen Recht weiter unten — eine davon reicht,
|
||||||
|
-- aber bei einer Aufzeichnung, an der Menschenleben haengen, ist die zweite
|
||||||
|
-- ihren Preis wert.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION atemschutz_protokoll_unveraenderlich()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Das Atemschutzprotokoll ist unveraenderlich. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht durch Aendern (Tabelle %).',
|
||||||
|
TG_TABLE_NAME
|
||||||
|
USING ERRCODE = 'check_violation';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER druckmessung_unveraenderlich
|
||||||
|
BEFORE UPDATE OR DELETE ON "druckmessung"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION atemschutz_protokoll_unveraenderlich();
|
||||||
|
|
||||||
|
CREATE TRIGGER atemschutz_ereignis_unveraenderlich
|
||||||
|
BEFORE UPDATE OR DELETE ON "atemschutz_ereignis"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION atemschutz_protokoll_unveraenderlich();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte fuer die Anwendungsrolle
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||||
|
|
||||||
|
-- Aufzeichnung, kein Datenbestand: anlegen ja, aendern nein.
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Nachtrag zu Phase 5.
|
||||||
|
--
|
||||||
|
-- Der Rueckzugsdruck wird nach dem doppelten Anmarschverbrauch gerechnet:
|
||||||
|
-- so viel Luft, wie der Weg hinein gekostet hat, braucht auch der Weg
|
||||||
|
-- zurueck. Dafuer meldet der Trupp beim Erreichen des Einsatzziels einmal
|
||||||
|
-- seinen Druck — das ist die neue Art ZIEL.
|
||||||
|
--
|
||||||
|
-- Der Ansprechdruck der Warneinrichtung steht je Ueberwachung im Datensatz,
|
||||||
|
-- weil eine Wehr Geraete unterschiedlicher Baujahre im Bestand hat.
|
||||||
|
--
|
||||||
|
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert
|
||||||
|
-- (siehe Kopf der Migration 20260903190219_atemschutz_fwdv7).
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "DruckArt" ADD VALUE 'ZIEL';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "atemschutzeinsatz" ADD COLUMN "warndruck" INTEGER NOT NULL DEFAULT 55;
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
-- Phase 6 — Kartenmodule
|
||||||
|
--
|
||||||
|
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert:
|
||||||
|
-- die Volltext- und GiST-Indizes aus den fruehereren Phasen stehen nicht im
|
||||||
|
-- Schema, weil Prisma sie nicht abbilden kann. Rechte, Mandantentrennung,
|
||||||
|
-- Koordinaten-Trigger und Umkreisindizes stehen in der Migration daneben.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "HydrantTyp" AS ENUM ('UNTERFLUR', 'UEBERFLUR', 'SAUGSTELLE', 'ZISTERNE', 'OFFENES_GEWAESSER', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "HydrantStatus" AS ENUM ('EINSATZBEREIT', 'EINGESCHRAENKT', 'DEFEKT', 'UNBEKANNT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ObjektKategorie" AS ENUM ('SCHULE', 'KINDERGARTEN', 'PFLEGEHEIM', 'KRANKENHAUS', 'VERSAMMLUNGSSTAETTE', 'HOCHHAUS', 'INDUSTRIE', 'LANDWIRTSCHAFT', 'GEFAHRSTOFFLAGER', 'SONDERBAU', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PlanArt" AS ENUM ('UEBERSICHTSPLAN', 'GESCHOSSPLAN', 'UMGEBUNGSPLAN', 'SONDERPLAN', 'ANLAGE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LageelementArt" AS ENUM ('ZEICHEN', 'LINIE', 'FLAECHE', 'TEXT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FoerderpunktArt" AS ENUM ('ENTNAHME', 'VERSTAERKER', 'WEGPUNKT', 'ZIEL');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "standort" ADD COLUMN "breitengrad" DECIMAL(9,6),
|
||||||
|
ADD COLUMN "laengengrad" DECIMAL(9,6);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "hydrant" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"bezeichnung" VARCHAR(120),
|
||||||
|
"typ" "HydrantTyp" NOT NULL DEFAULT 'UNTERFLUR',
|
||||||
|
"status" "HydrantStatus" NOT NULL DEFAULT 'UNBEKANNT',
|
||||||
|
"nennweite" INTEGER,
|
||||||
|
"durchfluss" INTEGER,
|
||||||
|
"strasse" VARCHAR(200),
|
||||||
|
"hausnummer" VARCHAR(20),
|
||||||
|
"ortsteil" VARCHAR(120),
|
||||||
|
"breitengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"laengengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"koordinaten" geography(Point, 4326),
|
||||||
|
"letztePruefung" DATE,
|
||||||
|
"naechstePruefung" DATE,
|
||||||
|
"betreiber" VARCHAR(200),
|
||||||
|
"hinweis" VARCHAR(1000),
|
||||||
|
"fotoDateiId" UUID,
|
||||||
|
"quelle" VARCHAR(40),
|
||||||
|
"externeNummer" VARCHAR(80),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "hydrant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "objekt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"kategorie" "ObjektKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||||
|
"strasse" VARCHAR(200),
|
||||||
|
"hausnummer" VARCHAR(20),
|
||||||
|
"plz" VARCHAR(10),
|
||||||
|
"ort" VARCHAR(120),
|
||||||
|
"breitengrad" DECIMAL(9,6),
|
||||||
|
"laengengrad" DECIMAL(9,6),
|
||||||
|
"koordinaten" geography(Point, 4326),
|
||||||
|
"zufahrt" VARCHAR(2000),
|
||||||
|
"gefahren" VARCHAR(2000),
|
||||||
|
"schluesseldepot" VARCHAR(500),
|
||||||
|
"loeschwasser" VARCHAR(1000),
|
||||||
|
"bemerkung" VARCHAR(2000),
|
||||||
|
"personenzahl" INTEGER,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "objekt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "objektkontakt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"objektId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"funktion" VARCHAR(120),
|
||||||
|
"telefon" VARCHAR(60),
|
||||||
|
"mobil" VARCHAR(60),
|
||||||
|
"jederzeit" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"reihenfolge" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "objektkontakt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "objektplan" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"objektId" UUID NOT NULL,
|
||||||
|
"dateiId" UUID NOT NULL,
|
||||||
|
"art" "PlanArt" NOT NULL DEFAULT 'UEBERSICHTSPLAN',
|
||||||
|
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||||
|
"standAm" DATE,
|
||||||
|
"reihenfolge" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "objektplan_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lagekarte" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID NOT NULL,
|
||||||
|
"breitengrad" DECIMAL(9,6),
|
||||||
|
"laengengrad" DECIMAL(9,6),
|
||||||
|
"zoomstufe" INTEGER,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "lagekarte_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lageelement" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"lagekarteId" UUID NOT NULL,
|
||||||
|
"art" "LageelementArt" NOT NULL DEFAULT 'ZEICHEN',
|
||||||
|
"zeichen" VARCHAR(60),
|
||||||
|
"text" VARCHAR(300),
|
||||||
|
"farbe" VARCHAR(20),
|
||||||
|
"geometrie" JSONB NOT NULL,
|
||||||
|
"drehung" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltVonId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "lageelement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "wasserfoerderung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID,
|
||||||
|
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||||
|
"durchmesser" INTEGER NOT NULL DEFAULT 75,
|
||||||
|
"foerdermenge" INTEGER NOT NULL DEFAULT 800,
|
||||||
|
"ausgangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 8,
|
||||||
|
"eingangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 1.5,
|
||||||
|
"bemerkung" VARCHAR(2000),
|
||||||
|
"geplantVonId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "wasserfoerderung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "foerderpunkt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"wasserfoerderungId" UUID NOT NULL,
|
||||||
|
"reihenfolge" INTEGER NOT NULL,
|
||||||
|
"art" "FoerderpunktArt" NOT NULL DEFAULT 'WEGPUNKT',
|
||||||
|
"bezeichnung" VARCHAR(200),
|
||||||
|
"breitengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"laengengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"hoehe" DECIMAL(7,2),
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "foerderpunkt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hydrant_organisationId_status_idx" ON "hydrant"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hydrant_organisationId_typ_idx" ON "hydrant"("organisationId", "typ");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "hydrant_organisationId_externeNummer_key" ON "hydrant"("organisationId", "externeNummer");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objekt_organisationId_kategorie_idx" ON "objekt"("organisationId", "kategorie");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objekt_organisationId_name_idx" ON "objekt"("organisationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objektkontakt_organisationId_objektId_idx" ON "objektkontakt"("organisationId", "objektId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objektplan_organisationId_objektId_idx" ON "objektplan"("organisationId", "objektId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "lagekarte_einsatzId_key" ON "lagekarte"("einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "lageelement_organisationId_lagekarteId_geloeschtAm_idx" ON "lageelement"("organisationId", "lagekarteId", "geloeschtAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wasserfoerderung_organisationId_einsatzId_idx" ON "wasserfoerderung"("organisationId", "einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "foerderpunkt_organisationId_wasserfoerderungId_idx" ON "foerderpunkt"("organisationId", "wasserfoerderungId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "foerderpunkt_wasserfoerderungId_reihenfolge_key" ON "foerderpunkt"("wasserfoerderungId", "reihenfolge");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objekt" ADD CONSTRAINT "objekt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_dateiId_fkey" FOREIGN KEY ("dateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_lagekarteId_fkey" FOREIGN KEY ("lagekarteId") REFERENCES "lagekarte"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_geplantVonId_fkey" FOREIGN KEY ("geplantVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_wasserfoerderungId_fkey" FOREIGN KEY ("wasserfoerderungId") REFERENCES "wasserfoerderung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 6: Mandantentrennung, Koordinaten und die Indizes für die Karte.
|
||||||
|
--
|
||||||
|
-- Vier Dinge:
|
||||||
|
--
|
||||||
|
-- 1. Row Level Security für die acht neuen Tabellen.
|
||||||
|
--
|
||||||
|
-- 2. Koordinaten doppelt halten. Prisma kann den PostGIS-Typ nicht lesen,
|
||||||
|
-- also stehen Dezimalgrade daneben — und ein Trigger sorgt dafür, dass
|
||||||
|
-- beide nie auseinanderlaufen. Dasselbe Muster wie beim Einsatz aus
|
||||||
|
-- Phase 3, jetzt auch für Standort, Hydrant, Objekt und Foerderpunkt.
|
||||||
|
--
|
||||||
|
-- 3. GiST-Indizes auf den Punkten. Ohne sie ist die Umkreissuche ein
|
||||||
|
-- vollstaendiger Durchlauf der Tabelle — bei tausend Hydranten im
|
||||||
|
-- Ausrueckegebiet auf der Anfahrt zu langsam.
|
||||||
|
--
|
||||||
|
-- 4. Volltextsuche ueber Objekte: der Name allein genuegt nicht, gesucht
|
||||||
|
-- wird auch nach Strasse, Gefahren und Ansprechpartner.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'hydrant',
|
||||||
|
'objekt',
|
||||||
|
'objektkontakt',
|
||||||
|
'objektplan',
|
||||||
|
'lagekarte',
|
||||||
|
'lageelement',
|
||||||
|
'wasserfoerderung',
|
||||||
|
'foerderpunkt'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten
|
||||||
|
--
|
||||||
|
-- Eine Funktion fuer alle Tabellen: sie erwartet die Spalten "breitengrad",
|
||||||
|
-- "laengengrad" und "koordinaten" und laesst sich damit an jede Tabelle
|
||||||
|
-- haengen, die einen Ort hat.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION koordinaten_setzen()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
breite double precision;
|
||||||
|
laenge double precision;
|
||||||
|
BEGIN
|
||||||
|
breite := (to_jsonb(NEW) ->> 'breitengrad')::double precision;
|
||||||
|
laenge := (to_jsonb(NEW) ->> 'laengengrad')::double precision;
|
||||||
|
|
||||||
|
IF breite IS NULL OR laenge IS NULL THEN
|
||||||
|
NEW := jsonb_populate_record(NEW, jsonb_build_object('koordinaten', NULL));
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Reihenfolge beachten: ST_MakePoint nimmt erst die Laenge, dann die Breite.
|
||||||
|
NEW := jsonb_populate_record(
|
||||||
|
NEW,
|
||||||
|
jsonb_build_object(
|
||||||
|
'koordinaten',
|
||||||
|
ST_AsEWKT(ST_SetSRID(ST_MakePoint(laenge, breite), 4326)::geography)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION koordinaten_setzen() IS
|
||||||
|
'Haelt den PostGIS-Punkt einer Tabelle mit ihren Dezimalgraden im Gleichlauf.';
|
||||||
|
|
||||||
|
CREATE TRIGGER standort_koordinaten
|
||||||
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "standort"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||||
|
|
||||||
|
CREATE TRIGGER hydrant_koordinaten
|
||||||
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "hydrant"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||||
|
|
||||||
|
CREATE TRIGGER objekt_koordinaten
|
||||||
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "objekt"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_koordinaten_gueltig" CHECK (
|
||||||
|
"breitengrad" >= -90 AND "breitengrad" <= 90
|
||||||
|
AND "laengengrad" >= -180 AND "laengengrad" <= 180
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "objekt" ADD CONSTRAINT "objekt_koordinaten_gueltig" CHECK (
|
||||||
|
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||||
|
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "standort" ADD CONSTRAINT "standort_koordinaten_gueltig" CHECK (
|
||||||
|
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||||
|
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_koordinaten_gueltig" CHECK (
|
||||||
|
"breitengrad" >= -90 AND "breitengrad" <= 90
|
||||||
|
AND "laengengrad" >= -180 AND "laengengrad" <= 180
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Eine Foerderstrecke ohne Wasser oder mit einem Schlauch, den es nicht gibt.
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_sinnvoll" CHECK (
|
||||||
|
"durchmesser" IN (52, 75, 110)
|
||||||
|
AND "foerdermenge" > 0 AND "foerdermenge" <= 10000
|
||||||
|
AND "ausgangsdruck" > 0 AND "ausgangsdruck" <= 16
|
||||||
|
AND "eingangsdruck" >= 0 AND "eingangsdruck" < "ausgangsdruck"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Indizes fuer die Umkreissuche
|
||||||
|
--
|
||||||
|
-- GiST auf der Geografie: damit beantwortet Postgres "was liegt im Umkreis von
|
||||||
|
-- 300 Metern" ueber den Index statt ueber die ganze Tabelle.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "hydrant_umkreis" ON "hydrant" USING GIST ("koordinaten")
|
||||||
|
WHERE "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX "objekt_umkreis" ON "objekt" USING GIST ("koordinaten")
|
||||||
|
WHERE "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Volltextsuche ueber Objekte
|
||||||
|
--
|
||||||
|
-- Trigram wie bei den Geraeten aus Phase 1: gesucht wird mit Tippfehlern und
|
||||||
|
-- Wortfragmenten, nicht mit vollstaendigen Woertern.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "objekt_suche_name" ON "objekt" USING GIN ("name" gin_trgm_ops);
|
||||||
|
CREATE INDEX "objekt_suche_strasse" ON "objekt" USING GIN ("strasse" gin_trgm_ops);
|
||||||
|
CREATE INDEX "objekt_suche_gefahren" ON "objekt" USING GIN ("gefahren" gin_trgm_ops);
|
||||||
|
CREATE INDEX "hydrant_suche_bezeichnung" ON "hydrant" USING GIN ("bezeichnung" gin_trgm_ops);
|
||||||
|
CREATE INDEX "hydrant_suche_strasse" ON "hydrant" USING GIN ("strasse" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte fuer die Anwendungsrolle
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||