1409 lines
61 KiB
Markdown
1409 lines
61 KiB
Markdown
# FLORIAN — Feuerwehr-Verwaltungsplattform
|
||
|
||
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im
|
||
Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.
|
||
|
||
## Schnellstart
|
||
|
||
```bash
|
||
git clone https://git.wilde-server.de/florian.wilde/FireAdministration.git && cd FireAdministration
|
||
cp .env.example .env # Passwörter und AUTH_SECRET ersetzen
|
||
npm ci # Abhängigkeiten
|
||
docker compose up -d # Postgres, Redis, MinIO, Gotenberg
|
||
npm run db:migrate && npm run db:seed # Schema und Beispielwehr
|
||
npm run dev # http://localhost:3100
|
||
```
|
||
|
||
Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||
(Vorgabe: `admin@beispielwehr.de` / `Feuerwehr2026`). Passwort danach ändern.
|
||
|
||
---
|
||
|
||
## Was drin ist
|
||
|
||
| Bereich | Stand |
|
||
|---|---|
|
||
| Phase 0 — Fundament | fertig |
|
||
| Phase 1 — Stammdaten | fertig |
|
||
| Phase 2 — Wartung und Prüfung | fertig |
|
||
| Phase 3 — Einsätze und Übungen | fertig |
|
||
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
|
||
| Phase 5 — Atemschutz (FwDV 7) | fertig |
|
||
| Phase 6 — Kartenmodule | fertig |
|
||
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
||
| Phase 8 — Lokale KI | fertig |
|
||
| Phase 9 — Kassenverwaltung | fertig |
|
||
|
||
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
||
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
|
||
|
||
## Weiterführende Dokumentation
|
||
|
||
| Datei | Inhalt |
|
||
|---|---|
|
||
| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Bedienung mit Bildschirmfotos |
|
||
| [docs/ROADMAP.md](docs/ROADMAP.md) | Phasenplan und was jeweils entsteht |
|
||
| [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist |
|
||
| [docs/DEVLOG.md](docs/DEVLOG.md) | Entwicklungstagebuch mit Zeiterfassung |
|
||
| [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md) | Versions- und Branch-Regeln |
|
||
| [CHANGELOG.md](CHANGELOG.md) | Was sich wann geändert hat |
|
||
|
||
---
|
||
|
||
## Phase 0 — Fundament
|
||
|
||
### Aufbau
|
||
|
||
```
|
||
FireAdministration/
|
||
├─ docker/ Dockerfile, Entrypoint, Postgres-Initialisierung
|
||
├─ prisma/ Schema, Migrationen, Seed
|
||
├─ scripts/ Hilfsskripte (Bildschirmfotos)
|
||
├─ src/
|
||
│ ├─ app/ Next.js App Router
|
||
│ │ ├─ (app)/ alles, was Anmeldung verlangt
|
||
│ │ ├─ anmelden/ Anmeldeseite
|
||
│ │ └─ api/ Auth.js, tRPC, Health
|
||
│ ├─ components/ shadcn/ui-Bausteine und Layout
|
||
│ ├─ lib/ authz, db, storage, audit, env, auth, warteschlange
|
||
│ ├─ server/ tRPC-Router und Kontext
|
||
│ └─ worker/ BullMQ-Hintergrundprozess
|
||
├─ tests/ Vitest (unit) und Playwright (e2e)
|
||
└─ docs/ Anleitung, Roadmap, Devlog, Aufwände
|
||
```
|
||
|
||
### Mandantentrennung
|
||
|
||
Jede Tabelle trägt `organisationId`, und Postgres setzt die Trennung mit Row
|
||
Level Security durch. Es gibt zwei Datenbankverbindungen:
|
||
|
||
- **`florian_app`** (`DATABASE_URL`) — RLS aktiv, `NOBYPASSRLS`. Über diese
|
||
Verbindung arbeitet die Anwendung. Ohne gesetztes
|
||
`app.current_organisation` liefert jede Abfrage **null Zeilen**.
|
||
- **Eigentümer** (`DIRECT_DATABASE_URL`) — umgeht RLS. Nur für Migrationen,
|
||
Seed, Anmeldung (die E-Mail-Suche muss über alle Wehren gehen) und
|
||
Wartungsjobs.
|
||
|
||
Fachdaten laufen ausschließlich über `fuerOrganisation()` aus
|
||
[`src/lib/db.ts`](src/lib/db.ts):
|
||
|
||
```ts
|
||
const personen = await fuerOrganisation(orgId, (db) =>
|
||
db.person.findMany({ where: { aktiv: true } }),
|
||
);
|
||
```
|
||
|
||
Ein vergessener Mandantenfilter kostet damit im schlimmsten Fall ein leeres
|
||
Ergebnis — nicht die Daten einer fremden Wehr.
|
||
`tests/unit/mandantentrennung.test.ts` prüft das gegen eine echte Datenbank.
|
||
|
||
### Audit-Log
|
||
|
||
Jede schreibende Operation erzeugt einen Eintrag mit Akteur, Zeitpunkt,
|
||
Entität und Vorher/Nachher als JSONB. Die Tabelle ist append-only:
|
||
|
||
- `UPDATE` ist per Trigger **immer** gesperrt.
|
||
- `DELETE` nur im gekennzeichneten Aufbewahrungslauf
|
||
(`SET LOCAL app.audit_bereinigung = 'an'`), den ausschließlich der Worker
|
||
ausführt — nachdem er exportiert hat. Ohne diese Ausnahme ließe sich das
|
||
Löschkonzept nach DSGVO nicht umsetzen.
|
||
|
||
Geheimnisse (Passwort-Hashes, PINs, Token) werden vor dem Schreiben entfernt.
|
||
|
||
### Berechtigungen
|
||
|
||
Alles in [`src/lib/authz.ts`](src/lib/authz.ts) — keine Rollenprüfung in
|
||
Komponenten. 9 Rollen, 38 Einzelrechte. Der Seed spiegelt den Katalog in die
|
||
Datenbank, damit eine Wehr Rechte nachträglich zuschneiden kann, ohne dass
|
||
jemand Code ändert.
|
||
|
||
```ts
|
||
export const geraetSpeichern = mitRecht("geraet.bearbeiten").mutation(/* … */);
|
||
```
|
||
|
||
### Anmeldung
|
||
|
||
Drei Wege, eine Sitzung:
|
||
|
||
1. **OIDC gegen authentik** — der Regelweg. authentik wird **separat**
|
||
betrieben und ist nicht Teil dieses Stacks. Im Provider einzutragen:
|
||
Redirect-URI `${APP_URL}/api/auth/callback/authentik`, Scopes
|
||
`openid profile email`.
|
||
2. **E-Mail und Passwort** — Rückfalloption für den Erststart und für den Fall,
|
||
dass authentik nicht erreichbar ist. Über `AUTH_LOCAL_ENABLED` abschaltbar.
|
||
3. **Terminal (PIN oder RFID)** — für das Kiosk-Gerät im Gerätehaus. Zwei
|
||
Schranken: erst weist sich das *Gerät* mit `KIOSK_DEVICE_SECRET` aus, dann
|
||
die *Person* mit PIN oder Karte. Die vollständige Oberfläche dazu kommt in
|
||
Phase 4.
|
||
|
||
Rollen aus authentik-Gruppen wirken als **Vorbelegung beim ersten Login**
|
||
(`AUTH_OIDC_GROUP_MAPPING`). Sobald jemand die Rolle in FLORIAN ändert, ist
|
||
FLORIAN führend und OIDC überschreibt nicht mehr.
|
||
|
||
### Dateien
|
||
|
||
Alles liegt in MinIO, nie im Dateisystem und nie in der Datenbank. Zugriff
|
||
ausschließlich über [`src/lib/storage.ts`](src/lib/storage.ts). Objektschlüssel:
|
||
|
||
```
|
||
<wehr-slug>/<entitaet>/<entitaetId>/<uuid>-<dateiname>
|
||
```
|
||
|
||
Downloads laufen über presignte Links mit begrenzter Gültigkeit
|
||
(`S3_URL_TTL`, Vorgabe 15 Minuten).
|
||
|
||
---
|
||
|
||
## Phase 2 — Wartung und Prüfung
|
||
|
||
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt: Prüffristen
|
||
im Blick behalten und im Streitfall nachweisen können, dass geprüft wurde.
|
||
|
||
### Der Weg durch das Modul
|
||
|
||
```
|
||
Wartungsvorlage was an einem Gerätetyp in welchem Takt geprüft wird
|
||
└─ Checklistenpunkt die einzelne Frage: ja/nein, Messwert, Text, Foto
|
||
↓ nächtlicher Lauf
|
||
Wartungstermin ein fälliger Termin an einem konkreten Gerät
|
||
↓ Prüfwizard
|
||
Pruefung durchgeführte Prüfung, unterschrieben, festgeschrieben
|
||
├─ Pruefergebnis je Checklistenpunkt eine Antwort
|
||
├─ Signatur Unterschrift aus dem Zeichenfeld, PNG in MinIO
|
||
└─ Mangel was aufgefallen ist und behoben werden muss
|
||
```
|
||
|
||
Ohne Vorlage gibt es keine Termine, ohne Termine kein Fristen-Dashboard. Die
|
||
erste Seite, die eine Wehr in Phase 2 ausfüllt, ist deshalb
|
||
`/pruefungen/vorlagen`.
|
||
|
||
### Das Protokoll ist unveränderbar
|
||
|
||
Ein abgeschlossenes Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse.
|
||
Wäre es nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
|
||
deshalb in der Datenbank (Trigger `pruefung_unveraenderlich`, Migration
|
||
`20260903000300_wartung_rls`) und nicht nur im Anwendungscode — ein vergessener
|
||
Zustandscheck in einem Router würde sonst reichen, um sie auszuhebeln.
|
||
|
||
Nach dem Abschluss sind genau drei Änderungen zulässig, und alle drei ergänzen
|
||
das Protokoll, statt es zu verfälschen:
|
||
|
||
| Änderung | Warum |
|
||
|---|---|
|
||
| `ABGESCHLOSSEN` → `ERSETZT` | eine Korrekturprüfung ist nachgefolgt |
|
||
| `protokollDateiId`, solange leer | das PDF entsteht im Hintergrund und wird nachgetragen |
|
||
| `geaendertAm` | setzt Prisma bei jedem Schreibvorgang mit |
|
||
|
||
Dasselbe gilt für die Bestandteile: an einem abgeschlossenen Protokoll lassen
|
||
sich weder Einzelergebnisse ändern noch Unterschriften nachtragen. **Korrekturen
|
||
erzeugen eine neue Prüfung** mit Verweis auf die alte; beide bleiben lesbar.
|
||
|
||
Gelöscht wird ein Protokoll nie — außer im ausdrücklich gekennzeichneten
|
||
Aufbewahrungslauf, demselben Schalter wie beim Audit-Log
|
||
(`SET LOCAL app.audit_bereinigung = 'an'`).
|
||
|
||
### Der nächtliche Lauf
|
||
|
||
Der Worker trägt sich beim Start je Wehr zwei wiederkehrende Aufträge ein:
|
||
|
||
| Auftrag | Vorgabe | Was er tut |
|
||
|---|---|---|
|
||
| `fristen.pruefen` | 03:15 Uhr | Termine aus den Vorlagen planen, Frist und Status an jedem Gerät nachziehen |
|
||
| `erinnerung.senden` | 03:45 Uhr | Vorwarnungen zu offenen Terminen verschicken |
|
||
|
||
Beide sind wiederholbar: doppelte Termine verhindert die Eindeutigkeit über
|
||
Gerät, Vorlage und Fälligkeitstag, doppelte Mails ein Erinnerungseintrag je
|
||
Termin und Stufe. Erinnert wird bei T-30 (bzw. der Vorwarnzeit der Vorlage),
|
||
T-7, T-0 und einmal bei Überfälligkeit — als **eine** Sammelnachricht je
|
||
Empfänger, nicht als eine Mail je Gerät.
|
||
|
||
„Überfällig" ist bewusst kein gespeicherter Zustand, sondern wird aus dem
|
||
Fälligkeitsdatum und dem heutigen Tag gerechnet. Gespeicherte Zustände laufen
|
||
der Uhr hinterher; berechnete nicht.
|
||
|
||
### Prüfwizard
|
||
|
||
Zugeschnitten auf das Tablet im Gerätehaus: ein Prüfpunkt je Bildschirm, große
|
||
Schaltflächen, Foto direkt aus der Kamera, Unterschrift auf einem Zeichenfeld,
|
||
das Finger, Stift und Maus gleichermaßen versteht.
|
||
|
||
Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens die
|
||
letzte Antwort verloren, nicht die ganze Prüfung — beim erneuten Öffnen wird der
|
||
Entwurf fortgesetzt.
|
||
|
||
Die Bewertung rechnet der Server, nicht der Browser
|
||
([`src/lib/wartung/bewertung.ts`](src/lib/wartung/bewertung.ts)):
|
||
|
||
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
|
||
- ein beanstandeter **Pflichtpunkt** bedeutet „nicht bestanden" und sperrt das
|
||
Gerät,
|
||
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
|
||
|
||
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er
|
||
trägt die Verantwortung.
|
||
|
||
### Prüfprotokoll als PDF
|
||
|
||
Erzeugt der Worker über Gotenberg
|
||
([`src/lib/wartung/protokoll.ts`](src/lib/wartung/protokoll.ts)) und legt es in
|
||
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
|
||
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
|
||
Prüfdatum und Seitenzahl.
|
||
|
||
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder.
|
||
Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
|
||
|
||
Die laufende Protokollnummer (`JJJJ-NNNN`) wird je Wehr und Jahr unter einer
|
||
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
|
||
verschiedene Nummern.
|
||
|
||
### Mängel und Rückrufe
|
||
|
||
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben.
|
||
**Melden darf jedes Mitglied** — der Mangel fällt dem auf, der das Gerät
|
||
benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das
|
||
Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn
|
||
kein anderer offener Mangel dasselbe Gerät sperrt.
|
||
|
||
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich
|
||
und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein
|
||
**Vorschlag**, keine automatische Sperre: eine Chargenangabe des Herstellers
|
||
passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
|
||
|
||
### Zusätzliche Umgebungsvariablen
|
||
|
||
| Variable | Vorgabe | Bedeutung |
|
||
|---|---|---|
|
||
| `WARTUNG_NACHTLAUF` | `15 3 * * *` | wann Termine geplant werden (cron, Europe/Berlin) |
|
||
| `WARTUNG_ERINNERUNG_LAUF` | `45 3 * * *` | wann Erinnerungen verschickt werden |
|
||
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
|
||
|
||
|
||
---
|
||
|
||
## Phase 3 — Einsätze und Übungen
|
||
|
||
Was die Wehr tut, wird hier dokumentiert — für die Gemeinde, für die
|
||
Versicherung und für die eigene Mannschaft.
|
||
|
||
### Der Weg durch das Modul
|
||
|
||
```
|
||
Schnellerfassung Stichwort, Ort, Fahrzeuge — im Alarmfall in 20 Sekunden
|
||
↓ während des Einsatzes
|
||
Zeitstempel alarmiert, ausgerückt, eingetroffen, Ende, eingerückt,
|
||
einsatzbereit — je ein Knopf
|
||
Lagemeldungen aus der Wehr und über das Portal einer Fremdwehr
|
||
↓ danach, in Ruhe
|
||
Kräfte und Material wer war dabei, was wurde verbraucht
|
||
Bericht der Text des Einsatzleiters
|
||
↓ Freigabe
|
||
Einsatzbericht als PDF, festgeschrieben, an die Gemeinde
|
||
```
|
||
|
||
### Die sechs Zeitstempel
|
||
|
||
Sie sind der Kern jeder Einsatzauswertung. Aus ihnen ergeben sich drei Zahlen:
|
||
|
||
| Kennzahl | Rechnung | sagt etwas aus über |
|
||
|---|---|---|
|
||
| **Ausrückzeit** | Alarm bis Ausrücken | die Wehr selbst |
|
||
| **Anfahrtszeit** | Ausrücken bis Eintreffen | Lage und Wegstrecke |
|
||
| **Hilfsfrist** | Alarm bis Eintreffen | das, wonach die Gemeinde fragt |
|
||
|
||
In Bayern gilt als Ziel: **in 80 Prozent der Fälle innerhalb von zehn Minuten**.
|
||
Genau dieser Wert steht in der Statistik — nicht der Durchschnitt, denn der
|
||
verdeckt die Ausreißer, um die es geht.
|
||
|
||
### Stärke nach FwDV 3
|
||
|
||
Aus den Funktionen der erfassten Kräfte errechnet FLORIAN die Stärke in der
|
||
üblichen Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`:
|
||
|
||
| Einheit | Stärke | Besetzung |
|
||
|---|---|---|
|
||
| Trupp | 0/1/1/2 | Truppführer, Truppmann |
|
||
| Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps |
|
||
| Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps |
|
||
| Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel |
|
||
|
||
**Regional unterschiedlich.** Manche Wehren zählen den Maschinisten zu den
|
||
Unterführern, andere schreiben dreistellig ohne Unterführer (Gruppe = 1/8/9).
|
||
Die Zuordnung steht an einer Stelle — [`src/lib/einsatz/staerke.ts`](src/lib/einsatz/staerke.ts) —
|
||
und ist bewusst leicht zu ändern.
|
||
|
||
### Der freigegebene Bericht ist unveränderbar
|
||
|
||
Dieselbe Logik wie beim Prüfprotokoll aus Phase 2, denn der Bericht geht an
|
||
die Gemeinde und im Schadensfall an die Versicherung. Bis zur Freigabe ist
|
||
alles änderbar — ein Einsatz wird tagelang ergänzt, und genau dafür ist der
|
||
Entwurfsstand da. Mit der Freigabe ist Schluss; der Trigger
|
||
`einsatz_freigabe_schuetzen` setzt das durch.
|
||
|
||
Zwei Ausnahmen bleiben, beide nachtragend: das PDF, solange keines hinterlegt
|
||
ist, und `geaendertAm`.
|
||
|
||
**Lagemeldungen sind ausdrücklich ausgenommen.** Sie sind das Protokoll des
|
||
Verlaufs, kein Bestandteil des Berichts — und eine Meldung aus dem Portal kann
|
||
eintreffen, wenn der Bericht längst geschrieben ist.
|
||
|
||
### Einsatzportal für Fremdwehren
|
||
|
||
Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor
|
||
Ort. Die haben kein Konto in FLORIAN und sollen auch keines bekommen. Der
|
||
Einsatzleiter erzeugt stattdessen einen Link, den er als QR-Code zeigt oder
|
||
weiterschickt.
|
||
|
||
Vier Schranken sichern ihn ab:
|
||
|
||
1. **Zufall** — 32 zufällige Bytes im Link.
|
||
2. **Nur ein Hash in der Datenbank** — wer sie liest, bekommt keinen gültigen Link.
|
||
3. **Zeitlich begrenzt** — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder
|
||
Aufruf wird mitgezählt.
|
||
4. **Zugriffsbegrenzung** — 120 Aufrufe je Link und Stunde, 30 Meldungen, 10
|
||
Fotos.
|
||
|
||
Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort,
|
||
Zeitstempel und Lagemeldungen. **Keine Namen von Einsatzkräften, keine
|
||
Berichte, kein Weg in den Rest der Anwendung.**
|
||
|
||
### Adressen zu Koordinaten
|
||
|
||
Über einen **selbst betriebenen** Nominatim (`NOMINATIM_URL`). Einsatzadressen
|
||
sagen, wo es gebrannt hat und wer betroffen war; solche Anfragen gehören nicht
|
||
an einen fremden Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim
|
||
erlauben es ohnehin nicht.
|
||
|
||
Ohne Konfiguration bleibt das Feld leer und die Koordinaten werden von Hand
|
||
eingetragen — kein Fehler, sondern der Normalfall in einer frischen
|
||
Installation. Die Karte dazu kommt in Phase 6.
|
||
|
||
Gespeichert wird doppelt: als Dezimalgrad, die Prisma lesen kann, und als
|
||
PostGIS-Punkt für die Karte. Ein Trigger hält beides zusammen.
|
||
|
||
### Statistik und Jahresexport
|
||
|
||
`/einsaetze/statistik` beantwortet beide Fragen: die der Gemeinde (Anzahl nach
|
||
Kategorie der Landesstatistik, Hilfsfristen) und die der Wehr (Beteiligung je
|
||
Person, Auslastung je Fahrzeug). Der Jahresexport als PDF liegt unter
|
||
`/api/einsaetze/jahresexport?jahr=…` und wird bewusst **nicht** abgelegt — er
|
||
ist eine Auswertung, keine Urkunde, und ändert sich, solange noch Einsätze
|
||
nachgetragen werden.
|
||
|
||
### Zusätzliche Umgebungsvariablen
|
||
|
||
| Variable | Vorgabe | Bedeutung |
|
||
|---|---|---|
|
||
| `GEOCODING_ENABLED` | `false` | Adresssuche einschalten |
|
||
| `NOMINATIM_URL` | — | Adresse des eigenen Nominatim |
|
||
| `GEOCODING_LAENDER` | `de,at,ch` | Eingrenzung der Suche |
|
||
| `PORTAL_ENABLED` | `true` | Einsatzportal für Fremdwehren |
|
||
| `PORTAL_GUELTIGKEIT_STUNDEN` | `24` | Lebensdauer eines Links |
|
||
| `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde |
|
||
|
||
|
||
---
|
||
|
||
## Phase 4 — Das Terminal im Gerätehaus
|
||
|
||
Ein Wandtablet neben der Fahrzeughalle. Es muss zwei Dinge können, die eine
|
||
Verwaltungsoberfläche am Schreibtisch nicht können muss: **mit Handschuhen
|
||
bedienbar** sein und **weiterarbeiten, wenn das Netz weg ist**. Stahlbeton,
|
||
Metalltore und ein Repeater, der irgendwann ausfällt — das ist der Normalfall,
|
||
nicht die Ausnahme.
|
||
|
||
Die Oberfläche liegt unter `/kiosk` und ist eine eigene: große Kacheln statt
|
||
Navigationsleiste, Schaltflächen ab 64 Pixel Höhe, Symbol **und** Text.
|
||
|
||
### Der Weg durch das Modul
|
||
|
||
1. **Einmal einrichten** — `/kiosk/einrichten` verlangt den Kurznamen der Wehr
|
||
und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`). Beides nimmt
|
||
die Seite auch aus der Adresse an, damit am Tablet nichts abgetippt werden
|
||
muss:
|
||
|
||
```
|
||
/kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
|
||
```
|
||
|
||
2. **Anmelden** — Ausweis auflegen, QR-Code vom Handy oder Mitgliedsnummer und
|
||
PIN. Ohne eingerichtetes Gerät kommt niemand hinein, auch nicht mit richtiger
|
||
PIN: das Gerätegeheimnis ist die erste Schranke.
|
||
3. **Erfassen** — Mangel melden, Kommen/Gehen, Fahrt beginnen und beenden,
|
||
Wäsche abgeben, Inventurgang.
|
||
4. **Von selbst abmelden** — nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit,
|
||
mit sichtbarer Warnung vorher. Ein Terminal, an dem die letzte Anmeldung
|
||
stehen bleibt, erfasst irgendwann alles auf den Falschen.
|
||
|
||
Was dabei zusammenkommt, steht in der Verwaltung unter `/terminal`.
|
||
|
||
### Mangel melden in unter dreißig Sekunden
|
||
|
||
Der Vorgang, an dem sich das Terminal misst. Drei Schritte, kein Formular:
|
||
|
||
1. QR-Code am Gerät scannen — oder die Inventarnummer eintippen.
|
||
2. Foto aufnehmen und einen Satz dazu.
|
||
3. Dringlichkeit antippen. Damit ist es abgeschickt.
|
||
|
||
„Kritisch" sperrt das Gerät sofort. Eine unbekannte Inventarnummer hält die
|
||
Meldung **nicht** auf: sie geht raus und landet notfalls in der Konfliktliste —
|
||
lieber ein Zettel zu viel als ein Riss im Schlauch, den niemand gemeldet hat.
|
||
|
||
### Offline: wie nichts verloren geht
|
||
|
||
Drei Bausteine, und keiner davon ist optional:
|
||
|
||
**Service Worker** (`public/sw.js`, von Hand geschrieben, keine Bibliothek):
|
||
App-Shell und Symbole liegen im Zwischenspeicher, Seitenaufrufe gehen
|
||
`network-first` mit Ausweichseite `/kiosk/offline`, lesende tRPC-Aufrufe
|
||
`stale-while-revalidate`. Schreibende Anfragen und alles unter `/api/auth`
|
||
fasst er **nicht** an — eine zwischengespeicherte Anmeldung wäre eine Lücke,
|
||
kein Komfort.
|
||
|
||
**Warteschlange in der IndexedDB** (`src/lib/kiosk/outbox.ts`, Dexie): jede
|
||
schreibende Aktion geht zuerst dorthin — auch online. Sonst gäbe es zwei Wege
|
||
durch den Code, und der seltenere wäre der ungetestete. Fotos liegen als Blob
|
||
daneben und werden beim Übertragen zuerst hochgeladen.
|
||
|
||
**Idempotenzschlüssel**: die Kennung erzeugt der Browser beim Einreihen
|
||
(`crypto.randomUUID()`), nicht der Server beim Empfangen. Bricht die
|
||
Übertragung ab und wird wiederholt, erkennt der Server denselben Vorgang an
|
||
derselben Kennung wieder und antwortet mit dem Ergebnis von damals. Die Tabelle
|
||
`kiosk_vorgang` hat diese Kennung als Primärschlüssel, und die Datenbank
|
||
verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf.
|
||
|
||
Ein Vorgang verschwindet aus der Warteschlange, wenn der Server ihn angenommen
|
||
**oder** als bereits verarbeitet erkannt **oder** in die Konfliktliste gelegt
|
||
hat. In allen drei Fällen ist er angekommen. Liegen bleibt nur, was technisch
|
||
nicht durchging.
|
||
|
||
Der Zustand steht als Leiste über allem — verbunden, wie viele Änderungen
|
||
warten, kein Netz — in Klartext und nicht nur als Farbe.
|
||
|
||
**Wem ein Vorgang zugeschrieben wird.** Der Server nimmt dafür die Person, die
|
||
beim Empfang angemeldet ist. Im Regelfall ist das dieselbe, die erfasst hat —
|
||
wer offline erfasst, bleibt angemeldet, weil auch das Abmelden Netz braucht.
|
||
Damit dazwischen kein Fenster bleibt, überträgt das Terminal vor jedem
|
||
Abmelden — von Hand wie durch die Abmeldeuhr — noch einmal.
|
||
|
||
### Konflikte
|
||
|
||
Ein Vorgang, der sich nicht anwenden lässt (Gerät zwischenzeitlich ausgesondert,
|
||
Prüfung inzwischen festgeschrieben), wird nicht verworfen und nicht erzwungen,
|
||
sondern in `sync_konflikt` gelegt. Der Gerätewart sieht ihn unter `/terminal`
|
||
und entscheidet. Das ist der ehrliche Umgang mit einem Tablet, das drei Tage
|
||
offline war.
|
||
|
||
### Anmeldemarke fürs Handy
|
||
|
||
Wer den Ausweis vergessen hat, öffnet `/anmeldemarke` und hält den QR-Code vor
|
||
die Kamera des Terminals. Die Marke ist mit `AUTH_SECRET` signiert, **nicht
|
||
gespeichert**, und gilt 60 Sekunden — ein abfotografierter Code aus der Umkleide
|
||
nützt danach nichts mehr. Die Seite erneuert ihn von selbst.
|
||
|
||
### Hallenmonitor
|
||
|
||
`/kiosk/monitor` läuft im Vollbild ohne Bedienung: Uhrzeit, laufender Einsatz,
|
||
wer im Haus ist, Fahrzeuge samt laufenden Fahrten, fällige Prüfungen und die
|
||
nächsten Termine. Optional Wetterwarnungen des DWD — die einzige Verbindung,
|
||
die FLORIAN von sich aus nach außen aufbaut, und deshalb standardmäßig aus.
|
||
|
||
### Als App installieren
|
||
|
||
Das Manifest liegt unter `/manifest.webmanifest`, die Symbole entstehen mit
|
||
`npm run icons` und liegen unter `public/icons/`. Startseite ist `/kiosk`. Im
|
||
Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile.
|
||
|
||
### Zusätzliche Umgebungsvariablen
|
||
|
||
| Variable | Vorgabe | Bedeutung |
|
||
|---|---|---|
|
||
| `KIOSK_ENABLED` | `true` | Terminalanmeldung freischalten |
|
||
| `KIOSK_DEVICE_SECRET` | — | Geheimnis, mit dem sich ein Tablet ausweist |
|
||
| `KIOSK_IDLE_TIMEOUT` | `120` | Abmeldung nach Untätigkeit, in Sekunden |
|
||
| `WETTER_ENABLED` | `false` | Wetterwarnungen auf dem Monitor |
|
||
| `DWD_WARNCELL` | — | Warnzellen-Nummer der eigenen Gemeinde |
|
||
| `DWD_WARNUNGEN_URL` | DWD-Warnapp | Quelle der Warnungen |
|
||
| `WETTER_TIMEOUT` | `5` | Zeitlimit des Abrufs, in Sekunden |
|
||
| `WETTER_ZWISCHENSPEICHER` | `300` | Gültigkeit einer Antwort, in Sekunden |
|
||
|
||
---
|
||
|
||
## Phase 5 — Atemschutz nach FwDV 7
|
||
|
||
Das sicherheitsrelevanteste Modul des Projekts. Über jeder Ansicht steht
|
||
deshalb dauerhaft:
|
||
|
||
> **Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.**
|
||
|
||
Der Hinweis lässt sich abschalten, aber nur zusammen mit einer Fundstelle:
|
||
`ATEMSCHUTZ_HINWEIS=false` wirkt erst, wenn unter `ATEMSCHUTZ_FREIGABE` steht,
|
||
worauf sich die Freigabe stützt — etwa „Kommandantenbeschluss vom 12.03.2026".
|
||
Fehlt sie, bleibt der Hinweis stehen. Eine Freigabe ohne Beschluss ist keine.
|
||
|
||
### Die Zeit gehört dem Server
|
||
|
||
Das ist die eine Entscheidung, aus der alles Weitere folgt. **Nirgends wird
|
||
eine Restzeit heruntergezählt.** In der Datenbank stehen Zeitstempel; wie lange
|
||
ein Trupp drin ist, rechnet jede Ansicht bei jedem Bild neu aus dem
|
||
Einstiegszeitpunkt und der aktuellen Uhr.
|
||
|
||
Daraus ergibt sich von selbst, was Phase-5.md als wichtigste Anforderung nennt:
|
||
|
||
| Was passiert | Was die Anzeige macht |
|
||
|---|---|
|
||
| Browser neu laden | Zeigt dieselbe Zeit weiter — es gab nichts zu verlieren |
|
||
| Zweiter Bildschirm daneben | Zeigt dieselbe Zeit, ohne Abgleich |
|
||
| Verbindung bricht ab | Die Uhr läuft weiter, und der Abriss steht sichtbar da |
|
||
| Server neu gestartet | Nach der ersten Antwort ist alles wieder da |
|
||
|
||
Server und Browser rechnen mit **denselben** Funktionen
|
||
(`src/lib/atemschutz/berechnung.ts`). Was das Tablet anzeigt, weist das
|
||
Protokoll später genauso aus.
|
||
|
||
### Rückzugsdruck
|
||
|
||
Gerechnet wird nach dem **doppelten Anmarschverbrauch**: was der Weg hinein an
|
||
Luft gekostet hat, kostet der Weg zurück noch einmal.
|
||
|
||
```
|
||
Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag
|
||
```
|
||
|
||
Dafür meldet der Trupp beim Erreichen des Einsatzziels einmal seinen Druck.
|
||
Solange das nicht geschehen ist, greift die **Zwei-Drittel-Regel** — sie liegt
|
||
in aller Regel höher und ist damit die vorsichtigere Annahme; die Karte sagt
|
||
dann „vorläufig" dazu.
|
||
|
||
Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht gemeinsam
|
||
zurück, also zählt, wer am wenigsten Luft hat.
|
||
|
||
Die Ampel steht schon zehn Prozent über der Grenze auf Gelb — der Trupp soll
|
||
losgehen, bevor der Wert erreicht ist, nicht wenn er erreicht ist. Farbe **und**
|
||
Text, wie überall in FLORIAN.
|
||
|
||
### Der Weg durch eine Überwachung
|
||
|
||
1. **Eröffnen** — Bezeichnung genügt. Die Zuordnung zu einem Einsatz lässt sich
|
||
nachtragen; im Ernstfall wird zuerst überwacht.
|
||
2. **Trupp anlegen** — zwei oder drei Personen, für jede der Eingangsdruck, eine
|
||
davon Truppführer. Daneben steht, ob die Person nach FwDV 7 einsetzbar ist.
|
||
**Gesperrt wird nichts:** im Einsatz entscheidet der Einheitsführer, und ein
|
||
Programm, das ihm im entscheidenden Moment eine Person verweigert, wird
|
||
umgangen — dann steht am Ende gar nichts im Protokoll.
|
||
3. **Einstieg** — ab hier läuft die Uhr.
|
||
4. **Druckabfrage** — für den ganzen Trupp in einer Zeile. Nach
|
||
`ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden ohne Meldung wird erinnert; gerechnet
|
||
ab der letzten Meldung, nicht ab dem Einstieg.
|
||
5. **Rückweg und heraus** — beim Herauskommen die Ausgangsdrücke.
|
||
6. **Beenden** — geht erst, wenn alle Trupps als draußen gemeldet sind. Eine
|
||
beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
|
||
gefährlichste Zeile im ganzen Protokoll.
|
||
|
||
Dazu jederzeit: **Rückzug anordnen**, **Mayday** (aufzuheben, nicht
|
||
wegzuklicken) und Notizen ins Protokoll.
|
||
|
||
### Der Ereignisstrom
|
||
|
||
Änderungen kommen über **Server-Sent Events** unter `/api/atemschutz/strom` —
|
||
kein WebSocket, kein eigener Node-Server, `next start` bleibt wie es ist. Der
|
||
Strom meldet nur „es hat sich etwas geändert"; die Ansicht holt daraufhin den
|
||
Stand. Geschrieben wird wie überall über tRPC.
|
||
|
||
Zwei Dinge machen ihn belastbar:
|
||
|
||
- **Er verbindet sich selbst neu.** `EventSource` bringt das mit.
|
||
- **Stille gilt als Abriss.** Der Server schickt alle zwei Sekunden ein
|
||
Lebenszeichen; bleibt es acht Sekunden aus, steht die Verbindung sichtbar auf
|
||
„unterbrochen" — auch wenn der Browser keinen Fehler meldet. Dieser Fall ist
|
||
real: wird das Netz gekappt, während der Strom offen ist, schweigt
|
||
`EventSource` einfach.
|
||
|
||
Fällt der Strom ganz aus, bleibt die Ansicht bedienbar — sie fragt zusätzlich im
|
||
festen Takt nach. Der Strom macht sie schneller, nicht erst funktionsfähig.
|
||
|
||
### Das Protokoll
|
||
|
||
Ein PDF mit Rahmen, Trupps und dem Verlauf — Ereignisse und Druckmessungen in
|
||
**einer** Zeitachse, damit niemand zwei Spalten gleichzeitig lesen muss. Es
|
||
entsteht beim Beenden im Hintergrundprozess und, falls das nicht geklappt hat,
|
||
beim Abruf unter `/api/atemschutz/protokoll?id=…`.
|
||
|
||
Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
|
||
später in der Hand hält, soll wissen, was er da hat.
|
||
|
||
Verändern lässt sich nichts: `druckmessung` und `atemschutz_ereignis` sind für
|
||
den Anwendungszugang gegen `UPDATE` und `DELETE` gesperrt, und zusätzlich sitzt
|
||
auf beiden ein Trigger. Ein Fehler wird durch einen neuen Eintrag
|
||
richtiggestellt.
|
||
|
||
### Wer darf unter Atemschutz?
|
||
|
||
Drei Nachweise, die gleichzeitig gelten müssen:
|
||
|
||
| Nachweis | Frist |
|
||
|---|---|
|
||
| **G26.3** | nach Alter: bis 50 Jahre drei Jahre, danach eines |
|
||
| **Belastungsübung** | jährlich (Übungsstrecke) |
|
||
| **Einsatzübung unter Atemschutz** | jährlich — ein echter Einsatz zählt |
|
||
|
||
Dazu die Qualifikation als Atemschutzgeräteträger aus Phase 1. Die Übersicht
|
||
zeigt eine Ampel **mit Klartext**, was fehlt: eine rote Ampel ohne Begründung
|
||
hilft am Fahrzeug niemandem. Auf der Startseite steht die Zahl, nach der im
|
||
Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind.
|
||
|
||
### Zusätzliche Umgebungsvariablen
|
||
|
||
| Variable | Vorgabe | Bedeutung |
|
||
|---|---|---|
|
||
| `ATEMSCHUTZ_HINWEIS` | `true` | Sicherheitshinweis über jeder Ansicht |
|
||
| `ATEMSCHUTZ_FREIGABE` | — | Fundstelle der Freigabe; ohne sie bleibt der Hinweis |
|
||
| `ATEMSCHUTZ_ABFRAGE_INTERVALL` | `600` | Abstand der Druckabfrage in Sekunden |
|
||
| `ATEMSCHUTZ_WARNDRUCK` | `55` | Ansprechdruck der Warneinrichtung in bar |
|
||
| `ATEMSCHUTZ_ZUSCHLAG` | `10` | Sicherheitszuschlag auf den Rückzugsdruck |
|
||
|
||
---
|
||
|
||
## Phase 6 — Kartenmodule
|
||
|
||
Die Frage, wegen der es das Modul gibt, wird auf der Anfahrt gestellt: **Was
|
||
liegt in der Nähe, und was gibt es her?**
|
||
|
||
### Keine Kacheln aus dem Netz
|
||
|
||
Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche Objekte sie im Blick
|
||
hat, ist niemandes Geschäft. FLORIAN lädt deshalb **keine** Kartenkacheln von
|
||
einem öffentlichen Dienst — auch nicht „nur zum Ausprobieren". Der Kartenserver
|
||
läuft selbst:
|
||
|
||
```bash
|
||
docker compose --profile karte up -d
|
||
```
|
||
|
||
Dafür wird eine MBTiles-Datei des eigenen Ausrückegebiets gebraucht; woher man
|
||
sie bekommt, steht in [docker/karten/LIESMICH.md](docker/karten/LIESMICH.md).
|
||
Danach in der `.env`:
|
||
|
||
```
|
||
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
|
||
```
|
||
|
||
**Ohne Kartenserver läuft alles trotzdem** — die Karte zeigt einen leeren Grund
|
||
mit einem Hinweis darauf. Punkte liegen an der richtigen Stelle, Entfernungen
|
||
stimmen, die Umkreissuche rechnet; es fehlen nur die Straßen darunter. Ist ein
|
||
Server eingetragen, aber nicht erreichbar, sieht es genauso aus — mit
|
||
entsprechendem Hinweis.
|
||
|
||
### Löschwasser
|
||
|
||
Hydranten, Saugstellen, Zisternen und offene Gewässer mit Typ, Nennweite,
|
||
Durchfluss, Zustand und dem, was man vor Ort wissen muss („im Winter oft
|
||
zugeschoben").
|
||
|
||
Die **Umkreissuche** ist der eigentliche Zweck: in die Karte tippen, und es
|
||
steht da, was in 500 Metern erreichbar ist — nach Entfernung sortiert, mit
|
||
Nennweite und Durchfluss. Sie läuft über PostGIS (`ST_DWithin` auf der
|
||
Geografie, GiST-Index), nicht über die Dezimalgrade: bei tausend Hydranten wäre
|
||
alles andere ein vollständiger Tabellendurchlauf, und gefragt wird während der
|
||
Fahrt.
|
||
|
||
### Import
|
||
|
||
Zwei Quellen, die eine Wehr tatsächlich bekommt:
|
||
|
||
| Quelle | Was erkannt wird |
|
||
|---|---|
|
||
| **GeoJSON** | FeatureCollection mit Punkten, Eigenschaften wie in der CSV |
|
||
| **CSV der Stadtwerke** | Spaltennamen wie `DN`, `Lage`, `Latitude`, `Pruefdatum`, `Bauart` |
|
||
|
||
Der Import ist nachsichtig, aber ehrlich: „DN 100" wird zu 100, „48,5031" zu
|
||
48.5031, „24.05.2026" zu einem Datum, und vertauschte Achsen werden erkannt und
|
||
zurückgedreht. Was nicht lesbar ist, wird mit Zeilennummer und Grund gemeldet —
|
||
und der Rest läuft durch. Eine Liste, die zu 90 Prozent stimmt, ist im Einsatz
|
||
mehr wert als keine.
|
||
|
||
**Geraten wird nichts.** Fehlt die Position, wird die Zeile übersprungen: ein
|
||
Hydrant ohne Ort ist kein Hydrant. Und ohne Statusangabe steht „ungeprüft",
|
||
nicht „einsatzbereit".
|
||
|
||
### Objekte und Feuerwehrpläne
|
||
|
||
Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom Beifahrersitz,
|
||
oft in zwanzig Sekunden. Deshalb sind **Zufahrt**, **Gefahren** und
|
||
**Schlüsseldepot** eigene Felder und stehen ganz oben, nicht in einer
|
||
Fließtextnotiz.
|
||
|
||
Dazu Ansprechpartner mit der einzigen Angabe, die im Einsatz zählt — wer nachts
|
||
ans Telefon geht — und die Planblätter nach DIN 14095 mit ihrem Stand.
|
||
|
||
Gesucht wird über alles: Name, Straße, Gefahrenstichwort, Ansprechpartner. Wer
|
||
im Einsatz sucht, weiß selten, wie das Objekt im System heißt.
|
||
|
||
### Lagekarte
|
||
|
||
Eine je Einsatz, unterhalb der Einsatzdaten. 22 taktische Zeichen nach
|
||
FwDV 102, selbst gezeichnet als SVG — fertige Sätze sind selten frei
|
||
verwendbar, und nachgeladen wird ohnehin nichts.
|
||
|
||
Ein Werkzeug, ein Klick: Zeichen wählen, in die Karte tippen. Dazu Linienzüge
|
||
(Schlauchleitung, Absperrung), Flächen (Bereitstellungsraum) und
|
||
Beschriftungen.
|
||
|
||
Der Abgleich zwischen Geräten läuft über denselben Ereignisstrom wie die
|
||
Atemschutzüberwachung, und wie dort steht der Zustand der Verbindung sichtbar
|
||
da. Zurückgenommene Elemente verschwinden von der Karte, nicht aus dem
|
||
Datenbestand: eine Lagekarte ist die Mitschrift einer Entscheidung.
|
||
|
||
### Wasserförderung
|
||
|
||
Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht.
|
||
Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen:
|
||
|
||
```
|
||
Reibungsverlust je 100 m = Beiwert × (Fördermenge / 100)²
|
||
Höhenverlust = Höhenmeter × 0,1 bar
|
||
```
|
||
|
||
Daraus: Zahl der Verstärkerpumpen, Schlauchlängen je Abschnitt, Restdruck und
|
||
der Druck, der an der Einsatzstelle ankommt. Die Beiwerte treffen die
|
||
Tabellenwerte der Ausbildung — 1,5 bar je 100 m im B-Schlauch bei 800 l/min.
|
||
|
||
Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
|
||
statt eine Zahl auszuwerfen, die niemand halten kann. Und unter jeder Rechnung
|
||
steht, was sie ist: **eine Planungshilfe. Was zählt, ist der Druck am
|
||
Verteiler.**
|
||
|
||
### Zusätzliche Umgebungsvariablen
|
||
|
||
| Variable | Vorgabe | Bedeutung |
|
||
|---|---|---|
|
||
| `KARTE_STIL_URL` | — | Stil des eigenen Kartenservers; leer = leerer Grund |
|
||
| `KARTE_BREITENGRAD` | `48.5` | Mittelpunkt, falls kein Standort verortet ist |
|
||
| `KARTE_LAENGENGRAD` | `12.15` | dito |
|
||
| `KARTE_ZOOM` | `13` | Zoomstufe beim Öffnen |
|
||
| `KARTE_PORT` | `8085` | Port des tileserver-gl (Profil `karte`) |
|
||
|
||
---
|
||
|
||
## Phase 7 — Benachrichtigungen und Termine
|
||
|
||
Der Satz, der über der ganzen Phase steht und der auch die Technik darunter
|
||
bestimmt: **Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
|
||
bekommt nichts. Es gibt keine Voreinstellung, die stillschweigend alle
|
||
anmeldet — ein neu eingerichteter Zustellweg hat eine leere Ereignisliste, und
|
||
erst wer ankreuzt, wofür er ihn will, bekommt etwas.
|
||
|
||
### Der Terminkalender
|
||
|
||
Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen. Drei Entscheidungen prägen
|
||
ihn, und alle drei kommen aus dem Alltag einer Wehr:
|
||
|
||
**Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen Übungsabend
|
||
meldet sich niemand an, man kommt oder man kommt nicht. Erst beim Lehrgang mit
|
||
zwölf Plätzen und beim Ausflug mit Busbestellung wird eine Rückmeldung
|
||
verlangt.
|
||
|
||
**Abgesagt, nicht gelöscht.** Ein Termin, der ohne ein Wort verschwindet, führt
|
||
dazu, dass am Dienstagabend jemand vor dem verschlossenen Gerätehaus steht.
|
||
Eine Absage braucht einen Grund, bleibt im Kalender stehen und erreicht die,
|
||
die zugesagt hatten — nicht die ganze Wehr.
|
||
|
||
**Die Warteliste rückt von selbst nach.** Wer bei belegten Plätzen zusagt,
|
||
landet auf der Warteliste; sagt jemand ab, rückt der Erste nach und erfährt es.
|
||
Sonst führt jemand die Liste von Hand, und das tut niemand zuverlässig.
|
||
|
||
Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
|
||
Abend ist ein eigener Datensatz. Der Grund ist derselbe Alltag — 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. Mit einer Regel wäre jede
|
||
dieser Ausnahmen ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele
|
||
Termine daraus werden.
|
||
|
||
Die Anwesenheitsliste zeigt **alle aktiven Mitglieder**, nicht nur die
|
||
Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen. Zusage und
|
||
Anwesenheit sind getrennt — wer zugesagt hat und nicht kam, steht genau so in
|
||
der Liste.
|
||
|
||
### Der Kalender fürs Handy
|
||
|
||
Eine Adresse, die man im Handy oder im Mailprogramm abonniert; die Termine
|
||
stehen dann neben allem anderen, ohne dass jemand FLORIAN öffnen muss. Das ist
|
||
der Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
|
||
|
||
**Bewusst kein CalDAV.** Das ist ein Schreib-Lese-Protokoll mit
|
||
Rechteverwaltung und Konfliktauflösung; gebraucht wird hier nur Lesen, und dafür
|
||
genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender kann das.
|
||
|
||
Die Adresse ist der Schlüssel — Kalenderprogramme können sich nicht anmelden,
|
||
sie rufen stumpf eine URL ab. Daraus folgt:
|
||
|
||
- Gespeichert wird nur der SHA-256 des Tokens; die Adresse steht **genau
|
||
einmal** auf dem Bildschirm.
|
||
- Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet einen
|
||
Klick, nicht ein neues Passwort für alle.
|
||
- Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine Personen
|
||
außer der Leitung, keine Mängel, keine Einsätze.
|
||
- Wer aus der Wehr austritt, verliert das Abonnement.
|
||
|
||
### Die vier Kanäle
|
||
|
||
| Kanal | Läuft über | Bekommt |
|
||
|---|---|---|
|
||
| **E-Mail** (SMTP) | den eigenen bzw. den Mailserver der Gemeinde | den ausführlichen Text |
|
||
| **ntfy** (selbst gehostet) | den eigenen Server, Profil `push` | den ausführlichen Text |
|
||
| **Telegram** | einen fremden Server | nur die Kurzfassung |
|
||
| **Web-Push** | den Push-Dienst des Browserherstellers | nur die Kurzfassung |
|
||
|
||
Die Spalte ganz rechts ist keine Bequemlichkeit, sondern die Regel: was das Netz
|
||
der Wehr verlässt, bekommt einen Hinweis ohne Namen, Gerätebezeichnungen und
|
||
Einsatzdetails — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht das
|
||
neben jedem externen Kanal, und die Vorlagenmaske sagt es noch einmal, bevor
|
||
jemand einen Namen in eine Telegram-Vorlage tippt.
|
||
|
||
**ntfy** ist der interessanteste Weg für eine Wehr: ein Container, eine App im
|
||
Store, kein Konto, keine Telefonnummer, kein Telefonbuchabgleich. Man abonniert
|
||
ein Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. Läuft
|
||
der Server im Gerätehaus, verlässt keine Nachricht das Haus. Der Preis: ein
|
||
Thema ist nur so geheim wie sein Name, deshalb schlägt FLORIAN Namen mit
|
||
Zufallsanteil vor.
|
||
|
||
```bash
|
||
docker compose --profile push up -d # ntfy startet auf Port 8086
|
||
npm run vapid # Schlüsselpaar für Web-Push
|
||
```
|
||
|
||
### Warum kein WhatsApp
|
||
|
||
Es gibt keine `WHATSAPP_`-Variablen, und das ist eine Entscheidung, keine
|
||
Lücke. Drei Gründe, in dieser Reihenfolge:
|
||
|
||
**Datenschutz.** Der einzige zulässige Weg für eine Organisation ist die
|
||
WhatsApp Business Platform. Nachrichten laufen über Server von Meta, und die
|
||
Wehr müsste einen Vertrag zur Auftragsverarbeitung schließen — für eine
|
||
Freiwillige Feuerwehr, deren Daten ausdrücklich im Gerätehaus bleiben sollen,
|
||
ist das der falsche Weg. Die gewöhnliche App scheidet ohnehin aus: sie gleicht
|
||
das Telefonbuch ab und gibt damit die Kontakte aller Mitglieder weiter.
|
||
|
||
**Kosten.** Abgerechnet wird je Konversation. Eine Wehr mit fünfzig Mitgliedern
|
||
und zwei Terminerinnerungen im Monat zahlt dauerhaft für etwas, das über den
|
||
eigenen Mailserver nichts kostet.
|
||
|
||
**Aufwand.** Business-Konto, verifizierte Telefonnummer, genehmigte
|
||
Nachrichtenvorlagen, ein Meta-Entwicklerkonto. Das ist mehr Verwaltung als der
|
||
gesamte Rest dieser Phase.
|
||
|
||
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
|
||
und verrät niemandem etwas.
|
||
|
||
### Der digitale Dienstausweis
|
||
|
||
Ein QR-Code auf dem Handy, den man an der Absperrung, beim Lehrgang oder bei
|
||
einer Kontrolle vorzeigt. Wer ihn scannt, landet auf einer Prüfseite und sieht
|
||
Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne sich
|
||
anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
|
||
|
||
- **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel.
|
||
Keine Tabelle, kein Aufräumjob.
|
||
- **Fünf Minuten gültig.** Er wird vorgezeigt, abgelesen und weggesteckt; ein
|
||
abfotografierter Code ist danach wertlos. Die Prüfseite nennt deshalb den
|
||
Zeitpunkt der Ausstellung — ein Ausweis, der „gültig" sagt, ohne zu sagen
|
||
seit wann, ist wertlos.
|
||
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. Wer den
|
||
Code hat, bekommt keinen Zugang zu einem einzigen Datensatz.
|
||
- Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
|
||
Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
|
||
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird abgewiesen.
|
||
|
||
### Was von selbst benachrichtigt
|
||
|
||
Sechs Anlässe, an die vorhandenen Abläufe angehängt:
|
||
|
||
| Anlass | Wann | An wen |
|
||
|---|---|---|
|
||
| Prüfung fällig | im Erinnerungslauf | wer es abonniert hat |
|
||
| Mangel gemeldet | sofort | wer es abonniert hat |
|
||
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | die Person selbst |
|
||
| Neuer Termin | beim Anlegen | wer es abonniert hat |
|
||
| Terminerinnerung | 7 Tage und 1 Tag vorher | Angemeldete, sonst Abonnenten |
|
||
| Einsatzbericht freigegeben | bei der Freigabe | wer es abonniert hat |
|
||
|
||
Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war — sie geht an
|
||
den, der zuständig ist, unabhängig davon, ob er irgendwo ein Kreuzchen gesetzt
|
||
hat. Ein Prüftermin ist eine Pflicht, kein Abo. Wer sie ohnehin bekommt, wird
|
||
von der Benachrichtigung ausgenommen und hat sie damit nicht doppelt.
|
||
|
||
Erinnert wird um sieben Uhr morgens, nicht nachts: eine Push-Meldung um drei Uhr
|
||
weckt Leute, die am nächsten Tag arbeiten müssen. Zugestellt wird dagegen jede
|
||
Minute — eine Absage am Dienstagabend muss ankommen, solange sie noch etwas
|
||
nützt.
|
||
|
||
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur eine
|
||
Zeile geschrieben; der Worker schickt sie weg und vermerkt, wie es ausging.
|
||
Sonst hinge das Speichern eines Termins am Antwortverhalten von Telegram. Eine
|
||
fehlgeschlagene Zustellung bleibt sichtbar stehen und wird nicht nach Frist
|
||
abgeräumt — sie ist der Hinweis darauf, dass ein Kanal kaputt ist, und der
|
||
verliert seinen Wert nicht durch Zeitablauf.
|
||
|
||
Der Probeknopf steht direkt an jedem Zustellweg und läuft durch dieselbe
|
||
Maschinerie wie alles andere. Ohne ihn merkt jemand erst in drei Wochen, dass
|
||
sein ntfy-Thema einen Tippfehler hat — nämlich dann, wenn die Nachricht wichtig
|
||
gewesen wäre.
|
||
|
||
### Zusätzliche Umgebungsvariablen
|
||
|
||
| Variable | Vorgabe | Bedeutung |
|
||
|---|---|---|
|
||
| `NTFY_ENABLED` | `false` | Push über den eigenen ntfy |
|
||
| `NTFY_URL` | — | Adresse des eigenen ntfy, z. B. `http://ntfy:80` |
|
||
| `NTFY_TOKEN` | — | nur bei eingeschaltetem Zugriffsschutz |
|
||
| `NTFY_PORT` | `8086` | Port des ntfy-Containers (Profil `push`) |
|
||
| `WEBPUSH_ENABLED` | `false` | Push in den Browser |
|
||
| `WEBPUSH_PUBLIC_KEY` | — | aus `npm run vapid`; **ins Backup** |
|
||
| `WEBPUSH_PRIVATE_KEY` | — | dito |
|
||
| `WEBPUSH_KONTAKT` | `APP_URL` | Kontaktweg für den Push-Dienst |
|
||
| `BENACHRICHTIGUNG_LAUF` | `0 7 * * *` | wann erinnert wird |
|
||
| `TERMIN_ERINNERUNG_TAGE` | `7,1` | Vorlauf der Terminerinnerungen |
|
||
| `BENACHRICHTIGUNG_VERSUCHE` | `3` | Zustellversuche, bevor es stehen bleibt |
|
||
| `BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE` | `90` | Frist für **zugestellte** Nachrichten |
|
||
|
||
Ein neuer öffentlicher VAPID-Schlüssel macht alle bestehenden Push-Abos
|
||
wertlos — jeder müsste Push von Hand neu erlauben, und niemand merkt es, die
|
||
Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
|
||
|
||
---
|
||
|
||
## Phase 8 — Lokale KI
|
||
|
||
Ein Satz bestimmt alles, was hier gebaut wurde:
|
||
|
||
> **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.**
|
||
|
||
Daraus folgt der Aufbau: jede KI-Ausgabe ist zuerst ein eigener Datensatz und
|
||
nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst,
|
||
wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf daneben
|
||
und ist als Entwurf erkennbar, mit gestricheltem Rahmen und dem Modellnamen
|
||
darüber. Das ist der Unterschied zwischen einer Hilfe und einer Fälschung.
|
||
|
||
Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper als
|
||
Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die das
|
||
Gerätehaus verlässt. Es gibt **keinen Rückfall** auf einen Cloud-Dienst: fehlen
|
||
die Container, fehlt die Funktion. Das ist die einzige Bauart, bei der der Satz
|
||
„die Daten bleiben im Haus" auch dann noch stimmt, wenn der eigene Server aus
|
||
ist.
|
||
|
||
### Ohne Modelle geht alles andere weiter
|
||
|
||
Die wichtigste Eigenschaft der Phase, und die am meisten Arbeit gemacht hat:
|
||
**FLORIAN bleibt ohne Sprachmodell vollständig benutzbar.** Wiki lesen und
|
||
schreiben, Wortsuche, Belege ablegen und von Hand ausfüllen, beide Protokolle —
|
||
alles läuft. Wo etwas fehlt, steht ein Satz, der sagt *was* fehlt und *wie es
|
||
ohne geht*, statt eines kaputten Knopfes.
|
||
|
||
Das ist kein Zugeständnis, sondern der Normalfall: eine Wehr setzt FLORIAN auf,
|
||
lange bevor sie über eine Grafikkarte nachdenkt.
|
||
|
||
### Das Wiki
|
||
|
||
Dienstvorschriften, Ausbildungsunterlagen, Merkblätter — das, was sonst als PDF
|
||
im Ordner steht und im Einsatz niemand findet. Inhalt ist **Markdown**, weil es
|
||
sich ohne Werkzeug lesen lässt, auch in zwanzig Jahren, auch ohne FLORIAN.
|
||
|
||
**Zwei Suchen nebeneinander**, und beide werden gebraucht:
|
||
|
||
| Suche | Findet | Braucht ein Modell |
|
||
|---|---|---|
|
||
| **Wortsuche** | was dasteht — „FwDV 7" findet die Seite mit dieser Zeichenfolge | nein |
|
||
| **Frage** | was gemeint ist — „wie lange hält ein Schlauch" findet den Abschnitt über Ablegereife | ja |
|
||
|
||
Die Frage wird **ausschließlich aus den eigenen Seiten** beantwortet, mit
|
||
Fundstellen darunter. Steht die Antwort nicht drin, sagt FLORIAN genau das. Ein
|
||
Modell, das auf die Frage nach der Prüffrist von Steckleitern eine Zahl nennt,
|
||
die nirgends steht, wäre gefährlicher als gar keine Antwort — deshalb wird ohne
|
||
passende Auszüge gar nicht erst gefragt.
|
||
|
||
Zerlegt wird an den **Überschriften**, nicht nach Zeichenzahl: eine Fundstelle
|
||
soll ein Kapitel sein, kein aus der Mitte gerissener Absatz. Und ein kurzer
|
||
Abschnitt wird nie unter eine fremde Überschrift gehängt — sonst zitierte die
|
||
Antwort eine Fundstelle, in der der Satz gar nicht steht.
|
||
|
||
Jede Änderung schreibt die vorherige Fassung weg. Eine Dienstvorschrift, die
|
||
sich unbemerkt ändert, ist schlimmer als keine.
|
||
|
||
### Diktat
|
||
|
||
Der Weg, den es in jeder Wehr schon gibt: der Einsatzleiter spricht auf der
|
||
Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Neu ist nur, dass
|
||
abends schon ein Text dasteht.
|
||
|
||
Ein Knopf, keine Einstellungen. Die Aufnahme bleibt abgelegt, solange über den
|
||
Text nicht entschieden ist — wer dem Transkript nicht traut, hört nach. Und das
|
||
Modell darf **nichts verbessern**: „Hohlstrahlrohr" bleibt „Hohlstrahlrohr",
|
||
auch wenn es das Wort nicht kennt.
|
||
|
||
### Berichtsassistent
|
||
|
||
Stichpunkte hinein, Entwurf heraus. Was das Modell **nicht** bekommt, ist ebenso
|
||
wichtig: Zeitstempel und Einsatznummer stehen schon als Felder im Bericht und
|
||
stünden sonst zweimal da, beim zweiten Mal womöglich falsch.
|
||
|
||
Der Entwurf landet nicht im Berichtsfeld. Er steht daneben, gekennzeichnet, mit
|
||
zwei gleich großen Knöpfen — „Übernehmen" ist nicht der Standardweg und sieht
|
||
auch nicht so aus.
|
||
|
||
### Belegerfassung
|
||
|
||
Quittung fotografieren, Angaben prüfen, gegenzeichnen. Alle Felder sind
|
||
änderbar, und was das Modell nicht erkannt hat, bleibt **leer und gelb
|
||
hinterlegt** statt geraten zu werden. Ein leeres Feld ist eine Aufforderung; ein
|
||
geratener Betrag ist eine falsche Buchung, die niemand bemerkt.
|
||
|
||
Die Buchung daraus kommt mit der Kasse in Phase 9 — hier entsteht erst einmal
|
||
ein sauber erfasster Beleg mit Foto.
|
||
|
||
### Zwei Protokolle nebeneinander
|
||
|
||
| Ansicht | Beantwortet |
|
||
|---|---|
|
||
| **KI-Protokoll** | Was hat die Maschine getan? Jede Befragung, jedes Modell, jede Entscheidung mit Namen und Zeitpunkt. |
|
||
| **Protokoll** (Audit-Log) | Was haben Menschen getan? Wird seit Phase 0 geschrieben; die Ansicht dazu kam jetzt. |
|
||
|
||
Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem Anlegen
|
||
**unveränderlich** — ein Trigger hält dagegen. Löschen ist der Anwendungsrolle
|
||
entzogen, 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.
|
||
|
||
### Einrichtung
|
||
|
||
Postgres braucht seit dieser Phase **pgvector** neben PostGIS. Es gibt kein
|
||
offizielles Image mit beidem, deshalb baut FLORIAN eines
|
||
(`docker/postgres/Dockerfile`):
|
||
|
||
```bash
|
||
docker compose build postgres # bei einer bestehenden Installation
|
||
docker compose up -d postgres # Datenverzeichnis bleibt unberührt
|
||
npm run db:migrate
|
||
```
|
||
|
||
Dann die Modelle:
|
||
|
||
```bash
|
||
docker compose --profile ki up -d
|
||
|
||
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 in die `docker-compose.override.yml`:
|
||
|
||
```yaml
|
||
services:
|
||
ollama:
|
||
deploy:
|
||
resources:
|
||
reservations:
|
||
devices:
|
||
- driver: nvidia
|
||
count: all
|
||
capabilities: [gpu]
|
||
```
|
||
|
||
### Ein anderes Einbettungsmodell
|
||
|
||
Die Spalte `wikiabschnitt.einbettung` ist auf **768 Dimensionen** festgelegt —
|
||
das liefert `nomic-embed-text`. Ein Modell mit anderer Länge braucht eine
|
||
Migration:
|
||
|
||
```sql
|
||
ALTER TABLE "wikiabschnitt" DROP COLUMN "einbettung";
|
||
ALTER TABLE "wikiabschnitt" ADD COLUMN "einbettung" vector(1024);
|
||
CREATE INDEX "wikiabschnitt_einbettung_idx"
|
||
ON "wikiabschnitt" USING hnsw ("einbettung" vector_cosine_ops);
|
||
```
|
||
|
||
Danach `LLM_EINBETTUNG_DIMENSIONEN` anpassen und alle Seiten neu einbetten. Der
|
||
nächtliche Lauf holt das von selbst nach; wer nicht warten will, drückt auf
|
||
jeder Seite „jetzt nachziehen".
|
||
|
||
Ohne Anpassung weist FLORIAN das Modell mit einer klaren Meldung ab, statt eine
|
||
Fehlermeldung der Datenbank durchzureichen, die niemand versteht.
|
||
|
||
### Zusätzliche Umgebungsvariablen
|
||
|
||
| Variable | Vorgabe | Bedeutung |
|
||
|---|---|---|
|
||
| `LLM_ENABLED` | `false` | Schaltet alle Textfunktionen ein |
|
||
| `LLM_BASE_URL` | — | OpenAI-kompatibler Endpunkt, z. B. `http://ollama:11434/v1` |
|
||
| `LLM_MODELL` | — | Modell für Berichte und Antworten |
|
||
| `LLM_TIMEOUT` | `120` | Zeitlimit in Sekunden |
|
||
| `LLM_EINBETTUNG_MODELL` | `nomic-embed-text` | Modell für die semantische Suche |
|
||
| `LLM_EINBETTUNG_DIMENSIONEN` | `768` | **Muss zur Spalte passen** |
|
||
| `LLM_VISION_MODELL` | — | Modell für die Belegerfassung |
|
||
| `WHISPER_ENABLED` | `false` | Schaltet das Diktat ein |
|
||
| `WHISPER_URL` | — | z. B. `http://whisper:8000` |
|
||
| `WHISPER_MODELL` | `base` | `tiny`, `base`, `small` |
|
||
| `DIKTAT_MAX_SEKUNDEN` | `600` | Höchstlänge einer Aufnahme |
|
||
| `OLLAMA_PORT` | `11435` | Port des Ollama-Containers (Profil `ki`) |
|
||
| `WHISPER_PORT` | `8087` | Port des Whisper-Containers (Profil `ki`) |
|
||
|
||
---
|
||
|
||
## Phase 9 — Kassenverwaltung
|
||
|
||
Kassenbuch, Mitgliedsbeiträge, Rechnungen an Dritte, Kassenprüfung und die
|
||
Zahlen für die Jahreshauptversammlung.
|
||
|
||
Eine Feuerwehrkasse wird von einem Ehrenamtlichen geführt und von zwei anderen
|
||
geprüft. Was die Prüfung wert ist, hängt einzig daran, dass sich das Geprüfte
|
||
hinterher nicht mehr ändern lässt. Deshalb ist der zentrale Punkt dieser Phase
|
||
kein Formular, sondern eine Sperre.
|
||
|
||
### Drei Festlegungen
|
||
|
||
**Eine festgeschriebene Buchung ist unveränderbar.** Nicht, weil ein Zweig im
|
||
Router es so vorsieht, sondern weil die Datenbank es ablehnt — ein vergessener
|
||
Zustandscheck, ein Skript, ein Import würden sonst reichen. Korrigiert wird
|
||
über eine **Gegenbuchung** mit Verweis: beide Buchungen bleiben stehen, und im
|
||
Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein
|
||
Kassenprüfer sehen; eine verschwundene Buchung kann er nicht prüfen.
|
||
|
||
**Der Kassenabschluss schließt seinen Zeitraum.** Danach wird keine Buchung
|
||
mit einem Datum darin mehr angenommen — auch keine nachträglich
|
||
hineindatierte. Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach
|
||
einem Datumswechsel nicht mehr, und niemand hätte es bemerkt. Wer nach der
|
||
Prüfung noch eine Quittung findet, bucht sie mit dem heutigen Datum.
|
||
|
||
**Beträge sind `DECIMAL(12,2)`, gerechnet wird mit `Prisma.Decimal`.**
|
||
`0.1 + 0.2` ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das
|
||
kein Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand
|
||
erklären kann.
|
||
|
||
### Das Kassenbuch
|
||
|
||
Drei Kontenarten: Barkasse, Bankkonto, Rücklage. Der **Bestand wird gerechnet,
|
||
nie gespeichert** — Anfangsbestand plus die Wirkung aller Buchungen. Ein
|
||
gespeicherter Bestand neben einer nachgetragenen Buchung wäre falsch, ohne
|
||
falsch auszusehen, und genau danach sucht ein Kassenprüfer.
|
||
|
||
Der Betrag einer Buchung ist immer positiv; die Richtung steht daneben. Eine
|
||
negative Ausgabe wäre eine Einnahme in Verkleidung und in keiner Auswertung
|
||
als solche zu erkennen — die Datenbank nimmt sie deshalb nicht an.
|
||
|
||
Die Buchungsmaske schlägt eine Kategorie vor: zuerst aus den **eigenen
|
||
früheren Buchungen** der Wehr, erst danach aus einer kurzen Stichwortliste.
|
||
Der Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass dieselbe
|
||
Sache immer unter derselben Kategorie landet — erst das macht die
|
||
Jahresauswertung brauchbar. Unter der Hälfte gemeinsamer Wörter schweigt der
|
||
Vorschlag: ein falscher wird übernommen, ohne gelesen zu werden.
|
||
|
||
Ein **gegengezeichneter Beleg** aus Phase 8 wird mit einem Klick zur Buchung:
|
||
Konto und Kategorie wählen, fertig. Betrag, Datum und Händler stehen schon
|
||
fest, und das Foto hängt danach an der Buchung — zu jeder Ausgabe ein Beleg,
|
||
an der Buchung und nicht in einem Ordner daneben. Ein Entwurf lässt sich
|
||
nicht buchen: er ist ein von einem Modell ausgefülltes Formular.
|
||
|
||
### Kontoauszug einlesen
|
||
|
||
Das Online-Banking bietet beides zum Herunterladen an, meist unter
|
||
„Umsatzexport“:
|
||
|
||
| Format | Datei | Was gelesen wird |
|
||
|---|---|---|
|
||
| CAMT.053 | `.xml`, `.camt` | Datum, Richtung, Betrag, Verwendungszweck, Gegenseite, Bankkennung |
|
||
| MT940 | `.txt`, `.sta` | dasselbe, aus den Feldern `:61:` und `:86:` |
|
||
|
||
Erkannt wird am Inhalt, nicht an der Dateiendung. Es entstehen **Entwürfe,
|
||
keine fertigen Buchungen** — durchsehen muss sie der Kassier.
|
||
|
||
Jeder Umsatz trägt eine Kennung: die der Bank, sonst eine aus Datum, Betrag
|
||
und Zweck gebildete. Derselbe Auszug zweimal eingelesen wird dadurch als
|
||
solcher erkannt und in der Liste als „steht schon in der Kasse“ ausgewiesen.
|
||
Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim
|
||
Kassenabschluss auf.
|
||
|
||
Was nicht gelesen werden konnte, steht mit Grund daneben. Ein Parser, der
|
||
stillschweigend drei Umsätze verschluckt, ist schlimmer als einer, der gar
|
||
nicht erst liest.
|
||
|
||
### Mitgliedsbeiträge
|
||
|
||
Sätze gelten ab einem Jahr und je Abteilung; ein Satz ohne Abteilung gilt für
|
||
alle übrigen. Rückwirkend geändert wird nichts — sonst stimmten die Beiträge
|
||
vergangener Jahre nicht mehr, und die sind bereits geprüft.
|
||
|
||
Der **Beitragslauf** zeigt vorher, was entstehen wird: wie viele Beiträge,
|
||
welche Summe, und wer keinen passenden Satz hat. Der letzte Teil ist der
|
||
interessante — das sind die Leute, für die niemand entschieden hat, was sie
|
||
zahlen. Der Lauf ist wiederholbar; im Februar noch einmal gestartet legt er
|
||
nur die Neuen an.
|
||
|
||
Ein Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der
|
||
Kasse wäre eine Behauptung. Ein Erlass braucht eine Begründung — die Datenbank
|
||
lehnt einen ohne ab, weil er in der Kassenprüfung sonst eine offene Frage ist.
|
||
|
||
Die Rückstandsliste geht über alle Jahre. Sie ist die einzige Ansicht, in der
|
||
auffällt, wer seit drei Jahren offen steht.
|
||
|
||
### Rechnungen an Dritte
|
||
|
||
Kostenersatz nach Art. 28 BayFwG, Vermietungen, Veranstaltungen. Die Nummer
|
||
vergibt FLORIAN fortlaufend je Jahr und **lückenlos**: eine fehlende Nummer
|
||
muss in der Kassenprüfung erklärt werden.
|
||
|
||
Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben. Ein
|
||
eingetippter Gesamtbetrag neben Positionen, die etwas anderes ergeben, ist der
|
||
Fehler, den niemand bemerkt, bis der Empfänger anruft. Die Menge hat drei
|
||
Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet, und
|
||
0,25 × 47,50 muss auf den Cent stimmen.
|
||
|
||
Bis zum Versenden ist eine Rechnung ein Entwurf, danach unveränderbar: was
|
||
beim Empfänger liegt, muss dem entsprechen, was hier steht. Korrigiert wird
|
||
über Storno und eine neue Rechnung; die stornierte bleibt mit Grund stehen.
|
||
|
||
### Kassenprüfung
|
||
|
||
In der Prüfmaske steht links der Bestand laut Buch und rechts der gezählte.
|
||
In dieser Reihenfolge, und die gezählten Felder fangen leer an — wer erst das
|
||
Buch sieht und dann zählt, zählt, bis es passt.
|
||
|
||
Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben: ohne beides ist es
|
||
kein Abschluss, sondern eine Notiz. Eine Differenz braucht eine Erläuterung,
|
||
sonst steht in fünf Jahren „stimmt nicht“ im Bericht und niemand weiß mehr,
|
||
warum.
|
||
|
||
Der Kassenprüfbericht entsteht als PDF mit vier Unterschriftsfeldern und wird
|
||
im Hintergrund erzeugt.
|
||
|
||
### Auswertungen
|
||
|
||
Einnahmen und Ausgaben nach Kategorie und Monat, mit dem Vorjahr daneben —
|
||
eine Zahl ohne Vergleich sagt nichts. Der Export ist eine CSV-Datei mit
|
||
Semikolon und BOM: das ist, was Excel in Deutschland ohne Rückfrage richtig
|
||
öffnet. Eine Datei, die beim Doppelklick als eine Spalte voller Semikolons
|
||
erscheint, wird zurückgeschickt.
|
||
|
||
### Wer was sieht
|
||
|
||
| Recht | Wer | Was |
|
||
|---|---|---|
|
||
| `kasse.lesen` | Kassier, Kommandant, Vorstand | Buchungen und Auswertungen ansehen |
|
||
| `kasse.bearbeiten` | Kassier | Buchungen, Beiträge, Rechnungen erfassen |
|
||
| `kasse.abschluss` | Kassier, Kassenprüfer | Kassenabschluss anlegen und festschreiben |
|
||
|
||
Der Gerätewart sieht keine Buchungen, der Kassier keine Atemschutzdaten. Die
|
||
Trennung liegt in `lib/authz.ts`, nicht in den Komponenten.
|
||
|
||
**Keine zusätzlichen Umgebungsvariablen.** Die Kasse braucht nur, was ohnehin
|
||
läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs.
|
||
|
||
---
|
||
|
||
## Befehle
|
||
|
||
```bash
|
||
npm run dev # Entwicklungsserver auf Port 3100
|
||
npm run build # Produktionsbuild
|
||
npm run worker:dev # Hintergrundprozess mit Neuladen
|
||
npm run test # Vitest
|
||
npm run test:e2e # Playwright (vorher: npm run db:test-reset)
|
||
npm run lint # ESLint
|
||
npm run lint:fix # ESLint mit automatischen Korrekturen
|
||
npm run typecheck # TypeScript ohne Ausgabe
|
||
npm run db:migrate # ausstehende Migrationen anwenden
|
||
npm run db:seed # Beispielwehr einspielen
|
||
npm run db:test-reset # Testwehr für die E2E-Tests neu aufbauen
|
||
npm run db:studio # Prisma Studio
|
||
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
|
||
npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen
|
||
```
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
npm run test # Vitest — Unit und Datenbankregeln
|
||
npm run db:test-reset # einmalig vor dem E2E-Lauf
|
||
npm run test:e2e # Playwright
|
||
```
|
||
|
||
### Zwei Wehren in der Datenbank
|
||
|
||
Die E2E-Tests laufen gegen eine **eigene Wehr** (`ff-teststelle`), nicht gegen
|
||
die Beispielwehr aus der Anleitung. Der Grund ist ein handfester: bis Phase 8
|
||
schrieben sie in `ff-musterhausen`, und nach fünf Phasen standen dort
|
||
120 Atemschutz-Überwachungen statt einer und 115 Geräte statt 45. Zweimal
|
||
fielen Tests aus, weil ein gesuchter Eintrag von Seite 1 gerutscht war — und
|
||
bei jedem Lauf unterschieden sich alle 72 Bildschirmfotos.
|
||
|
||
`npm run db:test-reset` löscht die Testwehr und legt sie neu an, mit denselben
|
||
Beispieldaten. Die Beispielwehr wird dabei nicht angefasst; ein Sicherungsriegel
|
||
im Skript bricht ab, wenn beide Kennungen übereinstimmen.
|
||
|
||
Anmeldung der Testwehr: `e2e@teststelle.de` / `Feuerwehr2026`, änderbar über
|
||
`E2E_ADMIN_EMAIL` und `E2E_ADMIN_PASSWORT`.
|
||
|
||
## Dienste und Ports (Entwicklung)
|
||
|
||
Bewusst nicht die Standardports, damit FLORIAN neben anderen lokalen Stacks
|
||
laufen kann. Alle Werte stehen in der `.env`.
|
||
|
||
| Dienst | Adresse |
|
||
|---|---|
|
||
| Anwendung | http://localhost:3100 |
|
||
| PostgreSQL 17 + PostGIS | `localhost:5442` |
|
||
| Redis | `localhost:6389` |
|
||
| MinIO API | http://localhost:9010 |
|
||
| MinIO Weboberfläche | http://localhost:9011 |
|
||
| Gotenberg | http://localhost:3101 |
|
||
| Mailpit (nur Entwicklung) | http://localhost:8125 |
|
||
| ntfy (Profil `push`) | http://localhost:8086 |
|
||
| Ollama (Profil `ki`) | http://localhost:11435 |
|
||
| faster-whisper (Profil `ki`) | http://localhost:8087 |
|
||
| Adminer (nur Entwicklung) | http://localhost:8081 |
|
||
|
||
## Produktionsbetrieb
|
||
|
||
```bash
|
||
docker compose -f docker-compose.yml up -d
|
||
```
|
||
|
||
Die Angabe von `-f` ist wichtig: ohne sie legt Docker Compose automatisch
|
||
`docker-compose.override.yml` darüber, und das ist die Entwicklungsvariante
|
||
ohne Traefik. In Produktion terminiert Traefik TLS und verteilt auf
|
||
`APP_DOMAIN` und `MINIO_DOMAIN`.
|
||
|
||
Der App-Container wendet beim Start ausstehende Migrationen an; der Worker
|
||
tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
|
||
|
||
## Datenschutz
|
||
|
||
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur
|
||
Schriften verwendet, die auf dem Gerät vorhanden sind.
|
||
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
|
||
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
|
||
- Externe Dienste (Mail, Telegram, ntfy, Web-Push) sitzen hinter
|
||
Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
|
||
Freischaltung wird nur ins Log geschrieben.
|
||
- **Die KI-Funktionen laufen ausschließlich gegen selbst betriebene Modelle.**
|
||
Es gibt keinen Rückfall auf einen Anbieter: fehlt der eigene Endpunkt, fehlt
|
||
die Funktion. Kein Text verlässt das Gerätehaus.
|
||
- Jede KI-Ausgabe ist gekennzeichnet, wird als eigener Datensatz festgehalten
|
||
und muss von einem Menschen übernommen werden, bevor sie in einen Nachweis
|
||
gelangt. Nachvollziehbar im KI-Protokoll.
|
||
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
|
||
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
|
||
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
|
||
|
||
## Sicherheitshinweis
|
||
|
||
Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Solange die
|
||
Wehr das System nicht ausdrücklich freigegeben hat, bleibt die **analoge
|
||
Überwachung führend**. Jede entsprechende Ansicht zeigt diesen Hinweis
|
||
dauerhaft an.
|