Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b20abcb23 | ||
|
|
14cc4de9b8 | ||
|
|
91742a0dd7 | ||
|
|
2b53556175 | ||
|
|
d70acad4cd | ||
|
|
7196c7110c | ||
|
|
8081131eb7 | ||
|
|
f7ccd9a908 | ||
|
|
12243f177d | ||
|
|
0b8874da26 | ||
|
|
c779266450 | ||
|
|
4cda79434f | ||
|
|
1c1e141666 | ||
|
|
e1480c0c3e | ||
|
|
4e2daaafd4 | ||
|
|
b2b31ed00a | ||
|
|
e500ad6c0e | ||
|
|
791523027f |
@@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true
|
|||||||
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
|
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
|
||||||
SEED_ADMIN_EMAIL=admin@beispielwehr.de
|
SEED_ADMIN_EMAIL=admin@beispielwehr.de
|
||||||
SEED_ADMIN_PASSWORT=Feuerwehr2026
|
SEED_ADMIN_PASSWORT=Feuerwehr2026
|
||||||
|
# Das Konto der Kreisebene aus dem Beispielbestand (Phase 10): Mitglied in der
|
||||||
|
# eigenen Wehr, Aufsicht in der Nachbarwehr. Vorgabe leitet sich aus der
|
||||||
|
# Wehrkennung ab, also kbi@ff-musterhausen.de.
|
||||||
|
# SEED_KBI_EMAIL=
|
||||||
|
# SEED_KBI_PASSWORT=Feuerwehr2026
|
||||||
# bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
|
# bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
|
||||||
AUTH_BCRYPT_ROUNDS=12
|
AUTH_BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
@@ -185,6 +190,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.
|
||||||
@@ -204,14 +260,122 @@ TELEGRAM_ENABLED=false
|
|||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Push über ntfy (Phase 7, optional)
|
||||||
|
#
|
||||||
|
# ntfy ist ein winziger Push-Dienst, den man selbst betreibt: ein Container,
|
||||||
|
# eine App im Store, fertig. Man abonniert ein "Thema" (einen frei gewählten
|
||||||
|
# Namen) und bekommt Meldungen darauf — ohne Konto, ohne Telefonnummer.
|
||||||
|
#
|
||||||
|
# Läuft der Server im eigenen Netz, verlässt keine Nachricht das Gerätehaus.
|
||||||
|
# Deshalb gilt ntfy in FLORIAN als INTERNER Kanal und bekommt die
|
||||||
|
# ausführlichen Texte. Wer stattdessen das öffentliche ntfy.sh einträgt, macht
|
||||||
|
# ihn zu einem externen Dienst wie jeden anderen.
|
||||||
|
#
|
||||||
|
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest mit.
|
||||||
|
# FLORIAN schlägt deshalb Namen mit Zufallsanteil vor.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
NTFY_ENABLED=false
|
||||||
|
# NTFY_URL=http://ntfy:80
|
||||||
|
# Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat.
|
||||||
|
NTFY_TOKEN=
|
||||||
|
NTFY_TIMEOUT=10
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Web-Push in den Browser (Phase 7, optional)
|
||||||
|
#
|
||||||
|
# Der Weg, der ohne App auskommt. Die Meldung läuft über den Push-Dienst des
|
||||||
|
# Browserherstellers (Mozilla, Google, Apple) — verschlüsselt, aber sie
|
||||||
|
# verlässt das Netz der Wehr. Deshalb ist Web-Push ein EXTERNER Kanal und
|
||||||
|
# bekommt nur Kurzfassungen: "3 Prüfungen fällig", nicht welche.
|
||||||
|
#
|
||||||
|
# Schlüsselpaar erzeugen mit: npm run vapid
|
||||||
|
#
|
||||||
|
# ACHTUNG: Beide Schlüssel gehören ins Backup. Ein neuer öffentlicher Schlüssel
|
||||||
|
# macht alle bestehenden Push-Abos wertlos — jeder müsste Push neu erlauben,
|
||||||
|
# und niemand merkt es, die Meldungen bleiben einfach aus.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
WEBPUSH_ENABLED=false
|
||||||
|
WEBPUSH_PUBLIC_KEY=
|
||||||
|
WEBPUSH_PRIVATE_KEY=
|
||||||
|
# Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse.
|
||||||
|
WEBPUSH_KONTAKT=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WhatsApp: bewusst NICHT.
|
||||||
|
#
|
||||||
|
# Es gibt keine WHATSAPP_-Variablen, und das ist eine Entscheidung, keine Lücke.
|
||||||
|
# Die Begründung steht im README unter "Warum kein WhatsApp" — kurz: die
|
||||||
|
# Business-Plattform kostet je Konversation, verlangt einen Vertrag mit Meta
|
||||||
|
# als Auftragsverarbeiter, und die App gleicht das Telefonbuch ab. Für eine
|
||||||
|
# Wehr, deren Daten im Gerätehaus bleiben sollen, ist das der falsche Weg.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Benachrichtigungen und Termine (Phase 7)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Wann der Worker die Erinnerungen anlegt und verschickt (cron, Europe/Berlin).
|
||||||
|
# Bewusst am frühen Morgen und nicht nachts: eine Push-Meldung um drei Uhr
|
||||||
|
# weckt Leute, die am nächsten Tag arbeiten müssen.
|
||||||
|
BENACHRICHTIGUNG_LAUF=0 7 * * *
|
||||||
|
# Wie viele Tage vor einem Termin erinnert wird (kommagetrennt).
|
||||||
|
# "7,1": einmal in der Woche davor, damit man den Abend freihalten kann, und
|
||||||
|
# einmal am Vortag, weil man es bis dahin wieder vergessen hat.
|
||||||
|
TERMIN_ERINNERUNG_TAGE=7,1
|
||||||
|
# Wie oft eine fehlgeschlagene Zustellung wiederholt wird, bevor sie als
|
||||||
|
# fehlgeschlagen stehen bleibt. Stehen bleiben ist Absicht: eine still
|
||||||
|
# verschluckte Benachrichtigung ist schlimmer als eine sichtbar gescheiterte.
|
||||||
|
BENACHRICHTIGUNG_VERSUCHE=3
|
||||||
|
# Wie lange ZUGESTELLTE Benachrichtigungen aufgehoben werden.
|
||||||
|
# Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an.
|
||||||
|
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
|
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
# ES GIBT KEINEN RUECKFALL AUF EINEN ANBIETER. Laeuft der eigene Endpunkt
|
||||||
|
# nicht, fehlen die KI-Funktionen — sie werden nicht durch einen fremden
|
||||||
|
# Dienst ersetzt. Ein Einsatzbericht spricht ueber Menschen in ihrer
|
||||||
|
# schlechtesten Stunde; solche Texte gehoeren nirgendwo anders hin.
|
||||||
|
#
|
||||||
|
# Container starten: docker compose --profile ki up -d
|
||||||
|
# Modelle holen: docker compose exec ollama ollama pull <modell>
|
||||||
LLM_ENABLED=false
|
LLM_ENABLED=false
|
||||||
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
|
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
|
||||||
LLM_BASE_URL=http://ollama:11434/v1
|
LLM_BASE_URL=http://ollama:11434/v1
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODELL=qwen2.5:14b
|
LLM_MODELL=qwen2.5:14b
|
||||||
|
# Zeitlimit fuer eine Antwort. Ein Bericht darf dauern, aber nicht ewig.
|
||||||
|
LLM_TIMEOUT=120
|
||||||
|
|
||||||
|
# Modell fuer Einbettungen (semantische Suche im Wiki).
|
||||||
|
#
|
||||||
|
# ACHTUNG: Die Spalte wikiabschnitt.einbettung ist auf 768 Dimensionen
|
||||||
|
# festgelegt — das liefert nomic-embed-text. Ein Modell mit anderer Laenge
|
||||||
|
# braucht eine Migration; wie, steht im README unter Phase 8.
|
||||||
|
LLM_EINBETTUNG_MODELL=nomic-embed-text
|
||||||
|
LLM_EINBETTUNG_DIMENSIONEN=768
|
||||||
|
|
||||||
|
# Modell, das Bilder lesen kann — fuer die Belegerfassung. Ohne dieses Modell
|
||||||
|
# werden Belege trotzdem abgelegt, die Angaben nur von Hand eingetragen.
|
||||||
|
# LLM_VISION_MODELL=llama3.2-vision
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Spracherkennung fuers Diktat (Phase 8, optional)
|
||||||
|
#
|
||||||
|
# Selbst betriebener faster-whisper im Compose-Profil "ki". Groesse ueber
|
||||||
|
# WHISPER_MODELL: "tiny" laeuft auf jedem Buerorechner, "base" ist der
|
||||||
|
# brauchbare Kompromiss, "small" erkennt Fachbegriffe merklich besser und
|
||||||
|
# will dafuer Zeit oder eine Grafikkarte.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
WHISPER_ENABLED=false
|
||||||
|
WHISPER_URL=http://whisper:8000
|
||||||
|
WHISPER_MODELL=base
|
||||||
|
WHISPER_SPRACHE=de
|
||||||
|
# Zeitlimit fuer eine Aufnahme. Zehn Minuten Sprache brauchen Zeit.
|
||||||
|
WHISPER_TIMEOUT=300
|
||||||
|
# Hoechstlaenge einer Aufnahme in Sekunden.
|
||||||
|
DIKTAT_MAX_SEKUNDEN=600
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Einsätze und Übungen (Phase 3)
|
# Einsätze und Übungen (Phase 3)
|
||||||
@@ -262,3 +426,19 @@ PERSON_ANONYMISIERUNG_TAGE=365
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
CHECKPOINT_DISABLE=1
|
CHECKPOINT_DISABLE=1
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Testwehr fuer die E2E-Tests
|
||||||
|
#
|
||||||
|
# Die Playwright-Tests laufen gegen eine EIGENE Wehr, nicht gegen die
|
||||||
|
# Beispielwehr aus der Anleitung. Sonst wachsen deren Listen bei jedem Lauf,
|
||||||
|
# und die Bildschirmfotos aendern sich, ohne dass sich die Oberflaeche
|
||||||
|
# geaendert haette.
|
||||||
|
#
|
||||||
|
# Vor dem Lauf einmal: npm run db:test-reset
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
E2E_SLUG=ff-teststelle
|
||||||
|
E2E_NAME=Freiwillige Feuerwehr Teststelle
|
||||||
|
E2E_KURZNAME=FF Teststelle
|
||||||
|
E2E_ADMIN_EMAIL=e2e@teststelle.de
|
||||||
|
E2E_ADMIN_PASSWORT=Feuerwehr2026
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Die Pruefung bei jedem Push.
|
||||||
|
#
|
||||||
|
# Bis Phase 10 gab es 581 Unit- und 266 E2E-Tests — und nichts, was sie
|
||||||
|
# ausfuehrte ausser einem Menschen, der daran dachte. In der Sitzung, in der
|
||||||
|
# die Kassenverwaltung entstand, haben die Tests vier echte Fehler gefunden,
|
||||||
|
# zwei davon in Code, der eine Stunde alt war. Genau das soll hier von allein
|
||||||
|
# passieren.
|
||||||
|
#
|
||||||
|
# Zwei Auftraege, bewusst getrennt:
|
||||||
|
#
|
||||||
|
# pruefung typecheck, lint und die Unit-Tests. Braucht eine Datenbank,
|
||||||
|
# weil ein Teil der Tests die Trigger und RLS gegen echtes
|
||||||
|
# Postgres prueft — mit einer Attrappe waeren sie wertlos.
|
||||||
|
#
|
||||||
|
# oberflaeche die E2E-Tests. Dauern eine Dreiviertelstunde und brauchen den
|
||||||
|
# halben Stack, deshalb laufen sie nur auf main und in Pull
|
||||||
|
# Requests, nicht bei jedem Zwischenstand auf einem Zweig.
|
||||||
|
# =============================================================================
|
||||||
|
name: Pruefung
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Dieselben Werte wie in .env.example. Es sind Testdaten in einem
|
||||||
|
# Wegwerf-Container; echte Geheimnisse haben in einer Pipeline nichts zu
|
||||||
|
# suchen und stehen im Betrieb in der .env.
|
||||||
|
POSTGRES_USER: florian
|
||||||
|
POSTGRES_PASSWORD: pruefung
|
||||||
|
POSTGRES_DB: florian
|
||||||
|
POSTGRES_APP_PASSWORD: pruefung_app
|
||||||
|
DATABASE_URL: postgresql://florian_app:pruefung_app@localhost:5432/florian?schema=public
|
||||||
|
DIRECT_DATABASE_URL: postgresql://florian:pruefung@localhost:5432/florian?schema=public
|
||||||
|
AUTH_SECRET: nur-fuer-die-pruefung-nicht-fuer-den-betrieb
|
||||||
|
AUTH_TRUST_HOST: "true"
|
||||||
|
APP_URL: http://localhost:3100
|
||||||
|
S3_ENDPOINT: localhost
|
||||||
|
S3_PORT: "9000"
|
||||||
|
S3_USE_SSL: "false"
|
||||||
|
S3_ACCESS_KEY: florian
|
||||||
|
S3_SECRET_KEY: pruefung_minio
|
||||||
|
S3_BUCKET: florian
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pruefung:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
# Das eigene Postgres-Image statt eines fertigen: FLORIAN braucht
|
||||||
|
# PostGIS **und** pgvector, und kein oeffentliches Image bringt beides
|
||||||
|
# mit. Ein Dienst-Container mit postgres:17 wuerde die Migrationen
|
||||||
|
# durchlassen und erst bei der ersten Umkreissuche scheitern.
|
||||||
|
- name: Datenbank bauen und starten
|
||||||
|
run: |
|
||||||
|
docker build -t florian-postgres:pruefung ./docker/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=$POSTGRES_USER \
|
||||||
|
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||||
|
-e POSTGRES_DB=$POSTGRES_DB \
|
||||||
|
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
|
||||||
|
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
|
||||||
|
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
|
||||||
|
florian-postgres:pruefung
|
||||||
|
|
||||||
|
- name: Redis und MinIO starten
|
||||||
|
run: |
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
docker run -d --name minio -p 9000:9000 \
|
||||||
|
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
|
||||||
|
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
|
||||||
|
minio/minio:latest server /data
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Auf die Datenbank warten
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Postgres kam nicht hoch:"
|
||||||
|
docker logs postgres
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- run: npx prisma generate
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
- name: Migrationen einspielen
|
||||||
|
run: npm run db:migrate
|
||||||
|
|
||||||
|
# Der Seed gehoert dazu: er ist selbst ein Test. Faellt eine Migration
|
||||||
|
# oder ein Standardwert um, merkt man es hier und nicht erst, wenn eine
|
||||||
|
# Wehr ihn zum ersten Mal ausfuehrt.
|
||||||
|
- name: Beispielbestand anlegen
|
||||||
|
run: npm run db:seed
|
||||||
|
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
|
- name: Aufraeumen
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f postgres redis minio || true
|
||||||
|
|
||||||
|
oberflaeche:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Nur wo es sich lohnt: ein voller Lauf dauert etwa 45 Minuten.
|
||||||
|
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Dienste starten
|
||||||
|
run: |
|
||||||
|
docker build -t florian-postgres:pruefung ./docker/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=$POSTGRES_USER \
|
||||||
|
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||||
|
-e POSTGRES_DB=$POSTGRES_DB \
|
||||||
|
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
|
||||||
|
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
|
||||||
|
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
|
||||||
|
florian-postgres:pruefung
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
docker run -d --name minio -p 9000:9000 \
|
||||||
|
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
|
||||||
|
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
|
||||||
|
minio/minio:latest server /data
|
||||||
|
docker run -d --name gotenberg -p 3001:3000 gotenberg/gotenberg:8
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Auf die Datenbank warten
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker logs postgres
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- run: npx prisma generate
|
||||||
|
- run: npm run db:migrate
|
||||||
|
|
||||||
|
# Die E2E-Tests laufen seit Phase 8 gegen eine eigene Wehr, nicht gegen
|
||||||
|
# den Beispielbestand. Das Skript legt beide an.
|
||||||
|
- name: Testwehr anlegen
|
||||||
|
run: npm run db:test-reset
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
- run: npx playwright test
|
||||||
|
env:
|
||||||
|
GOTENBERG_URL: http://localhost:3001
|
||||||
|
|
||||||
|
- name: Fehlerbilder sichern
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-bericht
|
||||||
|
path: |
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Aufraeumen
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f postgres redis minio gotenberg || true
|
||||||
@@ -40,3 +40,6 @@ debug.log
|
|||||||
# Prisma
|
# Prisma
|
||||||
prisma/*.db
|
prisma/*.db
|
||||||
prisma/*.db-journal
|
prisma/*.db-journal
|
||||||
|
|
||||||
|
# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand.
|
||||||
|
sicherungen/
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -57,10 +71,32 @@ x-app-env: &app-env
|
|||||||
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
|
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
|
NTFY_ENABLED: ${NTFY_ENABLED:-false}
|
||||||
|
NTFY_URL: ${NTFY_URL:-}
|
||||||
|
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||||
|
NTFY_TIMEOUT: ${NTFY_TIMEOUT:-10}
|
||||||
|
WEBPUSH_ENABLED: ${WEBPUSH_ENABLED:-false}
|
||||||
|
WEBPUSH_PUBLIC_KEY: ${WEBPUSH_PUBLIC_KEY:-}
|
||||||
|
WEBPUSH_PRIVATE_KEY: ${WEBPUSH_PRIVATE_KEY:-}
|
||||||
|
WEBPUSH_KONTAKT: ${WEBPUSH_KONTAKT:-}
|
||||||
|
BENACHRICHTIGUNG_LAUF: ${BENACHRICHTIGUNG_LAUF:-0 7 * * *}
|
||||||
|
TERMIN_ERINNERUNG_TAGE: ${TERMIN_ERINNERUNG_TAGE:-7,1}
|
||||||
|
BENACHRICHTIGUNG_VERSUCHE: ${BENACHRICHTIGUNG_VERSUCHE:-3}
|
||||||
|
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: ${BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE:-90}
|
||||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||||
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:-}
|
||||||
|
LLM_TIMEOUT: ${LLM_TIMEOUT:-120}
|
||||||
|
LLM_EINBETTUNG_MODELL: ${LLM_EINBETTUNG_MODELL:-nomic-embed-text}
|
||||||
|
LLM_EINBETTUNG_DIMENSIONEN: ${LLM_EINBETTUNG_DIMENSIONEN:-768}
|
||||||
|
LLM_VISION_MODELL: ${LLM_VISION_MODELL:-}
|
||||||
|
WHISPER_ENABLED: ${WHISPER_ENABLED:-false}
|
||||||
|
WHISPER_URL: ${WHISPER_URL:-}
|
||||||
|
WHISPER_MODELL: ${WHISPER_MODELL:-base}
|
||||||
|
WHISPER_SPRACHE: ${WHISPER_SPRACHE:-de}
|
||||||
|
WHISPER_TIMEOUT: ${WHISPER_TIMEOUT:-300}
|
||||||
|
DIKTAT_MAX_SEKUNDEN: ${DIKTAT_MAX_SEKUNDEN:-600}
|
||||||
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
|
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
|
||||||
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
||||||
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
||||||
@@ -118,10 +154,18 @@ services:
|
|||||||
logging: *logging
|
logging: *logging
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PostgreSQL 17 mit PostGIS. Einziger Ort für relationale Daten.
|
# PostgreSQL 17 mit PostGIS und pgvector. Einziger Ort für relationale Daten.
|
||||||
|
#
|
||||||
|
# Eigenes Image, weil FLORIAN beides braucht und es kein offizielles gibt,
|
||||||
|
# das beides mitbringt — siehe docker/postgres/Dockerfile. Beim ersten
|
||||||
|
# `docker compose up` wird es gebaut; bei einer bestehenden Installation
|
||||||
|
# genügt `docker compose build postgres` und ein Neustart des Dienstes. Das
|
||||||
|
# Datenverzeichnis bleibt unberührt, die Hauptversion ändert sich nicht.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:17-3.5
|
build:
|
||||||
|
context: ./docker/postgres
|
||||||
|
image: florian-postgres:${IMAGE_TAG:-17-3.5-pgvector}
|
||||||
container_name: florian-postgres
|
container_name: florian-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -262,6 +306,152 @@ 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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ollama — Sprachmodelle im eigenen Haus (Phase 8).
|
||||||
|
#
|
||||||
|
# Nur mit Profil: docker compose --profile ki up -d
|
||||||
|
#
|
||||||
|
# Es gibt bewusst KEINEN Rückfall auf einen Anbieter. Läuft dieser Container
|
||||||
|
# nicht, fehlen die KI-Funktionen — sie werden nicht durch einen fremden
|
||||||
|
# Dienst ersetzt. Ein Einsatzbericht spricht über Menschen in ihrer
|
||||||
|
# schlechtesten Stunde; solche Texte gehören nirgendwo anders hin.
|
||||||
|
#
|
||||||
|
# Nach dem ersten Start müssen die Modelle geholt werden:
|
||||||
|
#
|
||||||
|
# docker compose exec ollama ollama pull qwen2.5:14b # Text
|
||||||
|
# docker compose exec ollama ollama pull nomic-embed-text # Einbettungen
|
||||||
|
# docker compose exec ollama ollama pull llama3.2-vision # Belege (optional)
|
||||||
|
#
|
||||||
|
# Auf einem Rechner ohne Grafikkarte läuft das Einbettungsmodell noch flott,
|
||||||
|
# das Textmodell zäh und das Vision-Modell praktisch nicht. Deshalb sind alle
|
||||||
|
# drei einzeln konfigurierbar: eine Wehr kann das Wiki durchsuchbar machen,
|
||||||
|
# ohne Berichte schreiben zu lassen.
|
||||||
|
#
|
||||||
|
# Für eine Nvidia-Karte zusätzlich das Compose-Fragment aus dem README
|
||||||
|
# einbinden (deploy.resources.reservations.devices) — es steht nicht fest
|
||||||
|
# hier drin, weil es ohne Karte den Start verhindert.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:0.5.7
|
||||||
|
container_name: florian-ollama
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [ki]
|
||||||
|
environment:
|
||||||
|
# Modelle bleiben geladen, statt nach fünf Minuten aus dem Speicher zu
|
||||||
|
# fallen. Wer zweimal am Tag einen Bericht schreibt, wartet sonst jedes
|
||||||
|
# Mal auf das Nachladen.
|
||||||
|
OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-30m}
|
||||||
|
OLLAMA_HOST: "0.0.0.0:11434"
|
||||||
|
volumes:
|
||||||
|
- ollama-data:/root/.ollama
|
||||||
|
ports:
|
||||||
|
- "${OLLAMA_PORT:-11435}:11434"
|
||||||
|
networks:
|
||||||
|
- intern
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# faster-whisper — Spracherkennung fürs Diktat (Phase 8).
|
||||||
|
#
|
||||||
|
# Nur mit Profil: docker compose --profile ki up -d
|
||||||
|
#
|
||||||
|
# OpenAI-kompatible Schnittstelle, aber vollständig lokal. Das Modell wird
|
||||||
|
# beim ersten Aufruf geholt und dann behalten.
|
||||||
|
#
|
||||||
|
# WHISPER_MODELL steuert die Größe: "tiny" läuft auf jedem Bürorechner,
|
||||||
|
# "base" ist der brauchbare Kompromiss, "small" erkennt Fachbegriffe
|
||||||
|
# merklich besser und will dafür Zeit oder eine Grafikkarte.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
whisper:
|
||||||
|
image: fedirz/faster-whisper-server:latest-cpu
|
||||||
|
container_name: florian-whisper
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [ki]
|
||||||
|
environment:
|
||||||
|
WHISPER__MODEL: ${WHISPER_MODELL:-base}
|
||||||
|
WHISPER__INFERENCE_DEVICE: ${WHISPER_GERAET:-cpu}
|
||||||
|
# Nach zehn Minuten ohne Anfrage den Speicher freigeben. Eine Wehr
|
||||||
|
# diktiert nicht stündlich.
|
||||||
|
WHISPER__TTL: ${WHISPER_TTL:-600}
|
||||||
|
ENABLE_UI: "false"
|
||||||
|
volumes:
|
||||||
|
- whisper-data:/root/.cache/huggingface
|
||||||
|
ports:
|
||||||
|
- "${WHISPER_PORT:-8087}:8000"
|
||||||
|
networks:
|
||||||
|
- intern
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7).
|
||||||
|
#
|
||||||
|
# Nur mit Profil: docker compose --profile push up -d
|
||||||
|
#
|
||||||
|
# Warum das der interessanteste Push-Weg für eine Wehr ist: kein Konto, keine
|
||||||
|
# Telefonnummer, kein Telefonbuchabgleich. Man abonniert in der App ein
|
||||||
|
# Thema — einen frei gewählten Namen — und bekommt Meldungen darauf.
|
||||||
|
#
|
||||||
|
# Der Container ist winzig und speichert standardmäßig nichts dauerhaft:
|
||||||
|
# eine zugestellte Meldung ist erledigt. Wer sie nachlesen können will,
|
||||||
|
# schaltet den Cache ein (siehe ntfy-Dokumentation) — dann liegen die Texte
|
||||||
|
# allerdings auch dort und nicht nur in FLORIAN.
|
||||||
|
#
|
||||||
|
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest
|
||||||
|
# mit. Deshalb schlägt FLORIAN Namen mit Zufallsanteil vor, und deshalb
|
||||||
|
# steht NTFY_TOKEN bereit, falls der eigene ntfy Zugriffsschutz bekommt.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ntfy:
|
||||||
|
image: binwiederhier/ntfy:v2.11.0
|
||||||
|
container_name: florian-ntfy
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [push]
|
||||||
|
command: ["serve"]
|
||||||
|
environment:
|
||||||
|
NTFY_BASE_URL: ${NTFY_OEFFENTLICH:-http://localhost:8086}
|
||||||
|
NTFY_LISTEN_HTTP: ":80"
|
||||||
|
# Keine Anmeldung nötig, um zu lesen und zu schreiben — geschützt ist
|
||||||
|
# das Ding durch die Unerratbarkeit der Themennamen und dadurch, dass es
|
||||||
|
# im eigenen Netz steht. Wer es ins Internet stellt, setzt
|
||||||
|
# NTFY_AUTH_DEFAULT_ACCESS auf "deny-all" und vergibt Token.
|
||||||
|
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_ZUGRIFF:-read-write}
|
||||||
|
NTFY_BEHIND_PROXY: "true"
|
||||||
|
ports:
|
||||||
|
- "${NTFY_PORT:-8086}:80"
|
||||||
|
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.
|
||||||
@@ -338,6 +528,10 @@ volumes:
|
|||||||
redis-data:
|
redis-data:
|
||||||
minio-data:
|
minio-data:
|
||||||
traefik-acme:
|
traefik-acme:
|
||||||
|
# Phase 8: die Modelle. Sie sind mehrere Gigabyte groß und sollen einen
|
||||||
|
# Neustart des Containers überleben — sonst werden sie jedes Mal neu geholt.
|
||||||
|
ollama-data:
|
||||||
|
whisper-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# Nur was von außen erreichbar sein muss, hängt hier drin.
|
# Nur was von außen erreichbar sein muss, hängt hier drin.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres
|
# Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres
|
||||||
# Datenverzeichnis). Richtet ein:
|
# Datenverzeichnis). Richtet ein:
|
||||||
# - die benötigten Erweiterungen (PostGIS, pgcrypto, citext, unaccent, pg_trgm)
|
# - die benötigten Erweiterungen (PostGIS, pgvector, pgcrypto, citext,
|
||||||
|
# unaccent, pg_trgm)
|
||||||
# - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS
|
# - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS
|
||||||
# - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind
|
# - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind
|
||||||
#
|
#
|
||||||
@@ -28,6 +29,7 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'
|
|||||||
CREATE EXTENSION IF NOT EXISTS citext; -- E-Mail ohne Gross/Klein
|
CREATE EXTENSION IF NOT EXISTS citext; -- E-Mail ohne Gross/Klein
|
||||||
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
|
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
|
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector; -- semantische Suche im Wiki
|
||||||
EOSQL
|
EOSQL
|
||||||
|
|
||||||
# Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen
|
# Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# PostgreSQL 17 mit PostGIS **und** pgvector.
|
||||||
|
#
|
||||||
|
# Warum ein eigenes Image: FLORIAN braucht beides. PostGIS seit Phase 6 für
|
||||||
|
# Hydranten und Umkreissuche, pgvector seit Phase 8 für die semantische Suche
|
||||||
|
# im Wiki. Es gibt kein offizielles Image, das beides mitbringt —
|
||||||
|
# postgis/postgis kennt kein vector, pgvector/pgvector kein PostGIS.
|
||||||
|
#
|
||||||
|
# Die Alternative wäre gewesen, die Ähnlichkeitssuche in der Anwendung zu
|
||||||
|
# rechnen: alle Abschnitte laden, Kosinusabstand in TypeScript. Das geht bei
|
||||||
|
# zweihundert Wiki-Seiten und wird bei zweitausend zäh — und es verlegt eine
|
||||||
|
# Aufgabe in die Anwendung, für die die Datenbank einen Index hat.
|
||||||
|
#
|
||||||
|
# Das Basisimage ist Debian mit eingerichtetem PGDG-Repository; die Erweiterung
|
||||||
|
# kommt als fertiges Paket daher, nicht als Übersetzungslauf.
|
||||||
|
# =============================================================================
|
||||||
|
FROM postgis/postgis:17-3.5
|
||||||
|
|
||||||
|
# Version festhalten: ein stillschweigender Sprung auf eine neue pgvector-
|
||||||
|
# Fassung würde beim nächsten Neubau des Images passieren, nicht dann, wenn
|
||||||
|
# jemand darüber entscheidet.
|
||||||
|
ARG PGVECTOR_PAKET=postgresql-17-pgvector
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends "${PGVECTOR_PAKET}"; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Die Erweiterung selbst wird nicht hier angelegt, sondern in der Migration
|
||||||
|
# (CREATE EXTENSION IF NOT EXISTS vector). Grund: eine bestehende Installation
|
||||||
|
# bekommt zwar das neue Image, aber nicht noch einmal den Init-Lauf — das
|
||||||
|
# Datenverzeichnis ist ja nicht leer. Über die Migration greift es in beiden
|
||||||
|
# Fällen.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Aufwandsschätzung
|
# Aufwandsschätzung
|
||||||
|
|
||||||
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
|
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
|
||||||
[Devlog](DEVLOG.md). Stand: 3. September 2026.
|
[Devlog](DEVLOG.md). Stand: 4. September 2026.
|
||||||
|
|
||||||
## Wie hier gerechnet wird
|
## Wie hier gerechnet wird
|
||||||
|
|
||||||
@@ -34,13 +34,15 @@ Faktoren, die in die Schätzung eingehen:
|
|||||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
| 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 | **2:25 h** |
|
| 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 | **2:10 h** |
|
| 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 | **3:00 h** |
|
||||||
| 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 | **2:40 h** |
|
||||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
|
||||||
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen |
|
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
|
||||||
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
|
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
|
||||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** |
|
||||||
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
| 10 | Mandantenfähigkeit | 4–6 PT | 1–1,5 h | **4:20 h** |
|
||||||
|
| 11 | Freigabereife | 5–7 PT | 1,5–2 h | **2:40 h** |
|
||||||
|
| | **Summe** | **132–177 PT** | **30,5–40,5 h** | **35:30 h** |
|
||||||
|
|
||||||
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
|
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
|
||||||
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
||||||
@@ -192,7 +194,7 @@ Zwei Dinge fielen erst beim Tun auf:
|
|||||||
|
|
||||||
## 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 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -211,9 +213,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 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -232,9 +270,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 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -250,9 +315,33 @@ 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** · **Tatsächlich: 3:10 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -260,15 +349,28 @@ Physik mit klaren Formeln und deshalb gut testbar.
|
|||||||
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
|
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
|
||||||
| Abonnements je Person | 1,0 |
|
| Abonnements je Person | 1,0 |
|
||||||
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
|
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
|
||||||
| CalDAV-/ICS-Feed je Person mit Token | 1,5 |
|
| ICS-Feed je Person mit Token | 1,5 |
|
||||||
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
|
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
|
||||||
|
|
||||||
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
|
Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
|
||||||
(`lib/integrations/`), das spart hier etwa einen Personentag.
|
(`lib/integrations/`), das hat etwa einen Personentag gespart.
|
||||||
|
|
||||||
|
Die einzige Phase bisher, die über der geschätzten Sitzungszeit liegt — und
|
||||||
|
zwar an einer Stelle, die in der Aufstellung nicht vorkommt. „Kalender mit
|
||||||
|
Serienterminen" klingt nach einer Datumsrechnung; teuer war die Entscheidung
|
||||||
|
**gegen** eine Regel und **für** ausgeschriebene Termine, und dann die
|
||||||
|
Kalenderarithmetik dahinter: `setMonth(+1)` auf dem 31. Januar ergibt den
|
||||||
|
3. März, der 29. Februar existiert in vier von fünf Jahren nicht, und ein
|
||||||
|
Übungsabend muss über die ganze Serie auf demselben Wochentag bleiben. Das sind
|
||||||
|
drei Tests, die man erst schreibt, wenn man den Fall kennt.
|
||||||
|
|
||||||
|
Die 1,5 PT für den Feed waren dagegen zu hoch angesetzt, weil sie CalDAV
|
||||||
|
unterstellen. ICS ist ein Textformat; der ganze Erzeuger sind hundertsechzig
|
||||||
|
Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
|
||||||
|
|
||||||
## Phase 8 — Lokale KI
|
## Phase 8 — Lokale KI
|
||||||
|
|
||||||
**Vergleichsaufwand: 9–12 PT**
|
**Vergleichsaufwand: 9–12 PT** · **Tatsächlich: 3:40 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -278,10 +380,28 @@ Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
|
|||||||
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
|
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
|
||||||
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
|
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
|
||||||
|
|
||||||
Der Client aus Phase 0 (`lib/integrations/llm.ts`) ist bereits vorhanden. Die
|
Der Client aus Phase 0 (`lib/integrations/llm.ts`) war bereits vorhanden — er
|
||||||
Kennzeichnungspflicht — jede KI-Ausgabe ist als solche erkennbar und muss
|
konnte allerdings nur Text und musste um Einbettungen und Bilder erweitert
|
||||||
bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt —
|
werden.
|
||||||
kostet wenig Zeit, ist aber nicht verhandelbar.
|
|
||||||
|
Die Kennzeichnungspflicht war mit „kostet wenig Zeit" zu günstig eingeschätzt.
|
||||||
|
Sie kostet wenig, wenn man sie als Abzeichen an einer Ansicht versteht. Als
|
||||||
|
Zusicherung — eigener Datensatz je Ausgabe, unveränderlich, genau eine
|
||||||
|
Entscheidung, Trigger und REVOKE dahinter, zwei Protokollansichten — ist sie
|
||||||
|
ein guter halber Personentag, der in der Aufstellung gar nicht vorkommt.
|
||||||
|
|
||||||
|
Zwei weitere Posten fehlen in der Schätzung:
|
||||||
|
|
||||||
|
**Das eigene Postgres-Image.** pgvector und PostGIS gibt es nicht zusammen; es
|
||||||
|
gibt kein offizielles Image mit beidem. Ein Dockerfile, ein Neubau, ein Neustart
|
||||||
|
des Dienstes — eine halbe Stunde, aber sie stand nirgends.
|
||||||
|
|
||||||
|
**Der Betrieb ohne Modell.** Der größte Einzelposten dieser Phase, und in der
|
||||||
|
Aufstellung steht davon keine Zeile. Eine Wehr setzt FLORIAN auf, lange bevor
|
||||||
|
sie über eine Grafikkarte nachdenkt — also muss jede Ansicht ohne Modell
|
||||||
|
vollständig benutzbar bleiben und ehrlich sagen, was fehlt. Das sind vier
|
||||||
|
verschiedene Hinweise an vier Stellen, eine zweite Suche, die immer
|
||||||
|
funktioniert, und vierzehn E2E-Tests, die genau diesen Zustand absichern.
|
||||||
|
|
||||||
## Phase 9 — Kassenverwaltung
|
## Phase 9 — Kassenverwaltung
|
||||||
|
|
||||||
@@ -304,6 +424,41 @@ Die Unveränderlichkeit festgeschriebener Buchungen kostet hier fast nichts —
|
|||||||
das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich
|
das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich
|
||||||
übernehmen.
|
übernehmen.
|
||||||
|
|
||||||
|
**Tatsächlich: 4:10 h** — die längste Phase bisher. Beide Schätzungen oben
|
||||||
|
haben sich bestätigt und zugleich verschoben: MT940 kostete weniger als
|
||||||
|
gedacht, weil beide Formate ohne Fremdbibliothek auskommen. Dafür war die
|
||||||
|
Unveränderlichkeit nicht das Übernehmen eines Musters, sondern drei
|
||||||
|
Trigger mit drei genau begründeten Ausnahmen — und die Ausnahmen sind der
|
||||||
|
Teil, der Zeit gekostet hat. Zwei davon sind erst im Test aufgefallen:
|
||||||
|
der Prüfbericht, der nach dem Festschreiben entsteht, und die
|
||||||
|
DSGVO-Löschung einer Wehr, die an ihren eigenen festgeschriebenen Buchungen
|
||||||
|
scheiterte.
|
||||||
|
|
||||||
|
## Phase 10 — Mandantenfähigkeit
|
||||||
|
|
||||||
|
**Vergleichsaufwand: 4–6 PT** — aus der Kandidatenliste vorgezogen, am
|
||||||
|
05.09.2026 umgesetzt.
|
||||||
|
|
||||||
|
| Baustein | PT |
|
||||||
|
|---|---:|
|
||||||
|
| `Benutzer` und `Mitgliedschaft`, datenerhaltende Umstellung | 1,5 |
|
||||||
|
| Anmeldung: Konto statt Akte, Wehrwechsel im Sitzungstoken | 1,5 |
|
||||||
|
| Kreisebene mit reinen Leserechten | 1,0 |
|
||||||
|
| Wehrwähler und Verwaltung der Installation | 1,5 |
|
||||||
|
|
||||||
|
Die Schätzung ging von „technisch bereits möglich, Oberfläche fehlt" aus —
|
||||||
|
und das war zur Hälfte falsch. Datenmodell und RLS trugen tatsächlich mehrere
|
||||||
|
Wehren, aber die Anmeldung nicht: Passwort und OIDC-Subject hingen an der
|
||||||
|
Personenakte, und die gehört genau einer Wehr. Das war kein Oberflächenthema,
|
||||||
|
sondern ein Umbau am Fundament.
|
||||||
|
|
||||||
|
**Tatsächlich: 4:20 h.** Getragen hat eine Entscheidung ganz am Anfang: die
|
||||||
|
Sitzung behält ihre Form und entsteht nur anders. Ohne sie wären 61 Dateien
|
||||||
|
mit 414 Fundstellen zu ändern gewesen; so waren es acht. Die Zeit ging
|
||||||
|
stattdessen in die Absicherung — zwei Trigger, vier CHECKs und elf
|
||||||
|
Datenbanktests — und das ist die richtige Verteilung für eine Phase, deren
|
||||||
|
einziger Zweck es ist, dass eine Wehr die andere nicht sieht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Was hier nicht drin ist
|
## Was hier nicht drin ist
|
||||||
|
|||||||
@@ -15,7 +15,955 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
|||||||
| Phase 1 — Nachtrag Geräteformular | 0:50 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 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 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||||||
| **Summe** | **7:40 h** | **5** | |
|
| 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 |
|
||||||
|
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
|
||||||
|
| Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
|
||||||
|
| Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
|
||||||
|
| Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 |
|
||||||
|
| Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 |
|
||||||
|
| Phase 11 — Freigabereife | 2:40 h | 1 | 05.09.2026 |
|
||||||
|
| 1.0.0 — Freigabe | 0:50 h | 1 | 06.09.2026 |
|
||||||
|
| **Summe** | **36:20 h** | **15** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-06 · 1.0.0 — Freigabe
|
||||||
|
|
||||||
|
**09:20 – 10:10 · 0:50 h · Version 1.0.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Kein Modul. Zwei Dinge, die zum Betrieb gehören, und eine Entscheidung.
|
||||||
|
|
||||||
|
| Was | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| `update.sh` | Aktualisieren auf einem Linux-Server, in einem Befehl |
|
||||||
|
| `docs/KURZANLEITUNG.md` | Der Einstieg für die Wehr, nach Rollen sortiert |
|
||||||
|
| Version 1.0.0 | Ab hier semantische Versionierung |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Das Update-Skript sichert, bevor es irgendetwas anfasst.** Ein Update ist
|
||||||
|
der häufigste Anlass, eine Sicherung zu brauchen — und der Moment, in dem
|
||||||
|
niemand daran denkt. `--ohne-sicherung` gibt es, aber mit einer Warnung und
|
||||||
|
nicht als Vorgabe.
|
||||||
|
|
||||||
|
**Die Reihenfolge ist so gewählt, dass ein Abbruch harmlos bleibt.** Holen und
|
||||||
|
Bauen passieren, bevor umgeschaltet wird: schlägt der Bau fehl, läuft die alte
|
||||||
|
Fassung unverändert weiter. Erst danach wird umgeschaltet, und ab da nennt die
|
||||||
|
Fehlerbehandlung die Befehle für den Rückweg.
|
||||||
|
|
||||||
|
**Die Kurzanleitung ist aufgabenorientiert, nicht modulorientiert.** Die große
|
||||||
|
Anleitung hat 63 Kapitel und ist nach Funktionsbereichen sortiert — richtig
|
||||||
|
zum Nachschlagen, unbrauchbar zum Anfangen. Die Kurzanleitung fragt stattdessen
|
||||||
|
„was willst du tun": ein Gerät prüfen, einen Kontoauszug einlesen, einen
|
||||||
|
Termin absagen.
|
||||||
|
|
||||||
|
**Sie erklärt bei jedem heiklen Punkt das Warum.** Warum ein abgeschlossenes
|
||||||
|
Prüfprotokoll nicht änderbar ist, warum gegengebucht statt korrigiert wird,
|
||||||
|
warum der Atemschutzhinweis stehen bleibt. Wer den Grund kennt, sucht nicht
|
||||||
|
nach dem Weg drumherum.
|
||||||
|
|
||||||
|
**Die Roadmap hakt nicht alles ab.** Zwei Freigabevoraussetzungen sind
|
||||||
|
organisatorisch und stehen aus — die Abnahme des Löschkonzepts und die
|
||||||
|
Schulung. Sie stehen mit ihrem Stand dort, statt stillschweigend als erledigt
|
||||||
|
zu gelten. Eine Freigabe, die abhakt, was nicht geprüft wurde, ist keine.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
**Die Ursache der Testflackerei gefunden — nach elf Phasen.**
|
||||||
|
|
||||||
|
Beim abschließenden Durchlauf fielen wieder einmal fünf, dann sechs, dann ein
|
||||||
|
Test aus, in wechselnder Besetzung. Dieses Mal habe ich nicht weitergemacht,
|
||||||
|
sondern fünf Läufe hintereinander mitgeschrieben: betroffen waren immer
|
||||||
|
Zusicherungen über Trigger — „Wiki-Fassungen bleiben", „Mandantentrennung",
|
||||||
|
„abgeschlossene Prüfprotokolle".
|
||||||
|
|
||||||
|
`ki-datenbank.test.ts` und `atemschutz-datenbank.test.ts` schalteten beim
|
||||||
|
Aufräumen ihre Trigger mit `ALTER TABLE ... DISABLE TRIGGER USER` ab. Das ist
|
||||||
|
eine Schemaänderung: sie gilt für die ganze Datenbank und für alle. Vitest
|
||||||
|
fährt die Testdateien parallel — während also die eine Datei abräumte, prüfte
|
||||||
|
die andere gerade, dass genau dieser Trigger greift, und sah keinen.
|
||||||
|
|
||||||
|
`SET LOCAL session_replication_role = 'replica'` bewirkt dasselbe, aber nur in
|
||||||
|
der eigenen Transaktion. Danach: fünf Läufe hintereinander 601 grün.
|
||||||
|
|
||||||
|
Rückwirkend erklärt das eine Reihe von Ausfällen, die ich als Flackern
|
||||||
|
abgetan habe — sie waren keines. Die Lehre ist unbequem: „läuft einzeln durch,
|
||||||
|
also Umgebung" ist eine bequeme Erklärung und oft die falsche.
|
||||||
|
|
||||||
|
| Weiteres | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Ein Datenbanktest ließ sich nur einmal ausführen | `mandanten-datenbank.test.ts` räumte nur am Ende auf; ein abgebrochener Lauf legte den nächsten lahm | Räumt jetzt auch am Anfang auf — dasselbe Muster wie in Phase 10 bei `ki-datenbank` |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**`update.sh` ist nicht auf einem Linux-Server erprobt.** Entwickelt wurde
|
||||||
|
unter Windows; geprüft sind die Syntax (`bash -n`), die Hilfe, die
|
||||||
|
Argumentbehandlung und der Wächter gegen unverbuchte Änderungen — alles unter
|
||||||
|
Git Bash. Der eigentliche Ablauf (fetch, build, up, Gesundheitsprüfung) läuft
|
||||||
|
erst dort, wo FLORIAN wirklich steht.
|
||||||
|
|
||||||
|
Das ist keine Nachlässigkeit, sondern die Grenze dieser Umgebung. Beim ersten
|
||||||
|
echten Update gehört `./update.sh --pruefen` vorangestellt: es holt nichts und
|
||||||
|
ändert nichts, zeigt aber, ob Docker, git und die `.env` gefunden werden.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run lint` — ohne Befund
|
||||||
|
- `npm run test` — 601 Tests grün
|
||||||
|
- `bash -n update.sh` — Syntax in Ordnung
|
||||||
|
- `./update.sh --help` und `--quatsch` — Ausgabe und Ablehnung wie vorgesehen
|
||||||
|
- Der Wächter gegen unverbuchte Änderungen greift (an einem schmutzigen
|
||||||
|
Arbeitsverzeichnis geprüft)
|
||||||
|
- Alle acht Bilder der Kurzanleitung existieren
|
||||||
|
- `npm run test` **fünfmal hintereinander** — jedes Mal 601 grün. Das war
|
||||||
|
nach elf Phasen der erste Lauf, bei dem ich das behaupten kann
|
||||||
|
- `npm run test:e2e` — **268 grün, kein Fehlschlag, kein Flackern.** Ebenfalls
|
||||||
|
der erste vollständig saubere Durchlauf in diesem Projekt
|
||||||
|
- `npm run build` — sauber
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-05 · Phase 11 — Freigabereife
|
||||||
|
|
||||||
|
**14:30 – 17:10 · 2:40 h · Version 0.12.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Kein Modul, sondern die drei Software-Voraussetzungen für 1.0.0.
|
||||||
|
|
||||||
|
| Bereich | Umfang |
|
||||||
|
|---|---|
|
||||||
|
| Auskunft | `lib/dsgvo/auskunft.ts` und `bericht.ts`, `person.auskunft`, Dialog auf der Mitgliederseite |
|
||||||
|
| Sicherung | `scripts/sicherung.ts`, `wiederherstellen.ts`, `sicherung-zugang.ts`, `sicherung-sperren.ts` |
|
||||||
|
| Pipeline | `.gitea/workflows/pruefung.yml` mit zwei Aufträgen |
|
||||||
|
| Tests | 18 neue: 9 gegen die Auskunft, 11 gegen die Sicherung |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Auskunft trennt „über mich" von „von mir".** Was über die Person
|
||||||
|
gespeichert ist, steht vollständig drin — auch der gemahnte Beitrag und die
|
||||||
|
nicht bestandene G26. Was die Person *getan* hat, steht als Zahl drin. Die
|
||||||
|
zwölf Prüfprotokolle, die sie unterschrieben hat, gehören zum Nachweisbestand
|
||||||
|
der Wehr; vollständig ausgeliefert käme jeder über eine Selbstauskunft an die
|
||||||
|
Geräteakten, und die Auskunftspflicht wäre eine Hintertür.
|
||||||
|
|
||||||
|
**Über jedem Abschnitt steht der Zweck.** Art. 15 Abs. 1 verlangt nicht nur
|
||||||
|
die Daten, sondern auch Zwecke, Empfänger und den Hinweis auf die weiteren
|
||||||
|
Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine — das ist
|
||||||
|
der Unterschied zwischen einem Datenbankexport und einem Schreiben an einen
|
||||||
|
Menschen.
|
||||||
|
|
||||||
|
**Eine Mutation, keine Abfrage.** Das Erstellen der Auskunft *ist* die
|
||||||
|
Offenlegung, und die gehört ins Protokoll. Als Abfrage würde sie
|
||||||
|
zwischengespeichert und mehrfach ausgeführt — sie wäre entweder nicht oder
|
||||||
|
zwanzigmal vermerkt.
|
||||||
|
|
||||||
|
**Sicherung und Wiederherstellung gehören zusammen.** Deshalb sind es zwei
|
||||||
|
Skripte in einem Zug und nicht eines. Der Probelauf ist die Vorgabe; ohne
|
||||||
|
`--wirklich` geschieht nichts.
|
||||||
|
|
||||||
|
**Eine Liste, zwei Verwendungen.** `sicherung-sperren.ts` treibt sowohl das
|
||||||
|
Wiederherstellen der Sperren als auch deren Prüfung. Zwei Listen wären zwei
|
||||||
|
Wahrheiten, und die falsche fiele erst im Notfall auf.
|
||||||
|
|
||||||
|
**Die Pipeline baut das eigene Postgres-Image.** Ein Dienst-Container mit
|
||||||
|
`postgres:17` ließe die Migrationen durchlaufen und scheiterte erst bei der
|
||||||
|
ersten Umkreissuche — die Pipeline prüfte dann etwas anderes als den Betrieb.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien** | Zuerst `--no-acl` im Dump. Und selbst ohne das Flag greift beim Zurückspielen die Vorgabeprivilegierung aus dem Init-Skript: jede von `pg_restore` neu angelegte Tabelle bekommt alle Rechte, und `GRANT` kann nur hinzufügen | Das Skript setzt die Sperren nach dem Zurückspielen neu und prüft danach nach. Gefunden haben es vier Datenbanktests direkt nach der ersten Rückspielprobe |
|
||||||
|
| `pg_dump` lehnte die Verbindungszeichenkette ab | Prisma hängt `?schema=public` an — „invalid URI query parameter" | Die URL wird zerlegt, Benutzer und Datenbank gehen als Flags, das Passwort über `PGPASSWORD` |
|
||||||
|
| Feldnamen im Auskunftssammler stimmten reihenweise nicht | Aus dem Gedächtnis geschrieben statt aus dem Schema gelesen: `alarmierung` statt `alarmiertUm`, `beginn` statt `beginnUm`, `gemeldetVonId` statt `melderId` | Der Typechecker hat sie alle einzeln benannt — zehn Durchgänge, jeder eine Zeile |
|
||||||
|
| Ein eigener Test war falsch, nicht der Code | `new URL("localhost:5432")` gelingt in Node: „localhost:" gilt als Protokoll | Die Zusicherung prüft jetzt, was wirklich greift — die Protokollprüfung eine Zeile später |
|
||||||
|
| Ein Vorlagentest fiel im vollen Lauf aus | Er klickte auf „Speichern“, bevor React lief — dann verschickt das Formular sich selbst, und die Fehlerfläche entsteht nie. Unter Last dauert das Hydrieren länger. Dasselbe Muster wie beim Anmeldeskript in Phase 8 | Er wartet jetzt auf einen client-seitig erzeugten Knopf, bevor er klickt |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Die Pipeline ist nicht erprobt.** Sie ist geschrieben und syntaktisch
|
||||||
|
geprüft, aber in Gitea war zum Zeitpunkt dieser Phase kein `act_runner`
|
||||||
|
registriert. Ohne Runner erscheinen die Läufe als „wartend" und laufen nie.
|
||||||
|
Ob der Ablauf durchläuft, zeigt sich erst, wenn einer eingerichtet ist — das
|
||||||
|
steht so auch im README, damit niemand sich auf eine Prüfung verlässt, die
|
||||||
|
nicht stattfindet.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run lint` — ohne Befund
|
||||||
|
- `npm run test` — 599 Tests grün, davon 18 neu
|
||||||
|
- Auskunft von Hand erzeugt: 13 Abschnitte, 28 Angaben, 58 kB PDF
|
||||||
|
- **Sicherung und Wiederherstellung vollständig durchgeführt**: ein nach der
|
||||||
|
Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten wieder,
|
||||||
|
und die 599 Tests liefen nach dem Zurückspielen grün — einschließlich derer,
|
||||||
|
die die Sperren prüfen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-05 · Phase 10 — Mandantenfähigkeit
|
||||||
|
|
||||||
|
**08:10 – 12:30 · 4:20 h · Version 0.11.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Die einzige Phase bisher, die die Anmeldung selbst anfasst.
|
||||||
|
|
||||||
|
| Bereich | Umfang |
|
||||||
|
|---|---|
|
||||||
|
| Datenmodell | `Benutzer`, `Mitgliedschaft`, `MitgliedschaftArt`; `Person` verliert die Anmeldefelder |
|
||||||
|
| Absicherung | RLS für `mitgliedschaft`, zwei Trigger, vier CHECKs, ein REVOKE |
|
||||||
|
| Anmeldung | `lib/auth.ts` neu: Konto statt Akte, Mitgliedschaftswahl, Wehrwechsel im Token |
|
||||||
|
| Rechte | Rolle `AUFSICHT`, gefiltert statt aufgezählt; `istVerwalter` als Kontomerkmal |
|
||||||
|
| Oberfläche | Wehrwähler, Aufsichtshinweis, `/wehren` zur Verwaltung |
|
||||||
|
| Tests | 19 neue Unit-Tests (11 gegen die Datenbank), 13 E2E-Tests |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Sitzung behält ihre Form.** `sitzung.organisationId` und
|
||||||
|
`sitzung.personId` werden an 329 bzw. 85 Stellen in 61 Dateien gelesen. Hätte
|
||||||
|
sich ihre Bedeutung geändert, wäre das ein Umbau durch das halbe Projekt
|
||||||
|
geworden. Stattdessen entsteht die Sitzung nur *anders* — aus der aktiven
|
||||||
|
Mitgliedschaft statt aus der Person. Keine dieser 61 Dateien musste angefasst
|
||||||
|
werden.
|
||||||
|
|
||||||
|
**`personId` ist nullbar geworden.** Das war die eine Ausnahme davon, und zwar
|
||||||
|
absichtlich: eine Aufsichtssitzung hat keine Akte. Der Typechecker hat daraus
|
||||||
|
eine Liste gemacht — genau fünf Stellen verlangen eine Person, und alle fünf
|
||||||
|
sind Dinge, an denen die Kreisaufsicht nichts verloren hat (Anmeldemarke,
|
||||||
|
Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz). Ein `?? ""` hätte
|
||||||
|
dieselben fünf Stellen still passieren lassen.
|
||||||
|
|
||||||
|
**`Benutzer` ohne `organisationId`.** Die zweite Ausnahme von der
|
||||||
|
Architekturregel nach `Berechtigung` — und dieselbe Begründung: die Tabelle
|
||||||
|
steht über allen Mandanten, weil sie der Angelpunkt ist, an dem ein Mensch zu
|
||||||
|
mehreren Wehren gehört. Geschützt über Rechte statt RLS.
|
||||||
|
|
||||||
|
**`AUFSICHT` steht nicht im Prisma-Enum.** Alle anderen Rollen werden einem
|
||||||
|
Menschen in einer Wehr zugewiesen. Diese ergibt sich aus der Mitgliedschaftsart.
|
||||||
|
Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an
|
||||||
|
einer anderen verteilen könnte.
|
||||||
|
|
||||||
|
**Ihre Rechte werden gefiltert, nicht aufgezählt.** Alles, was `.lesen` heißt,
|
||||||
|
minus vier ausdrückliche Ausnahmen. Eine Liste von Hand wäre bei Phase 11
|
||||||
|
veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte mit, die niemand
|
||||||
|
nachträgt. So bekommt die Aufsicht neue Leserechte automatisch und neue
|
||||||
|
Schreibrechte nie. Der Test dazu ist entsprechend formuliert: nicht „diese
|
||||||
|
dreizehn Rechte", sondern „kein einziges, das nicht lesen heißt".
|
||||||
|
|
||||||
|
**Die Kasse ist ausgenommen**, obwohl `kasse.lesen` auf `.lesen` endet. Sie
|
||||||
|
gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. Sie wäre nur
|
||||||
|
deshalb dabei gewesen, weil das Recht zufällig richtig heißt — und genau
|
||||||
|
dagegen ist die Ausnahmeliste da.
|
||||||
|
|
||||||
|
**Die Migration ist datenerhaltend, mit Abbruchprüfung.** Konten und
|
||||||
|
Zuordnungen entstehen, *bevor* die alten Spalten fallen; am Ende prüft ein
|
||||||
|
`DO`-Block, dass keine Person mit Anmeldedaten ohne Konto dasteht, und bricht
|
||||||
|
sonst ab. Eine Migration, die die Anmeldung anfasst und auf halbem Weg
|
||||||
|
liegenbleibt, sperrt alle aus.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Nach dem Wehrwechsel landete alles auf der Anmeldeseite | `aktuelleSitzung()` verlangte eine Personenkennung — die Aufsicht hat keine und galt damit als nicht angemeldet | Eine Sitzung ist gültig, wenn Wehr und Konto feststehen; die Akte darf fehlen |
|
||||||
|
| Der Wechsel schien nicht zu greifen | Er griff: das Token trug die neue Wehr. Es war der dritte hängengebliebene Entwicklungsserver dieser Woche — `npm run build` neben laufendem `next dev` macht `.next` unbrauchbar, und der Server antwortet danach nur noch mit 500 | Server beenden, `.next` löschen. Diesmal notiert, damit es nicht ein viertes Mal passiert |
|
||||||
|
| Der Seed brach ab | Er legt die Rollen aus `ROLLEN` an, und dort steht jetzt `AUFSICHT` — die es im Prisma-Enum bewusst nicht gibt | Im Seed gefiltert, mit Begründung |
|
||||||
|
| `unstable_update` nahm den Wechselwunsch nicht an | Es akzeptiert nur `Partial<Session>` | Der Wunsch reist als `user.mitgliedschaftId` und wird serverseitig gegen die Mitgliedschaften geprüft |
|
||||||
|
| Die Nachbarwehr war fest verdrahtet | `ff-nachbarshausen` für jeden Mandanten — genau die Vermischung von Test- und Beispieldaten, die Phase 8 beseitigt hat | Aus dem Mandanten abgeleitet: `<slug>-nachbar` |
|
||||||
|
| Ein alter Datenbanktest wurde unzuverlässig | `ki-datenbank.test.ts` legte Wehr und Person idempotent an, die Wiki-Seite aber nicht. Die neue Testdatei verschob die Reihenfolge, und ein Rest aus einem abgebrochenen Lauf ließ den ganzen Datei-Lauf scheitern | Auch dort ein `upsert` |
|
||||||
|
| Nach `db:test-reset` zeigte der Wehrwähler nur noch die betreute Wehr | Der Reset löschte die Testwehr, aber nicht ihre Nachbarwehr. Der Seed sah die Nachbarwehr, hielt alles für erledigt und legte die Mitgliedschaft in der neuen Testwehr nie wieder an | Der Reset nimmt Nachbarwehr und Kreiskonto mit; der Seed prüft beide Teile getrennt statt mit einem gemeinsamen Ausstieg. Gefunden hat es ein E2E-Test |
|
||||||
|
| Im vollen Lauf fielen fremde Testdateien mit „Anmeldung fehlgeschlagen“ aus | Der Fehlversuch-Test meldete sich mit der Adresse des Administrators an. Fünf Fehlversuche sperren das Konto fünf Minuten — bei zwei Projekten und Wiederholungen kam das zusammen, und alle anderen Dateien brauchen dasselbe Konto | Der Test nimmt jetzt eine Adresse, die es nicht gibt. Die Meldung muss ohnehin dieselbe sein, damit sie nicht verrät, ob es die Adresse gibt |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Zwei Architekturregeln bekommen eine dokumentierte Ausnahme.**
|
||||||
|
|
||||||
|
„Jede Tabelle hat `organisationId`" gilt für `Benutzer` nicht — sie kann es
|
||||||
|
nicht, ohne den Zweck der Tabelle aufzuheben. Die Ausnahme steht im Schema, in
|
||||||
|
der Migration und in der Roadmap, und die Tabelle ist dafür über Rechte
|
||||||
|
geschützt statt über RLS.
|
||||||
|
|
||||||
|
„Berechtigungen zentral in `lib/authz.ts`" gilt für den Verwalterzugang nicht:
|
||||||
|
er hängt an `Benutzer.istVerwalter` und nicht an einem Recht. Rechte gelten je
|
||||||
|
Wehr — ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die
|
||||||
|
Hintertür, die diese Phase gerade schließen soll.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run lint` — ohne Befund
|
||||||
|
- `npm run test` — 581 Tests grün, davon 11 neu gegen eine echte Datenbank
|
||||||
|
- `npm run test:e2e` — grün über Desktop und Tablet, davon 26 neu
|
||||||
|
- `npm run db:seed` und `npm run db:test-reset` — beide Mandanten samt
|
||||||
|
Nachbarwehr und Kreisebene
|
||||||
|
- Anmeldung nach der Umstellung von Hand geprüft: Konto gefunden, Passwort
|
||||||
|
richtig, Sitzung trägt Konto, Mitgliedschaft und Verwaltermerkmal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-04 · Phase 9 — Kassenverwaltung
|
||||||
|
|
||||||
|
**12:40 – 16:50 · 4:10 h · Version 0.10.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Neun Modelle, drei Trigger, zwölf CHECKs, sechs Router, fünf Ansichten und
|
||||||
|
ein Kassenjahr im Seed.
|
||||||
|
|
||||||
|
Der Reihe nach, wie es CLAUDE.md vorgibt: Schema, Migration, Serverlogik, UI,
|
||||||
|
Tests.
|
||||||
|
|
||||||
|
| Bereich | Umfang |
|
||||||
|
|---|---|
|
||||||
|
| Datenmodell | `Kassenkonto`, `Buchungskategorie`, `Buchung`, `Beitragssatz`, `Beitrag`, `Rechnung`, `Rechnungsposition`, `Kassenabschluss`, `Abschlussposten` |
|
||||||
|
| Absicherung | RLS für alle neun Tabellen, drei Trigger, zwölf CHECKs, ein REVOKE |
|
||||||
|
| Bibliothek | `lib/kasse/betrag.ts`, `bankimport.ts`, `vorschlag.ts`, `texte.ts`, `dokumente.ts` |
|
||||||
|
| Router | Konten, Kategorien, Buchungen, Beiträge, Rechnungen, Abschluss |
|
||||||
|
| Oberfläche | Übersicht, Kassenbuch, Beiträge, Rechnungen, Abschluss mit Auswertung |
|
||||||
|
| Hintergrund | Rechnungs-PDF, Kassenprüfbericht, Meldung überfälliger Posten |
|
||||||
|
| Tests | 67 Unit-Tests (13 gegen eine echte Datenbank), 13 E2E-Tests |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Sperren sitzen in der Datenbank, nicht im Router.** Die ganze Phase
|
||||||
|
hängt an einem Satz: was geprüft ist, ändert sich nicht mehr. Ein
|
||||||
|
Zustandscheck in `buchung.aendern` erfüllt das nicht — er gilt für genau
|
||||||
|
diesen einen Weg. Ein zweiter Router, ein Skript, ein Import, und der geprüfte
|
||||||
|
Bestand ist ein anderer, ohne dass irgendwo etwas auffällt. Also drei Trigger:
|
||||||
|
festgeschriebene Buchung unveränderbar, geprüfter Zeitraum zu, festgeschriebener
|
||||||
|
Abschluss fest.
|
||||||
|
|
||||||
|
**Jeder Trigger hat genau eine Ausnahme, und jede hat einen Grund.** Die
|
||||||
|
Buchung lässt den Verweis auf ihre Gegenbuchung eintragen — eine Stornierung
|
||||||
|
entsteht später, sie einzutragen ist keine Änderung des Vorgangs, sondern
|
||||||
|
seine Fortschreibung. Der Abschluss lässt den Prüfbericht nachtragen, denn das
|
||||||
|
PDF entsteht im Hintergrund und damit nach dem Festschreiben. Ohne diese
|
||||||
|
Ausnahme gäbe es nie einen Bericht.
|
||||||
|
|
||||||
|
**Der Bestand wird gerechnet, nie gespeichert.** Ein gespeicherter Bestand
|
||||||
|
neben einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen — und
|
||||||
|
genau danach sucht ein Kassenprüfer. Bei den Größenordnungen einer
|
||||||
|
Feuerwehrkasse kostet das Rechnen nichts.
|
||||||
|
|
||||||
|
**Der Kategorievorschlag geht dem eigenen Bestand vor der Stichwortliste
|
||||||
|
nach.** Wenn eine Wehr Diesel seit Jahren unter „Fahrzeuge" bucht statt unter
|
||||||
|
„Kraftstoff", ist das ihre Entscheidung, und sie soll dabei bleiben dürfen.
|
||||||
|
Die Stichwortliste greift erst, wenn es noch keinen Bestand gibt. Unter der
|
||||||
|
Hälfte gemeinsamer Wörter schweigt der Vorschlag: ein falscher wird
|
||||||
|
übernommen, ohne gelesen zu werden.
|
||||||
|
|
||||||
|
**Der Bankimport erzeugt Entwürfe, keine Buchungen.** Und jeder Umsatz trägt
|
||||||
|
eine Kennung, damit ein zweimal eingelesener Auszug als solcher erkannt wird.
|
||||||
|
Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim
|
||||||
|
Kassenabschluss auf — also drei Monate später.
|
||||||
|
|
||||||
|
**Kein XML-Parser für CAMT.** Gebraucht werden sechs Felder aus einer flachen
|
||||||
|
Struktur, die Datei kommt von der eigenen Bank. Der Parser ist kürzer als die
|
||||||
|
Einbindung, und in einer Anwendung ohne Netzzugang ist jede Abhängigkeit
|
||||||
|
weniger eine Sorge weniger. Namensräume werden ignoriert — genau das, was ein
|
||||||
|
Parser hier auch täte.
|
||||||
|
|
||||||
|
**In der Prüfmaske stehen die gezählten Felder leer.** Der Buchbestand ist zu
|
||||||
|
sehen, weil der Prüfer ihn für die Differenz braucht. Vorbelegt wird nichts:
|
||||||
|
wer erst das Buch sieht und dann zählt, zählt, bis es passt.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Der Abschluss ließ sich nicht mit seinem Bericht versehen | Der Trigger sperrte auch `berichtDateiId` — das PDF entsteht aber erst nach dem Festschreiben | Ausnahme für genau dieses Feld, wie beim Stornierungsverweis der Buchung |
|
||||||
|
| Die Rechnungsnummer war ein Wettlauf | Höchste lesen, dann schreiben: zwei gleichzeitige Rechnungen lesen dieselbe Nummer | Zusammenstoß erkennen und die nächste nehmen; gefunden hat es der parallele E2E-Lauf über zwei Projekte |
|
||||||
|
| Eine Organisation ließ sich nicht mehr löschen | Der DELETE-Trigger der Buchung kannte den Aufbewahrungsschalter nicht, den Audit-Log und Prüfprotokoll haben | `app.audit_bereinigung` auch dort — ohne ihn wäre die DSGVO-Löschung nicht durchführbar |
|
||||||
|
| Der Datenbanktest ließ sich nur einmal ausführen | Dieselbe Ursache: das Aufräumen scheiterte an den festgeschriebenen Buchungen, und der Rest lief im nächsten Lauf gegen einen geprüften Zeitraum | siehe oben |
|
||||||
|
| E2E: „Barkasse" fand sieben Elemente | Das Wort steht auch in Verwendungszwecken | über die Kartenüberschrift angesprochen |
|
||||||
|
| E2E: die erste Maifest-Zeile war die falsche | Absteigend sortiert steht das laufende Jahr oben, und das ist nicht festgeschrieben | eine Buchung genommen, die es nur im Vorjahr gibt — und die Gegenprobe dazu |
|
||||||
|
| Prisma wollte zum zehnten Mal die Trigramm-Indizes aus Phase 1 löschen | Sie stehen in einer Roh-SQL-Migration, nicht im Schema | 30 DropIndex-Blöcke entfernt, Kommentar in die Migration |
|
||||||
|
| Der volle E2E-Lauf meldete quer durch alle Module Fehler | `npm run build` während eines laufenden `next dev`: der Produktionslauf überschreibt `.next`, und der Entwicklungsserver antwortet danach nur noch mit 500 | Entwicklungsserver beenden, `.next` löschen, neu laufen lassen — 239 grün |
|
||||||
|
| Ein E2E-Test aus Phase 0 wurde rot | Er sicherte zu, dass die Kasse **kein** Verweis ist — die Zusicherung ist mit dieser Phase abgelaufen | umgeschrieben: die Kasse ist jetzt ein Verweis. Ein künftiges Modul zum Ausgrauen gibt es nicht mehr |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
Keine. `Phase-9.md` ist vollständig umgesetzt, einschließlich CAMT.053, MT940,
|
||||||
|
Nummernkreis, Kassenprüfbericht und Export.
|
||||||
|
|
||||||
|
Der Beleg-Anhang an einer Buchung nutzt den `Belegentwurf` aus Phase 8 mit —
|
||||||
|
er hat dafür einen `buchungId` bekommen. Fotografieren und Erfassen war
|
||||||
|
bereits da; neu ist nur, dass daraus eine Buchung wird.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run lint` — ohne Befund
|
||||||
|
- `npm run test` — 562 Tests grün, davon 13 neu gegen eine echte Datenbank
|
||||||
|
- `npm run test:e2e` — 239 Tests grün über Desktop und Tablet, davon 26 neue
|
||||||
|
- `npm run build` — fünf neue Routen unter `/kasse`
|
||||||
|
- `npm run db:seed` — Kassenjahr angelegt, Abschluss festgeschrieben
|
||||||
|
- Rechnungs-PDF (41 kB) und Kassenprüfbericht (45 kB) einmal von Hand über
|
||||||
|
Gotenberg erzeugt und in MinIO abgelegt — die beiden Vorlagen haben sonst
|
||||||
|
keinen automatischen Nachweis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-04 · Aufräumarbeiten — Issues #10 bis #12
|
||||||
|
|
||||||
|
**11:15 – 12:35 · 1:20 h**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Drei Punkte, die seit mehreren Phasen im Abschlussbericht standen und in jeder
|
||||||
|
Phase Zeit gekostet haben. Diesmal als Issues angelegt und abgearbeitet.
|
||||||
|
|
||||||
|
| Issue | Kern |
|
||||||
|
|---|---|
|
||||||
|
| #11 | ESLint hatte keine Konfiguration — `npm run lint` prüfte nichts |
|
||||||
|
| #12 | `<div>` in einem `<p>` auf der Einsatzseite, seit Phase 3 |
|
||||||
|
| #10 | Die E2E-Tests schrieben in die Beispielwehr |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**ESLint zuerst.** Er findet die anderen beiden — und tatsächlich hat er
|
||||||
|
gleich zwei Fehler gemeldet, die niemand gesucht hatte: eine Fehlerfläche auf
|
||||||
|
der Karte, die nie etwas anzeigen konnte, und zwei `useMemo` in der Lagekarte,
|
||||||
|
die bei jedem Rendern neu rechneten.
|
||||||
|
|
||||||
|
**Keine Formatierungsregeln.** Die macht Prettier. Zwei Werkzeuge, die sich
|
||||||
|
über Anführungszeichen streiten, lenken von den Regeln ab, auf die es ankommt.
|
||||||
|
|
||||||
|
**Auch keine typgestützten Regeln.** `no-floating-promises` wäre für diesen
|
||||||
|
Stack die wertvollste Regel überhaupt — sie braucht aber den Compiler bei
|
||||||
|
jedem Lauf, und dann dauert der Lauf Minuten statt Sekunden. Bei einem
|
||||||
|
Werkzeug, das vor jedem Commit laufen soll, ist das der falsche Tausch.
|
||||||
|
|
||||||
|
**Das Badge wurde ein `<span>`, nicht der Absatz ein `<div>`.** Die Meldung
|
||||||
|
kam von einer Stelle, aber die Ursache saß im Bauteil: ein Abzeichen ist
|
||||||
|
Fließtext-Inhalt und gehört in ein Inline-Element. So ist auch jede künftige
|
||||||
|
Verwendung in einem Absatz richtig, und die Darstellung ändert sich nicht —
|
||||||
|
`inline-flex` verhält sich in beiden gleich.
|
||||||
|
|
||||||
|
**Eine zweite Wehr statt Aufräumen nach dem Lauf.** Ein abgebrochener Lauf
|
||||||
|
lässt beim Aufräumen einen Bestand zurück, den niemand erwartet. Ein
|
||||||
|
vollständiger Neuaufbau ist der einzige Zustand, den man kennt — und der Seed
|
||||||
|
kann ohnehin schon alles, er brauchte nur eine Kennung aus der Umgebung.
|
||||||
|
|
||||||
|
**Ein Riegel gegen den eigenen Fehler.** `npm run db:test-reset` bricht ab,
|
||||||
|
wenn die Testkennung mit der der Beispielwehr übereinstimmt. Ein Skript, das
|
||||||
|
eine ganze Wehr löscht, braucht eine Stelle, an der es sich weigert.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Zwei Tests fielen nach der Umstellung durch — und das war richtig so | Sie hatten die Verschmutzung gebraucht. `getByText("B 3 Wohnhausbrand", { exact: true })` traf bisher einen Einsatz aus einem früheren Testlauf, weil der kein Objekt hatte; der Einsatz aus dem Seed hat eines und steht in derselben Zelle | Anker am Zellenanfang statt Prüfung auf Gleichheit |
|
||||||
|
| Der Kiosk-Test schrieb weiter in die Beispielwehr | `const WEHR = "ff-musterhausen"` stand fest im Test. Das Terminal merkt sich die Wehr und schreibt Mängel und Fahrten genau dorthin | Kennung aus `wehr.ts`, wie überall sonst |
|
||||||
|
| Ein doppelt deklarierter Bezeichner rutschte durch den Typecheck | `tests/e2e` war in der `tsconfig.json` ausgeschlossen | Ausnahme entfernt. Sie hat nichts gebracht: die Dateien übersetzen sauber |
|
||||||
|
| Die Anmeldung im Bildschirmfoto-Skript lief ins Leere | Bekannt aus Phase 7 — bei kaltem `.next` ist die Seite noch nicht hydriert | War schon behoben; hier nur wieder aufgetreten und bestätigt |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Issues angelegt**, obwohl CLAUDE.md das ausdrücklich verbietet („Niemals ein
|
||||||
|
Issue anlegen"). Auf ausdrückliche Anweisung im Chat — die Regel stammt vom
|
||||||
|
Auftraggeber, und er hat sie für diesen Vorgang aufgehoben.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe, jetzt **einschließlich** `tests/e2e`
|
||||||
|
- `npm run lint` — ohne Beanstandung
|
||||||
|
- `npm run test` — 495 Tests grün
|
||||||
|
- `npx playwright test` — grün gegen die Testwehr
|
||||||
|
- Die Beispielwehr steht danach unverändert auf dem Seed-Stand: 49 Geräte,
|
||||||
|
8 Einsätze, 1 Atemschutzüberwachung, 19 Termine, 4 Wiki-Seiten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-04 · Phase 8 — Lokale KI
|
||||||
|
|
||||||
|
**07:30 – 11:10 · 3:40 h · Version 0.9.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Ein Wiki, das man befragen kann, ein Diktat, ein Berichtsassistent, eine
|
||||||
|
Belegerfassung — und zwei Protokolle, die belegen, was davon eine Maschine
|
||||||
|
getan hat.
|
||||||
|
|
||||||
|
| Bereich | Dateien |
|
||||||
|
|---|---|
|
||||||
|
| Rechnen | `lib/ki/zerlegen.ts`, `beleg-lesen.ts`, `anweisungen.ts`, `wiki.ts`, `vorgang.ts` |
|
||||||
|
| Kanäle | `lib/integrations/llm.ts` (um Einbettungen und Bilder erweitert), `whisper.ts` |
|
||||||
|
| Server | `server/routers/ki/wiki.ts`, `assistent.ts` |
|
||||||
|
| Oberfläche | `app/(app)/wiki/`, `belege/`, `ki-protokoll/`, `protokoll/`; `components/ki/` |
|
||||||
|
| Hintergrund | `worker/ki.ts`, zwei neue Auftragsarten |
|
||||||
|
| Betrieb | `docker/postgres/Dockerfile` (PostGIS **und** pgvector), Ollama und faster-whisper im Profil `ki` |
|
||||||
|
| Tests | 73 Unit, 14 E2E je Browser |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Nichts Maschinelles gelangt ungeprüft in einen Nachweis.** Das ist die Regel
|
||||||
|
aus Phase-8.md, und sie bestimmt den ganzen Aufbau: jede KI-Ausgabe ist zuerst
|
||||||
|
ein eigener Datensatz und nicht ein Feld im Zieldokument. Es gibt keine
|
||||||
|
Funktion, die eine Ausgabe erzeugt und gleich übernimmt — wer das umgehen will,
|
||||||
|
muss Code schreiben, und das fällt in einer Durchsicht auf.
|
||||||
|
|
||||||
|
Die Datenbank hält gegen: Eingabe, Ausgabe, Modell und Art sind nach dem
|
||||||
|
Anlegen unveränderlich, gelöscht wird nicht, und über einen Vorschlag wird
|
||||||
|
genau einmal entschieden. Eine Absicht im Code ist eine Absicht; ein Trigger
|
||||||
|
ist eine Zusicherung.
|
||||||
|
|
||||||
|
**Kein Rückfall auf einen Anbieter.** Fehlt der eigene Endpunkt, fehlt die
|
||||||
|
Funktion. Ein Einsatzbericht spricht über Menschen in ihrer schlechtesten
|
||||||
|
Stunde — solche Texte gehören nirgendwo anders hin als ins Gerätehaus. Und ein
|
||||||
|
Rückfall, der „nur im Notfall" greift, ist genau der Fall, in dem niemand
|
||||||
|
hinsieht.
|
||||||
|
|
||||||
|
**Ohne Modell bleibt alles andere benutzbar.** Das war der größte Einzelposten
|
||||||
|
der Phase und steht in keiner Aufwandsschätzung. Vier Hinweise an vier Stellen,
|
||||||
|
eine zweite Suche, die immer funktioniert, und vierzehn E2E-Tests, die genau
|
||||||
|
diesen Zustand absichern — der Testlauf hat keinen Ollama daneben, und das ist
|
||||||
|
Absicht.
|
||||||
|
|
||||||
|
**Drei Modelle statt einem.** Text, Einbettungen und Bilder sind einzeln
|
||||||
|
konfigurierbar, weil ein Rechner ohne Grafikkarte das Einbettungsmodell noch
|
||||||
|
flott schafft, das Textmodell zäh und das Vision-Modell praktisch nicht. Eine
|
||||||
|
Wehr kann so ihr Wiki durchsuchbar machen, ohne Berichte schreiben zu lassen.
|
||||||
|
|
||||||
|
**Zerlegt wird an den Überschriften, nicht nach Zeichenzahl.** Weil die
|
||||||
|
Fundstelle später unter der Antwort steht, und eine Fundstelle soll ein Kapitel
|
||||||
|
sein. Dazu kommt die Überschriftenkette in den Einbettungstext: ein Abschnitt
|
||||||
|
über „Rückzugsdruck" ohne das Wort „Atemschutz" darüber findet sich sonst
|
||||||
|
nicht, wenn jemand nach Atemschutz fragt.
|
||||||
|
|
||||||
|
**Markdown wird ohne HTML gerendert.** Der Text kommt von Anwendern. Jeder Weg
|
||||||
|
über `dangerouslySetInnerHTML` wäre ein Weg für ein `<script>` in einer
|
||||||
|
Dienstvorschrift; der Renderer baut React-Elemente, und ein spitzes
|
||||||
|
Klammernpaar bleibt ein spitzes Klammernpaar.
|
||||||
|
|
||||||
|
**HNSW statt IVFFlat.** IVFFlat muss auf einem gefüllten Bestand trainiert
|
||||||
|
werden und liefert auf einer frisch angelegten Tabelle schlechte Treffer. Ein
|
||||||
|
Wiki wächst Seite für Seite — es gibt nie den Moment, an dem man den Index
|
||||||
|
einmal aufbaut.
|
||||||
|
|
||||||
|
**Ein eigenes Postgres-Image.** pgvector und PostGIS gibt es nicht zusammen.
|
||||||
|
Die Alternative wäre gewesen, den Kosinusabstand in TypeScript zu rechnen: geht
|
||||||
|
bei zweihundert Wiki-Seiten, wird bei zweitausend zäh, und verlegt eine Aufgabe
|
||||||
|
in die Anwendung, für die die Datenbank einen Index hat.
|
||||||
|
|
||||||
|
**Die Audit-Log-Ansicht kam mit dazu.** Sie stand seit Phase 0 als ausgegrauter
|
||||||
|
Eintrag in der Navigation, markiert mit „ab Phase 8". Mit dem KI-Protokoll
|
||||||
|
daneben wäre es schief geworden: die Wehr könnte nachlesen, was die Maschine
|
||||||
|
getan hat, aber nicht, was Menschen getan haben. Der Router dafür existierte
|
||||||
|
seit Phase 0 — es fehlte nur die Seite.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Ein kurzer Abschnitt wurde unter eine fremde Überschrift gehängt | Die Regel „zu kurz → an den vorherigen anhängen" kannte keine Kapitelgrenze. Unter einer Antwort hätte dann eine Fundstelle gestanden, in der der zitierte Satz gar nicht vorkommt | Zusammengezogen wird nur innerhalb desselben Überschriftenpfads. Gefunden hat es der Test, nicht das Ausprobieren |
|
||||||
|
| `prisma migrate dev` wollte die Datenbank zurücksetzen | Die zurückgerollte Migration aus Phase 7 stand noch in `_prisma_migrations` und galt als „applied but missing" | Die Zeile entfernt. Ein `migrate resolve --rolled-back` räumt sie nicht weg |
|
||||||
|
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1, achte Migration in Folge | 27 DropIndex-Blöcke entfernt, im Kopf der Migration vermerkt |
|
||||||
|
| Die abgeleiteten Typen der Oberfläche waren `{}` | `ReturnType<typeof useQuery>["data"]` funktioniert bei tRPC nicht | `inferRouterOutputs<AppRouter>` — wie schon in Phase 7 |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Das Wiki hat keine Bilder.** Phase-8.md nennt sie nicht, aber man erwartet
|
||||||
|
sie in einem Wiki. Sie fehlen bewusst: ein Bild in einer Seite wäre entweder
|
||||||
|
eine Datei in MinIO mit eigener Rechteprüfung beim Ausliefern, oder ein Verweis
|
||||||
|
auf einen fremden Server — und Letzteres verbietet die Grundregel, dass FLORIAN
|
||||||
|
nichts von außen nachlädt. Nachrüstbar, wenn eine Wehr es vermisst.
|
||||||
|
|
||||||
|
**Kein Streaming der Modellantwort.** Der Bericht erscheint am Stück, nicht Wort
|
||||||
|
für Wort. Das sähe moderner aus, ist hier aber falsch: der Text soll gelesen und
|
||||||
|
gegengezeichnet werden, nicht beim Entstehen bewundert. Und ein halb geschriebener
|
||||||
|
Entwurf, den jemand vorzeitig übernimmt, wäre genau die Lücke, die diese Phase
|
||||||
|
schließen soll.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe
|
||||||
|
- `npm run test` — 495 Tests grün, davon 21 neue gegen eine echte Datenbank
|
||||||
|
- `npx playwright test tests/e2e/wissen.spec.ts` — 14 grün, ohne laufendes Modell
|
||||||
|
- `npm run build` — 56 Routen
|
||||||
|
- `npm run db:seed` — vier Wiki-Seiten mit eigenem Text
|
||||||
|
- `docker compose build postgres` — PostGIS 3.5 und pgvector 0.8.6 im selben Image
|
||||||
|
- `docker compose config` — Ollama und faster-whisper im Profil `ki` prüfen durch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-04 · Phase 7 — Benachrichtigungen und Termine
|
||||||
|
|
||||||
|
**04:10 – 07:20 · 3:10 h · Version 0.8.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Der Jahresplan der Wehr und die Wege, auf denen die Mannschaft davon erfährt.
|
||||||
|
|
||||||
|
| Bereich | Dateien |
|
||||||
|
|---|---|
|
||||||
|
| Rechnen | `lib/termin/serie.ts`, `ics.ts`, `lib/benachrichtigung/vorlagen.ts`, `versand.ts`, `lib/dienstausweis.ts` |
|
||||||
|
| Kanäle | `lib/integrations/mail.ts` (echtes SMTP), `ntfy.ts`, `webpush.ts` |
|
||||||
|
| Server | `server/routers/termin/termin.ts`, `benachrichtigung.ts`, `stammdaten/dienstausweis.ts` |
|
||||||
|
| Öffentlich | `api/kalender/[token]`, `ausweis/[code]`, `api/ausweis/[code]/foto` |
|
||||||
|
| Oberfläche | `app/(app)/termine/`, `benachrichtigungen/`, `ausweis/` |
|
||||||
|
| Hintergrund | `worker/benachrichtigung.ts`, zwei neue Zeitpläne |
|
||||||
|
| Tests | 56 Unit, 17 E2E je Browser |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Niemand wird ungefragt benachrichtigt.** Ein neu eingerichteter Zustellweg
|
||||||
|
hat eine leere Ereignisliste. Es gibt keinen Weg, jemand anderem einen Kanal
|
||||||
|
einzutragen — auch nicht für den Kommandanten. Der Seed legt bewusst *keine*
|
||||||
|
Zustellkanäle an, obwohl das die Beispieldaten hübscher machen würde. Wer eine
|
||||||
|
Wehr ungefragt anmeldet, hat binnen einer Woche eine Wehr, die die
|
||||||
|
Benachrichtigungen abschaltet, und danach kommen auch die wichtigen nicht mehr
|
||||||
|
an.
|
||||||
|
|
||||||
|
**Serientermine werden ausgeschrieben, nicht gerechnet.** Eine Regel wäre
|
||||||
|
weniger Zeilen in der Datenbank und mehr Sonderfälle im Code: der Übungsabend
|
||||||
|
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
|
||||||
|
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Ausgeschrieben ist jede
|
||||||
|
dieser Ausnahmen einfach ein Datensatz, den man ändert. Der Preis sind hundert
|
||||||
|
Zeilen für zwei Jahre — Kleingeld, und die Zahl steht vor dem Anlegen in der
|
||||||
|
Maske.
|
||||||
|
|
||||||
|
**Gerechnet wird auf dem Kalender, nicht in Millisekunden.** „Monatlich" heißt
|
||||||
|
derselbe Tag im nächsten Monat. `setMonth(+1)` auf dem 31. Januar ergibt den
|
||||||
|
3. März, weil der Februar keinen 31. hat — ein Fehler, der in einer
|
||||||
|
Übungsserie erst im Frühjahr auffällt. Fällt der Tag im Zielmonat aus, rutscht
|
||||||
|
der Termin auf den letzten des Monats.
|
||||||
|
|
||||||
|
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird eine
|
||||||
|
Zeile geschrieben, sonst nichts. Der Worker nimmt sich, was auf WARTET steht.
|
||||||
|
Wären es eine Sache, hinge das Speichern eines Termins am Antwortverhalten von
|
||||||
|
Telegram. So ist die schlimmste Folge eines toten Mailservers eine
|
||||||
|
Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
|
||||||
|
|
||||||
|
**Intern bekommt den langen Text, extern die Kurzfassung.** Der Mailserver
|
||||||
|
einer Wehr ist ihr eigener oder der der Gemeinde, und ein selbst betriebener
|
||||||
|
ntfy steht im Gerätehaus — dorthin dürfen Namen, Gerätebezeichnungen und
|
||||||
|
Einsatznummern. Telegram und Web-Push laufen über fremde Server und bekommen
|
||||||
|
„3 Prüfungen fällig", nicht welche. Das steht in `KANAL_EXTERN` an einer
|
||||||
|
Stelle, wird im Test für *jeden* externen Kanal geprüft und in der Oberfläche
|
||||||
|
zweimal gesagt: neben dem Kanal und über dem Vorlagenfeld.
|
||||||
|
|
||||||
|
**Kein CalDAV, sondern ICS.** CalDAV ist ein Schreib-Lese-Protokoll mit
|
||||||
|
Rechteverwaltung und Konfliktauflösung. Gebraucht wird Lesen. Eine Adresse, die
|
||||||
|
eine ICS-Datei ausliefert, kann jeder Kalender — das Format ist eine Liste von
|
||||||
|
Zeilen, und die interessanten Teile (Zeitzone, Umbruch nach 75 Oktetten) stehen
|
||||||
|
in zwanzig Zeilen. Eine Abhängigkeit dafür wäre in einer Anwendung ohne
|
||||||
|
Netzzugang der schlechtere Tausch.
|
||||||
|
|
||||||
|
**Der Ausweis gilt fünf Minuten, die Anmeldemarke eine.** Beide sind mit
|
||||||
|
AUTH_SECRET signiert; was sie trennt, ist der Zweck im signierten Teil. Ohne
|
||||||
|
den ginge das eine als das andere durch, und der Ausweis wäre plötzlich ein
|
||||||
|
Anmeldeweg. Die fünf Minuten sind der Unterschied zwischen „wird gescannt" und
|
||||||
|
„wird vorgezeigt, abgelesen und weggesteckt" — und die Prüfseite nennt den
|
||||||
|
Zeitpunkt der Ausstellung, weil ein Ausweis, der „gültig" sagt ohne zu sagen
|
||||||
|
seit wann, wertlos ist.
|
||||||
|
|
||||||
|
**Fehlgeschlagene Benachrichtigungen werden nicht abgeräumt.** Der
|
||||||
|
Aufbewahrungslauf nimmt sich nur die zugestellten. Eine fehlgeschlagene ist der
|
||||||
|
Hinweis darauf, dass ein Kanal kaputt ist, und der verliert seinen Wert nicht
|
||||||
|
durch Zeitablauf.
|
||||||
|
|
||||||
|
**Erinnert wird um sieben, zugestellt jede Minute.** Eine Push-Meldung um drei
|
||||||
|
Uhr weckt Leute, die am nächsten Tag arbeiten müssen. Eine Absage am
|
||||||
|
Dienstagabend muss dagegen ankommen, solange sie noch etwas nützt.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Die Vorlagenmaske überschrieb den gerade getippten Text | Der Effekt, der den passenden Text lädt, hing an `vorlagen.data`. Wird die Liste im Hintergrund nachgeladen — nach dem Speichern, beim Zurückkommen ins Fenster —, war die Eingabe weg | Merker auf `ereignis:kanal`; geladen wird nur beim Wechsel der Auswahl. Gefunden hat es der E2E-Test, nicht das Ausprobieren |
|
||||||
|
| `personId` wurde zweimal gesetzt und überschrieb sich | Einschränkung und Ausschluss als zwei Objekt-Einträge im selben `where` — der zweite gewinnt still | Beide in eine Bedingung zusammengeführt |
|
||||||
|
| Der Aufräumlauf durfte nicht löschen | `REVOKE DELETE ON "benachrichtigung"` gilt der Anwendungsrolle, und `fuerOrganisation` benutzt genau die | Über `ohneMandantenfilter` mit ausdrücklichem Mandantenfilter in der Bedingung — begründet, wie es die Regel verlangt |
|
||||||
|
| Zwei E2E-Selektoren trafen zu viel | „Absagen" steckt auch in „Termin absagen"; `{titel}` steht im Textfeld *und* auf dem Platzhalterknopf | `exact: true` und Suche innerhalb des Dialogs |
|
||||||
|
| Der Warteliste-Test hing an der Reihenfolge im Seed | Ob der angemeldete Nutzer schon einen Platz hat, entscheidet die Mitgliedsnummer | Test prüft jetzt die Anzeige der Warteliste — die gilt für alle |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Kein CalDAV**, obwohl Phase-7.md „CalDAV-/ICS-Feed" nennt. Begründung oben;
|
||||||
|
sie steht auch im README und in der Roadmap.
|
||||||
|
|
||||||
|
**Termine einer Serie werden einzeln geändert, nicht „alle folgenden".** Der
|
||||||
|
Fall ist selten und mehrdeutig — meint „alle folgenden" auch die, die jemand
|
||||||
|
schon von Hand verschoben hat? Absagen geht dagegen für die ganze Serie, weil
|
||||||
|
das der Fall ist, der wirklich vorkommt (Sommerpause, Bauarbeiten am
|
||||||
|
Gerätehaus).
|
||||||
|
|
||||||
|
**Kein Push für iOS ohne Umweg.** Web-Push funktioniert auf dem iPhone erst,
|
||||||
|
wenn FLORIAN zum Startbildschirm hinzugefügt wurde. Das steht in der
|
||||||
|
Oberfläche; ein eigener Weg dafür wäre eine App im Store, und die gibt es
|
||||||
|
bewusst nicht.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe
|
||||||
|
- `npm run test` — 422 Tests grün, davon 15 neue gegen eine echte Datenbank
|
||||||
|
- `npx playwright test tests/e2e/termine.spec.ts` — 17 grün
|
||||||
|
- `npm run build` — 50 Routen, darunter `/termine`, `/benachrichtigungen`,
|
||||||
|
`/ausweis` und die beiden öffentlichen Endpunkte
|
||||||
|
- `npm run db:seed` — 19 Termine, Warteliste und abgehakte Anwesenheit
|
||||||
|
- `docker compose config` — ntfy im Profil `push` prüft durch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# FLORIAN — Kurzanleitung
|
||||||
|
|
||||||
|
Alles, was man am Anfang braucht, auf ein paar Seiten. Nach Rollen sortiert:
|
||||||
|
such dir den Abschnitt, der zu dir gehört, und lies den Rest später.
|
||||||
|
|
||||||
|
Ausführlich steht alles in der [Anleitung](ANLEITUNG.md) — 63 Kapitel, zum
|
||||||
|
Nachschlagen, nicht zum Durchlesen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für alle
|
||||||
|
|
||||||
|
### Anmelden
|
||||||
|
|
||||||
|
FLORIAN läuft im Browser. Die Adresse bekommst du vom Kommandanten.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Nach der Anmeldung landest du auf der Übersicht: was ansteht, was fällig ist,
|
||||||
|
was gemeldet wurde.
|
||||||
|
|
||||||
|
**Das Passwort gehört nach der ersten Anmeldung geändert.** Oben rechts auf
|
||||||
|
deinen Namen.
|
||||||
|
|
||||||
|
### Was du überall findest
|
||||||
|
|
||||||
|
| Links in der Leiste | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Übersicht** | Die Startseite: Fristen, Mängel, nächste Termine |
|
||||||
|
| **Termine** | Übungsabende, Lehrgänge, Versammlungen — mit Rückmeldung |
|
||||||
|
| **Dienstausweis** | Dein Ausweis mit QR-Code, auch fürs Handy |
|
||||||
|
| **Wissen** | Merkblätter und Dienstanweisungen der Wehr |
|
||||||
|
|
||||||
|
Du siehst nur, was zu deiner Rolle gehört. Fehlt dir etwas, sag dem
|
||||||
|
Kommandanten Bescheid — Rollen werden dort vergeben.
|
||||||
|
|
||||||
|
### Zu einem Termin zurückmelden
|
||||||
|
|
||||||
|
**Termine** öffnen, den Termin anklicken, **Zusagen** oder **Absagen**. Fertig.
|
||||||
|
Wer absagt, kann einen Grund angeben; das hilft dem Ausbilder bei der Planung.
|
||||||
|
|
||||||
|
### Am Terminal im Gerätehaus
|
||||||
|
|
||||||
|
Am Wandtablet meldest du dich mit **PIN oder Ausweiskarte** an — nicht mit
|
||||||
|
E-Mail und Passwort. Deine PIN bekommst du vom Kommandanten.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Dort geht: anmelden zum Dienst, einen Mangel melden, eine Fahrt eintragen,
|
||||||
|
Schutzkleidung zur Reinigung geben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Gerätewart
|
||||||
|
|
||||||
|
Das ist der Bereich, in dem FLORIAN am meisten abnimmt.
|
||||||
|
|
||||||
|
### Der wichtigste Bildschirm: Prüfungen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Unter **Prüfungen** steht, was wann fällig wird. Rot heißt überfällig, gelb
|
||||||
|
heißt demnächst. Die Fristen rechnet FLORIAN selbst aus den Prüfintervallen
|
||||||
|
der Gerätetypen aus — du musst nichts nachhalten.
|
||||||
|
|
||||||
|
### Ein Gerät prüfen
|
||||||
|
|
||||||
|
1. **Prüfungen** → das fällige Gerät anklicken → **Prüfung durchführen**.
|
||||||
|
2. Die Checkliste durchgehen. Sie kommt aus der Wartungsvorlage des Gerätetyps.
|
||||||
|
3. Ergebnis eintragen, unterschreiben, abschließen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Danach entsteht ein **Prüfprotokoll als PDF** — das ist der Nachweis gegenüber
|
||||||
|
der Unfallkasse.
|
||||||
|
|
||||||
|
> **Achtung:** Ein abgeschlossenes Protokoll lässt sich nicht mehr ändern. Das
|
||||||
|
> ist Absicht — ein nachträglich änderbarer Nachweis wäre keiner. Ist etwas
|
||||||
|
> falsch, wird eine neue Prüfung erfasst.
|
||||||
|
|
||||||
|
### Ein Gerät anlegen
|
||||||
|
|
||||||
|
**Geräte** → **Gerät anlegen**. Pflicht sind nur Inventarnummer und
|
||||||
|
Bezeichnung; der Rest kann nachkommen.
|
||||||
|
|
||||||
|
Wähle möglichst einen **Gerätetyp** — daran hängen Prüfintervall und
|
||||||
|
Lebensdauer, und ohne Typ rechnet FLORIAN keine Frist.
|
||||||
|
|
||||||
|
### Einen Mangel bearbeiten
|
||||||
|
|
||||||
|
Mängel kommen meist vom Terminal — jemand meldet sie beim Einrücken.
|
||||||
|
|
||||||
|
**Mängel** → anklicken → jemandem zuweisen → nach der Behebung **erledigen**.
|
||||||
|
Ein Gerät mit einem schweren Mangel wird automatisch gesperrt.
|
||||||
|
|
||||||
|
### Was noch dazugehört
|
||||||
|
|
||||||
|
| Wo | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Rückrufe** | Herstellerrückrufe: welche eurer Geräte betroffen sind |
|
||||||
|
| **Lebensdauer** | Helme, Schläuche, Schutzkleidung mit Höchstalter |
|
||||||
|
| **Fahrzeuge** | Beladung nach Geräteräumen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Atemschutzgerätewart
|
||||||
|
|
||||||
|
> **Der wichtigste Satz zuerst:** Die Überwachungstafel am Fahrzeug bleibt
|
||||||
|
> führend. FLORIAN ergänzt sie, ersetzt sie nicht. Das steht auch dauerhaft
|
||||||
|
> über jeder Ansicht, und es soll dort stehen.
|
||||||
|
|
||||||
|
### Wer darf unter Pressluftatmer?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Atemschutz** zeigt jeden Träger mit seinem Stand: G26, Belastungsübung,
|
||||||
|
Einsatzübung. Wer eine der Voraussetzungen nicht erfüllt, steht rot da — mit
|
||||||
|
Klartext, woran es liegt, nicht nur mit einer Farbe.
|
||||||
|
|
||||||
|
### Eine G26 eintragen
|
||||||
|
|
||||||
|
**Atemschutz** → **Nachweise** → Person wählen → G26 erfassen. Untersuchungsdatum,
|
||||||
|
Ergebnis und Gültigkeit reichen; den Befund speichert FLORIAN bewusst nicht.
|
||||||
|
|
||||||
|
Dasselbe für Belastungsübungen.
|
||||||
|
|
||||||
|
### Eine Überwachung führen
|
||||||
|
|
||||||
|
**Atemschutz** → **Überwachung eröffnen**. Trupp zusammenstellen, Druckwerte
|
||||||
|
in Intervallen eintragen, Rückzugsdruck im Blick behalten. FLORIAN rechnet den
|
||||||
|
Rückzugsdruck und warnt, bevor es knapp wird.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Nach dem Ende entsteht ein vollständiges Protokoll als PDF.
|
||||||
|
|
||||||
|
> Solange ein Trupp im Einsatz ist, lässt sich die Überwachung nicht beenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Kommandanten
|
||||||
|
|
||||||
|
### Mitglieder und Rollen
|
||||||
|
|
||||||
|
**Mitglieder** → Person anklicken → Rollen vergeben.
|
||||||
|
|
||||||
|
Neun Rollen, jede mit festem Rechteumfang. Die wichtigsten: Gerätewart,
|
||||||
|
Atemschutzgerätewart, Kassier, Schriftführer, Ausbilder.
|
||||||
|
|
||||||
|
**Jemand kommt nicht hinein?** Dann hat die Person eine Akte, aber kein Konto.
|
||||||
|
Konten verwaltet, wer den Server betreibt — unter *Wehren und Konten*.
|
||||||
|
|
||||||
|
### Einen Einsatz erfassen
|
||||||
|
|
||||||
|
**Einsätze** → **Einsatz anlegen**. Die sechs Zeitstempel (Alarmierung,
|
||||||
|
Ausrücken, Eintreffen, Einsatzende, Einrücken, Einsatzbereitschaft), die
|
||||||
|
beteiligten Fahrzeuge und die Mannschaft.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Daraus entsteht der **Einsatzbericht als PDF** und die Statistik fürs Jahr.
|
||||||
|
|
||||||
|
### Kostenersatz abrechnen
|
||||||
|
|
||||||
|
Fehlalarm einer Brandmeldeanlage, Öleinsatz mit Verursacher, vermieteter
|
||||||
|
Schulungsraum: **Kasse** → **Rechnungen** → *Rechnung schreiben*. Die Nummer
|
||||||
|
vergibt FLORIAN fortlaufend.
|
||||||
|
|
||||||
|
### Wenn jemand Auskunft über seine Daten verlangt
|
||||||
|
|
||||||
|
Kommt selten vor, hat aber eine Frist von einem Monat.
|
||||||
|
|
||||||
|
**Mitglieder** → Person → **Datenauskunft** → *Auskunft erstellen*. Daraus
|
||||||
|
entsteht ein PDF mit allem, was über die Person gespeichert ist. Ausdrucken
|
||||||
|
und aushändigen, fertig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Kassier
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Buchen
|
||||||
|
|
||||||
|
**Kasse** → **Buchungen** → *Buchung erfassen*. Der Betrag ist immer positiv;
|
||||||
|
ob Einnahme oder Ausgabe, sagen die beiden Knöpfe daneben.
|
||||||
|
|
||||||
|
FLORIAN schlägt eine Kategorie vor — meist die richtige, weil er nachsieht,
|
||||||
|
wie ihr dieselbe Sache zuletzt gebucht habt.
|
||||||
|
|
||||||
|
### Kontoauszug einlesen
|
||||||
|
|
||||||
|
Statt fünfzig Umsätze abzutippen: im Online-Banking den **Umsatzexport**
|
||||||
|
herunterladen (CAMT.053 oder MT940), dann **Kasse** → **Buchungen** →
|
||||||
|
*Kontoauszug*.
|
||||||
|
|
||||||
|
Es entstehen **Entwürfe**, keine fertigen Buchungen — durchsehen musst du sie.
|
||||||
|
Umsätze, die schon in der Kasse stehen, erkennt FLORIAN und hakt sie nicht
|
||||||
|
noch einmal ab.
|
||||||
|
|
||||||
|
### Beiträge
|
||||||
|
|
||||||
|
Einmal im Jahr: **Kasse** → **Beiträge** → *Beitragslauf*. Vorher steht da, wie
|
||||||
|
viele Beiträge entstehen — und wer keinen passenden Satz hat.
|
||||||
|
|
||||||
|
Zahlungen hakst du in der Liste ab; dabei entsteht gleich die Buchung.
|
||||||
|
|
||||||
|
### Festschreiben — und was das bedeutet
|
||||||
|
|
||||||
|
Eine neue Buchung ist erst einmal ein **Entwurf** und änderbar. Mit
|
||||||
|
*Festschreiben* wird sie unveränderbar.
|
||||||
|
|
||||||
|
Ist danach etwas falsch, wird **gegengebucht**: eine zweite Buchung in die
|
||||||
|
andere Richtung, mit Begründung. Beide bleiben stehen. Das sieht nach mehr
|
||||||
|
Aufwand aus und ist genau der Punkt — eine verschwundene Buchung kann ein
|
||||||
|
Kassenprüfer nicht prüfen, eine berichtigte schon.
|
||||||
|
|
||||||
|
### Kassenprüfung
|
||||||
|
|
||||||
|
**Kasse** → **Abschluss** → *Kassenprüfung anlegen*. Links steht der Bestand
|
||||||
|
laut Buch, rechts trägst du ein, was gezählt wurde.
|
||||||
|
|
||||||
|
Bleibt eine Differenz, muss sie erklärt werden — sonst lässt sich der Abschluss
|
||||||
|
nicht festschreiben. In fünf Jahren weiß sonst niemand mehr, woher sie kam.
|
||||||
|
|
||||||
|
Danach entsteht der **Kassenprüfbericht als PDF** mit Unterschriftsfeldern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den, der den Server betreibt
|
||||||
|
|
||||||
|
### Sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sicherung
|
||||||
|
```
|
||||||
|
|
||||||
|
Sichert Datenbank **und** Dateien. Das Ergebnis gehört auf ein anderes Gerät —
|
||||||
|
eine Sicherung auf derselben Festplatte hilft gegen einen Festplattenfehler
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
### Einmal im Jahr proben
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run wiederherstellen -- --quelle sicherungen/<Ordner>
|
||||||
|
```
|
||||||
|
|
||||||
|
So passiert nichts, es wird nur angezeigt, was geschähe. Erst mit `--wirklich`
|
||||||
|
wird zurückgespielt.
|
||||||
|
|
||||||
|
> Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Der Ordner
|
||||||
|
> kann leer sein, der Auszug abgeschnitten — man erfährt es sonst an dem Tag,
|
||||||
|
> an dem man es nicht erfahren will.
|
||||||
|
|
||||||
|
### Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update.sh --pruefen # nachsehen, ob es etwas Neues gibt
|
||||||
|
./update.sh # einspielen
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript sichert von sich aus, bevor es etwas anfasst, und sagt bei einem
|
||||||
|
Fehler, wie der vorherige Stand zurückkommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht geht
|
||||||
|
|
||||||
|
| Was | Woran es meist liegt |
|
||||||
|
|---|---|
|
||||||
|
| **Anmeldung geht nicht** | Nach fünf Fehlversuchen ist das Konto fünf Minuten gesperrt. Kurz warten. |
|
||||||
|
| **Ein Menüpunkt fehlt** | Die Rolle fehlt. Der Kommandant vergibt sie unter *Mitglieder*. |
|
||||||
|
| **Ein PDF entsteht nicht** | Es wird im Hintergrund erzeugt; nach ein paar Sekunden die Seite neu laden. |
|
||||||
|
| **Das Terminal meldet niemanden an** | PIN falsch, oder das Tablet ist nicht mehr eingerichtet. |
|
||||||
|
| **Eine Buchung lässt sich nicht ändern** | Sie ist festgeschrieben. Gegenbuchen. |
|
||||||
|
|
||||||
|
Steht die Antwort nicht dabei: [Anleitung, letztes Kapitel](ANLEITUNG.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*FLORIAN läuft auf einem Server, den die Feuerwehr selbst betreibt. Es gibt
|
||||||
|
keine Telemetrie, keine Cloud und keine Weitergabe an Dritte — die Daten
|
||||||
|
bleiben im Gerätehaus.*
|
||||||
@@ -13,13 +13,15 @@ Phase 0 ████████████████████ fertig
|
|||||||
Phase 1 ████████████████████ fertig 0.2.0
|
Phase 1 ████████████████████ fertig 0.2.0
|
||||||
Phase 2 ████████████████████ fertig 0.3.0
|
Phase 2 ████████████████████ fertig 0.3.0
|
||||||
Phase 3 ████████████████████ fertig 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 ████████████████████ fertig 0.8.0
|
||||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
Phase 8 ████████████████████ fertig 0.9.0
|
||||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
Phase 9 ████████████████████ fertig 0.10.0
|
||||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
Phase 10 ████████████████████ fertig 0.11.0
|
||||||
|
Phase 11 ████████████████████ fertig 0.12.0
|
||||||
|
Freigabe ████████████████████ fertig 1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -97,28 +99,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 · als Nächstes
|
## 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
|
||||||
@@ -128,49 +137,106 @@ 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 · fertig
|
||||||
|
|
||||||
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
|
||||||
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
tippt.
|
||||||
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
|
|
||||||
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken
|
- Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
|
||||||
- CalDAV-/ICS-Feed je Person
|
Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
|
||||||
- Digitaler Dienstausweis mit signiertem QR und Prüfseite
|
- Selbstanmeldung mit Platzbegrenzung und einer Warteliste, die von selbst
|
||||||
|
nachrückt. Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
|
||||||
|
- Anwesenheitsliste über alle aktiven Mitglieder — zum Abhaken am Tablet oder
|
||||||
|
zum Ausdrucken. Zusage und Anwesenheit sind getrennt.
|
||||||
|
- Vier Kanäle hinter Schnittstellen: E-Mail (echtes SMTP statt Platzhalter),
|
||||||
|
ntfy (selbst gehostet), Telegram, Web-Push. Was das Netz der Wehr verlässt,
|
||||||
|
bekommt nur die Kurzfassung.
|
||||||
|
- Vorlagen je Ereignis und Kanal mit Platzhaltern, Vorschau und eingebautem
|
||||||
|
Standardtext für alles.
|
||||||
|
- ICS-Abonnement je Person, Token nur als Hash gespeichert, einzeln widerrufbar.
|
||||||
|
- Digitaler Dienstausweis: signierter QR, fünf Minuten gültig, Prüfseite ohne
|
||||||
|
Anmeldung.
|
||||||
|
|
||||||
|
**Danach kann die Wehr:** ihren Jahresplan führen, Rückmeldungen einsammeln,
|
||||||
|
Anwesenheit nachweisen und jedes Mitglied auf dem Weg erreichen, den es selbst
|
||||||
|
gewählt hat.
|
||||||
|
|
||||||
|
### Kein CalDAV
|
||||||
|
|
||||||
|
CalDAV ist ein Schreib-Lese-Protokoll mit Rechteverwaltung und
|
||||||
|
Konfliktauflösung. Gebraucht wird hier nur Lesen — die Termine entstehen in
|
||||||
|
FLORIAN, nicht im Kalender des Anwenders. Dafür genügt eine Adresse, die eine
|
||||||
|
ICS-Datei ausliefert; jeder Kalender kann das. CalDAV wäre ein Vielfaches an
|
||||||
|
Aufwand für dieselbe Wirkung.
|
||||||
|
|
||||||
### Warum kein WhatsApp
|
### Warum kein WhatsApp
|
||||||
|
|
||||||
Die WhatsApp Business API kostet je Konversation und verlangt einen
|
Die WhatsApp Business API kostet je Konversation und verlangt einen
|
||||||
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
|
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
|
||||||
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller
|
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
|
||||||
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im
|
über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
|
||||||
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben
|
schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
|
||||||
Bedarf ab, ntfy sogar vollständig selbst gehostet.
|
verträgt sich nicht mit einer Anwendung, die bewusst im Gerätehaus steht.
|
||||||
|
Telegram, ntfy und Web-Push decken denselben Bedarf ab, ntfy sogar vollständig
|
||||||
|
selbst gehostet.
|
||||||
|
|
||||||
## Phase 8 — Lokale KI
|
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
|
||||||
|
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts.
|
||||||
|
|
||||||
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
|
## Phase 8 — Lokale KI · fertig
|
||||||
|
|
||||||
- Diktat: Aufnahme im Browser → faster-whisper → Transkript im Berichtsfeld
|
Alles gegen selbst betriebene Modelle, kein externer Anbieter, komplett
|
||||||
- Berichtsassistent: aus Stichpunkten einen Entwurf, immer als Vorschlag
|
abschaltbar.
|
||||||
- Wiki mit pgvector: semantische Suche mit Quellenangabe
|
|
||||||
- Belegerfassung: Foto einer Quittung → Vorschlag für Betrag, Datum, Händler
|
|
||||||
|
|
||||||
Jede KI-Ausgabe ist gekennzeichnet und muss bestätigt werden, bevor sie in ein
|
- Wiki mit pgvector: Dienstvorschriften und Ausbildungsunterlagen als Markdown,
|
||||||
Dokument mit Nachweischarakter gelangt.
|
Wortsuche und semantische Suche nebeneinander, Antwort mit Fundstellenangabe
|
||||||
|
- Diktat: Aufnahme im Browser, faster-whisper, Transkript als Vorschlag
|
||||||
|
- Berichtsassistent: aus Stichpunkten ein Einsatzberichtsentwurf, immer mit
|
||||||
|
Übernehmen/Verwerfen
|
||||||
|
- Belegerfassung: Foto einer Quittung, Vorschlag für Betrag, Datum, Händler —
|
||||||
|
nicht Erkanntes bleibt leer statt geraten zu werden
|
||||||
|
- KI-Protokoll und Audit-Log-Ansicht nebeneinander
|
||||||
|
- Eigenes Postgres-Image mit PostGIS **und** pgvector
|
||||||
|
|
||||||
## Phase 9 — Kassenverwaltung
|
**Danach kann die Wehr:** ihr Wissen ablegen und befragen, Berichte diktieren
|
||||||
|
statt tippen und Belege fotografieren statt abzuschreiben — ohne dass eine
|
||||||
|
Zeile Text das Gerätehaus verlässt.
|
||||||
|
|
||||||
|
### Die Regel, die den Aufbau bestimmt
|
||||||
|
|
||||||
|
Jede KI-Ausgabe ist zuerst ein eigener Datensatz und nicht ein Feld im
|
||||||
|
Zieldokument. Sie ist gekennzeichnet, sie steht daneben statt mittendrin, und
|
||||||
|
sie braucht eine Entscheidung, bevor etwas mit ihr geschieht. Die Datenbank
|
||||||
|
hält gegen: Eingabe, Ausgabe und Modell sind unveränderlich, gelöscht wird
|
||||||
|
nicht, und über einen Vorschlag wird genau einmal entschieden.
|
||||||
|
|
||||||
|
Ohne das ließe sich in einem Jahr nicht mehr sagen, welcher Satz in einem
|
||||||
|
Einsatzbericht von einem Menschen stammt.
|
||||||
|
|
||||||
|
### Kein Rückfall auf einen Anbieter
|
||||||
|
|
||||||
|
Fehlen die Container, fehlt die Funktion — sie wird nicht durch einen fremden
|
||||||
|
Dienst ersetzt. Das ist die einzige Bauart, bei der der Satz „die Daten bleiben
|
||||||
|
im Haus" auch dann noch stimmt, wenn der eigene Server aus ist.
|
||||||
|
|
||||||
|
Dafür bleibt **alles andere ohne Modell vollständig benutzbar**: Wiki lesen und
|
||||||
|
schreiben, Wortsuche, Belege ablegen, beide Protokolle. Wo etwas fehlt, steht
|
||||||
|
ein Satz, der sagt was fehlt und wie es ohne geht.
|
||||||
|
|
||||||
|
## Phase 9 — Kassenverwaltung · fertig
|
||||||
|
|
||||||
Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul
|
Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul
|
||||||
dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`.
|
dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`.
|
||||||
@@ -189,20 +255,92 @@ Kassenabschluss friert seinen Zeitraum ein, danach nimmt die Buchungsmaske
|
|||||||
kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als
|
kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als
|
||||||
Fließkommazahl.
|
Fließkommazahl.
|
||||||
|
|
||||||
**Danach kann die Wehr:** ihre Kasse führen und den Kassenprüfbericht auf
|
Beides sitzt in Triggern und nicht im Router: ein vergessener Zustandscheck,
|
||||||
|
ein Skript oder ein Import würden sonst reichen, um den geprüften Bestand zu
|
||||||
|
ändern, ohne dass irgendwo etwas auffällt.
|
||||||
|
|
||||||
|
**Die Wehr kann jetzt:** ihre Kasse führen und den Kassenprüfbericht auf
|
||||||
Knopfdruck erzeugen.
|
Knopfdruck erzeugen.
|
||||||
|
|
||||||
## 1.0.0 — Freigabe
|
## Phase 10 — Mandantenfähigkeit · fertig
|
||||||
|
|
||||||
|
Am 05.09.2026 ergänzt. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren;
|
||||||
|
was fehlte, war alles darüber.
|
||||||
|
|
||||||
|
- `Benutzer` und `Mitgliedschaft`: das Anmeldekonto ist von der Personenakte
|
||||||
|
gelöst. Ein Mensch, ein Passwort, beliebig viele Wehren.
|
||||||
|
- Wehrwähler in der Seitenleiste — er erscheint nur, wenn es etwas zu wählen
|
||||||
|
gibt.
|
||||||
|
- Die Rolle `AUFSICHT` für die Kreisebene: Kreisbrandrat und
|
||||||
|
Kreisbrandinspektion sehen die betreuten Wehren und ändern nichts.
|
||||||
|
- Verwaltung der Installation unter `/wehren`: Wehren anlegen, Konten führen,
|
||||||
|
Zuordnungen vergeben.
|
||||||
|
|
||||||
|
Die Entscheidung, die alles andere bestimmt: **das Konto trägt keine
|
||||||
|
Mandantenkennung.** Es kann keine tragen — es ist ja der Angelpunkt, an dem
|
||||||
|
ein Mensch zu mehreren Wehren gehört. Geschützt wird es deshalb wie
|
||||||
|
`Berechtigung` über Rechte statt über Row Level Security, und was ein Konto in
|
||||||
|
einer Wehr darf, entscheidet nicht es selbst, sondern seine `Mitgliedschaft` —
|
||||||
|
und die trägt `organisationId` wie jede andere Tabelle.
|
||||||
|
|
||||||
|
**Bewusst nicht dabei:** wehrübergreifende Auswertungen. Eine Zahl über
|
||||||
|
mehrere Wehren hinweg braucht eine abgestimmte Definition, und ohne die ist
|
||||||
|
sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln.
|
||||||
|
|
||||||
|
**Die Wehr kann jetzt:** sich einen Server mit anderen teilen, ohne dass
|
||||||
|
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
|
||||||
|
etwas anfassen zu können.
|
||||||
|
|
||||||
|
## Phase 11 — Freigabereife · fertig
|
||||||
|
|
||||||
|
Am 05.09.2026 umgesetzt. Kein Modul, sondern die drei Software-Voraussetzungen
|
||||||
|
für die Freigabe.
|
||||||
|
|
||||||
|
- **Auskunft nach Art. 15 DSGVO.** Das Recht `person.export` stand seit Phase 0
|
||||||
|
im Katalog und war zugewiesen — umgesetzt war nichts. Ohne Auskunftsseite ist
|
||||||
|
ein Löschkonzept nicht abnehmbar.
|
||||||
|
- **Sicherung und Wiederherstellung** mit einer durchgeführten Rückspielprobe.
|
||||||
|
- **Pipeline** in Gitea Actions: typecheck, lint, Migrationen, Seed und Tests.
|
||||||
|
|
||||||
|
Die Rückspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief
|
||||||
|
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
|
||||||
|
Datenbank entfernt. Gefunden haben es die Datenbanktests unmittelbar danach —
|
||||||
|
also genau die Probe, wegen der 1.0.0 sie verlangt.
|
||||||
|
|
||||||
|
**Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern
|
||||||
|
und ihn nachweislich zurückholen.
|
||||||
|
|
||||||
|
## 1.0.0 — Freigabe · am 06.09.2026 erteilt
|
||||||
|
|
||||||
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
||||||
Echtbetrieb ein. Voraussetzungen:
|
Echtbetrieb ein.
|
||||||
|
|
||||||
- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen
|
**Damit das nicht zur Formalie wird, hier der Stand jeder Voraussetzung — auch
|
||||||
- Sicherungskonzept steht und ist einmal wiederhergestellt worden
|
der offenen.** Eine Freigabe, die alles abhakt, was ohnehin nicht geprüft
|
||||||
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
|
wurde, ist keine.
|
||||||
- Löschkonzept nach DSGVO abgenommen
|
|
||||||
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
|
| Voraussetzung | Stand |
|
||||||
dokumentiert abgeschaltet — oder bewusst stehen gelassen
|
|---|---|
|
||||||
|
| Datenübernahme aus der bisherigen Verwaltung | **entfällt.** Die Wehr pflegt den Bestand von Hand ein; es gibt kein Altsystem, aus dem übernommen würde. Für Mitglieder, Fahrzeuge und Geräte steht der Import-Assistent bereit, falls sich das ändert. |
|
||||||
|
| Sicherungskonzept steht und ist einmal wiederhergestellt worden | **erledigt** mit Phase 11. Die Probe hat dabei einen Fehler gefunden, der sonst erst im Notfall aufgefallen wäre. |
|
||||||
|
| Löschkonzept nach DSGVO | **technisch vollständig** seit Phase 11: Auskunft nach Art. 15, Anonymisierung im Aufbewahrungslauf, konfigurierbare Fristen. Die Abnahme durch die Wehr ist ein organisatorischer Schritt und steht aus. |
|
||||||
|
| Schulung von Kommandant, Gerätewart und Atemschutzgerätewart | **steht aus.** Sie läuft nach der Freigabe; Grundlage ist die [Kurzanleitung](KURZANLEITUNG.md). |
|
||||||
|
| Hinweis zur analogen Atemschutzüberwachung | **bewusst stehen gelassen.** `ATEMSCHUTZ_HINWEIS=true`. FLORIAN bleibt damit ergänzend; die Tafel am Fahrzeug ist führend. Das Abschalten verlangt ohnehin einen Kommandantenbeschluss samt Fundstelle. |
|
||||||
|
|
||||||
|
Die beiden offenen Punkte sind **organisatorisch, nicht technisch**: an der
|
||||||
|
Software hängt nichts davon ab. Sie stehen hier, damit später nachlesbar ist,
|
||||||
|
was zum Zeitpunkt der Freigabe erledigt war und was nicht.
|
||||||
|
|
||||||
|
### Was 1.0.0 bedeutet — und was nicht
|
||||||
|
|
||||||
|
**Bedeutet:** Der Funktionsumfang ist vollständig, die Datenbank ist stabil,
|
||||||
|
Migrationen sind aufwärtskompatibel, und ab hier gilt semantische
|
||||||
|
Versionierung. Ein Update bricht nichts, ohne dass es in der Hauptnummer
|
||||||
|
steht.
|
||||||
|
|
||||||
|
**Bedeutet nicht:** dass keine Fehler mehr da sind. Es bedeutet, dass die
|
||||||
|
Wehr echte Daten hineingeben kann, ohne befürchten zu müssen, sie beim
|
||||||
|
nächsten Update zu verlieren.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,8 +350,11 @@ Am 03.09.2026 entschieden:
|
|||||||
|
|
||||||
| Punkt | Entscheidung |
|
| Punkt | Entscheidung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Kassenmodul | Wird als **Phase 9** ergänzt. Spezifikation in `Phase-9.md`, Aufwand 8–11 PT. |
|
| Kassenmodul | Als **Phase 9** ergänzt und mit 0.10.0 umgesetzt. Spezifikation in `Phase-9.md`. |
|
||||||
| `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. |
|
| `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. |
|
||||||
|
| `Benutzer` ohne `organisationId` | Notwendige Ausnahme, seit Phase 10. Die Tabelle ist der Angelpunkt der Doppelmitgliedschaft — mit Mandantenkennung könnte sie das nicht sein. Geschützt über Rechte statt RLS, wie `Berechtigung`: die Anwendungsrolle darf lesen und die Anmeldefelder fortschreiben, mehr nicht. |
|
||||||
|
| `AUFSICHT` nicht im Prisma-Enum | Die Kreisebene ergibt sich aus der Mitgliedschaftsart, nicht aus einer Rollenzuweisung. Vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an einer anderen verteilen könnte. |
|
||||||
|
| `istVerwalter` als Kontomerkmal | Kein Recht. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die Hintertür, die Phase 10 vermeiden soll. |
|
||||||
| Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. |
|
| Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. |
|
||||||
|
|
||||||
## Weitere Kandidaten für später
|
## Weitere Kandidaten für später
|
||||||
@@ -224,10 +365,8 @@ Am 03.09.2026 entschieden:
|
|||||||
| Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT |
|
| Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT |
|
||||||
| Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT |
|
| Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT |
|
||||||
| Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS |
|
| Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS |
|
||||||
| Mehrere Wehren je Installation | technisch bereits möglich, Oberfläche fehlt | 4–6 PT |
|
|
||||||
|
|
||||||
Die Mandantenfähigkeit ist der Punkt, der ohne Zusatzaufwand am weitesten
|
Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene,
|
||||||
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist
|
Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die
|
||||||
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende
|
gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte
|
||||||
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das
|
Definition dessen, was verglichen werden soll.
|
||||||
der nächste sinnvolle Schritt.
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 754 KiB After Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 965 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1010 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 389 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 628 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 328 KiB |