Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b20abcb23 | ||
|
|
14cc4de9b8 | ||
|
|
91742a0dd7 | ||
|
|
2b53556175 | ||
|
|
d70acad4cd | ||
|
|
7196c7110c | ||
|
|
8081131eb7 | ||
|
|
f7ccd9a908 | ||
|
|
12243f177d | ||
|
|
0b8874da26 | ||
|
|
c779266450 | ||
|
|
4cda79434f | ||
|
|
1c1e141666 | ||
|
|
e1480c0c3e | ||
|
|
4e2daaafd4 |
@@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true
|
||||
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
|
||||
SEED_ADMIN_EMAIL=admin@beispielwehr.de
|
||||
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.
|
||||
AUTH_BCRYPT_ROUNDS=12
|
||||
|
||||
@@ -185,6 +190,42 @@ KIOSK_SPERRDAUER=300
|
||||
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
|
||||
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 —
|
||||
@@ -219,14 +260,122 @@ TELEGRAM_ENABLED=false
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
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)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
|
||||
LLM_BASE_URL=http://ollama:11434/v1
|
||||
LLM_API_KEY=
|
||||
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)
|
||||
@@ -277,3 +426,19 @@ PERSON_ANONYMISIERUNG_TAGE=365
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_TELEMETRY_DISABLED=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/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand.
|
||||
sicherungen/
|
||||
|
||||
@@ -47,6 +47,15 @@ x-app-env: &app-env
|
||||
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
||||
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
||||
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}
|
||||
@@ -62,10 +71,32 @@ x-app-env: &app-env
|
||||
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
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_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
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}
|
||||
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
||||
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
||||
@@ -123,10 +154,18 @@ services:
|
||||
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:
|
||||
image: postgis/postgis:17-3.5
|
||||
build:
|
||||
context: ./docker/postgres
|
||||
image: florian-postgres:${IMAGE_TAG:-17-3.5-pgvector}
|
||||
container_name: florian-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -267,6 +306,152 @@ services:
|
||||
- intern
|
||||
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).
|
||||
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
||||
@@ -343,6 +528,10 @@ volumes:
|
||||
redis-data:
|
||||
minio-data:
|
||||
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:
|
||||
# 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
|
||||
# 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
|
||||
# - 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 unaccent; -- Suche ueber Umlaute hinweg
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
|
||||
CREATE EXTENSION IF NOT EXISTS vector; -- semantische Suche im Wiki
|
||||
EOSQL
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
@@ -34,13 +34,15 @@ Faktoren, die in die Schätzung eingehen:
|
||||
| 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** |
|
||||
| 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 |
|
||||
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
||||
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen |
|
||||
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
|
||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
||||
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
||||
| 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 | **2:40 h** |
|
||||
| 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 | **3:10 h** |
|
||||
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
|
||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 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
|
||||
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
||||
@@ -249,7 +251,7 @@ soll, sind es nicht.
|
||||
|
||||
## Phase 5 — Atemschutz nach FwDV 7
|
||||
|
||||
**Vergleichsaufwand: 14–18 PT**
|
||||
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:40 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -268,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
|
||||
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
|
||||
|
||||
**Vergleichsaufwand: 14–18 PT**
|
||||
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:50 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -286,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
|
||||
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
|
||||
|
||||
**Vergleichsaufwand: 10–13 PT**
|
||||
**Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -296,15 +349,28 @@ Physik mit klaren Formeln und deshalb gut testbar.
|
||||
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
|
||||
| Abonnements je Person | 1,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 |
|
||||
|
||||
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
|
||||
(`lib/integrations/`), das spart hier etwa einen Personentag.
|
||||
Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
|
||||
(`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
|
||||
|
||||
**Vergleichsaufwand: 9–12 PT**
|
||||
**Vergleichsaufwand: 9–12 PT** · **Tatsächlich: 3:40 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -314,10 +380,28 @@ Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
|
||||
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
|
||||
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
|
||||
|
||||
Der Client aus Phase 0 (`lib/integrations/llm.ts`) ist bereits vorhanden. Die
|
||||
Kennzeichnungspflicht — jede KI-Ausgabe ist als solche erkennbar und muss
|
||||
bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt —
|
||||
kostet wenig Zeit, ist aber nicht verhandelbar.
|
||||
Der Client aus Phase 0 (`lib/integrations/llm.ts`) war bereits vorhanden — er
|
||||
konnte allerdings nur Text und musste um Einbettungen und Bilder erweitert
|
||||
werden.
|
||||
|
||||
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
|
||||
|
||||
@@ -340,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
|
||||
ü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
|
||||
|
||||
@@ -16,7 +16,857 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
|
||||
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||||
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **10:40 h** | **6** | |
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.*
|
||||
@@ -14,12 +14,14 @@ Phase 1 ████████████████████ fertig
|
||||
Phase 2 ████████████████████ fertig 0.3.0
|
||||
Phase 3 ████████████████████ fertig 0.4.0
|
||||
Phase 4 ████████████████████ fertig 0.5.0
|
||||
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
|
||||
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
||||
Phase 5 ████████████████████ fertig 0.6.0
|
||||
Phase 6 ████████████████████ fertig 0.7.0
|
||||
Phase 7 ████████████████████ fertig 0.8.0
|
||||
Phase 8 ████████████████████ fertig 0.9.0
|
||||
Phase 9 ████████████████████ fertig 0.10.0
|
||||
Phase 10 ████████████████████ fertig 0.11.0
|
||||
Phase 11 ████████████████████ fertig 0.12.0
|
||||
Freigabe ████████████████████ fertig 1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
@@ -116,14 +118,16 @@ Für die Wandtablets in der Fahrzeughalle.
|
||||
**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.
|
||||
|
||||
## Phase 5 — Atemschutz nach FwDV 7 · als Nächstes
|
||||
## Phase 5 — Atemschutz nach FwDV 7 · fertig
|
||||
|
||||
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
|
||||
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
|
||||
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
||||
|
||||
- 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
|
||||
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
|
||||
- Mayday-Knopf je Trupp
|
||||
@@ -133,49 +137,106 @@ eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
||||
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
|
||||
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
|
||||
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
||||
GeoJSON und Stadtwerke-CSV
|
||||
- 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
|
||||
|
||||
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
||||
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.
|
||||
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
||||
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
|
||||
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken
|
||||
- CalDAV-/ICS-Feed je Person
|
||||
- Digitaler Dienstausweis mit signiertem QR und Prüfseite
|
||||
Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
|
||||
tippt.
|
||||
|
||||
- Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
|
||||
Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
|
||||
- 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
|
||||
|
||||
Die WhatsApp Business API kostet je Konversation und verlangt einen
|
||||
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
|
||||
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller
|
||||
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im
|
||||
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben
|
||||
Bedarf ab, ntfy sogar vollständig selbst gehostet.
|
||||
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
|
||||
über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
|
||||
schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
|
||||
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
|
||||
- Berichtsassistent: aus Stichpunkten einen Entwurf, immer als Vorschlag
|
||||
- Wiki mit pgvector: semantische Suche mit Quellenangabe
|
||||
- Belegerfassung: Foto einer Quittung → Vorschlag für Betrag, Datum, Händler
|
||||
Alles gegen selbst betriebene Modelle, kein externer Anbieter, komplett
|
||||
abschaltbar.
|
||||
|
||||
Jede KI-Ausgabe ist gekennzeichnet und muss bestätigt werden, bevor sie in ein
|
||||
Dokument mit Nachweischarakter gelangt.
|
||||
- Wiki mit pgvector: Dienstvorschriften und Ausbildungsunterlagen als Markdown,
|
||||
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
|
||||
dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`.
|
||||
@@ -194,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
|
||||
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.
|
||||
|
||||
## 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
|
||||
Echtbetrieb ein. Voraussetzungen:
|
||||
Echtbetrieb ein.
|
||||
|
||||
- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen
|
||||
- Sicherungskonzept steht und ist einmal wiederhergestellt worden
|
||||
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
|
||||
- Löschkonzept nach DSGVO abgenommen
|
||||
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
|
||||
dokumentiert abgeschaltet — oder bewusst stehen gelassen
|
||||
**Damit das nicht zur Formalie wird, hier der Stand jeder Voraussetzung — auch
|
||||
der offenen.** Eine Freigabe, die alles abhakt, was ohnehin nicht geprüft
|
||||
wurde, ist keine.
|
||||
|
||||
| Voraussetzung | Stand |
|
||||
|---|---|
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,8 +350,11 @@ Am 03.09.2026 entschieden:
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
|
||||
## Weitere Kandidaten für später
|
||||
@@ -229,10 +365,8 @@ Am 03.09.2026 entschieden:
|
||||
| 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 |
|
||||
| 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
|
||||
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist
|
||||
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende
|
||||
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das
|
||||
der nächste sinnvolle Schritt.
|
||||
Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene,
|
||||
Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die
|
||||
gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte
|
||||
Definition dessen, was verglichen werden soll.
|
||||
|
||||
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.0 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: 246 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 733 KiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 756 KiB After Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 968 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1013 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 555 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 176 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 |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 959 KiB |