# Devlog Entwicklungstagebuch mit Zeiterfassung. Jeder Arbeitsabschnitt bekommt einen Eintrag: wann, wie lange, was entstanden ist, und was dabei schiefging. Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte „tatsächlich" der [Aufwandsschätzung](AUFWANDSSCHAETZUNG.md). ## Zeitübersicht | Phase | Sitzungszeit | Sitzungen | abgeschlossen | |---|---:|---:|---| | Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 | | Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 | | Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 | | Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 | | Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 | | Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 | | Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 | | Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 | | 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` | 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: `-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 | `
` in einem `

` 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 ``, nicht der Absatz ein `

`.** 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 `