feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s

- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:43:44 +02:00
1 parent 8a31c42578
commit c18860c75c
73 files changed
+7121 -309

No files matched your search

+88 -11
View File
@@ -23,6 +23,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `ExtractionsModule` | Schleuderungen | Harvests |
| `HoneyBatchesModule` | Honigchargen, Honigmessungen | Extractions |
| `StorageModule` | Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) | HoneyBatches |
| `VeterinaryMedicinesModule` | Tierarzneimittel-Katalog, Packungen, Bewegungsbuch (T6, Abschnitt 12.2); exportiert `VeterinaryMedicinesService` und `VeterinaryMedicineLotsService` für die Behandlung (Task 3) | – |
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
@@ -119,7 +120,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung.
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. Seit T6 gilt für die Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes (Abschnitt 12.3, Annahmen 69 und 70).
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Auch die Königin-Zuordnung (`QueenAssigned` → `calendar-sync.queen-assignment`) und die Wartezeit (`calendar-sync.withdrawal`, Abschnitt 11.8) laufen so; `CalendarSyncService.enqueue` und das Einreihen nach dem Commit gibt es nicht mehr, nur `enqueueIn`. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung/Wartezeit) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender und `getWithdrawalStatus` rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. Auch bei der Schleuderung warnt die App (`WITHDRAWAL_ACTIVE`, Abschnitt 11.12). Diese Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`: Lebensmittelsicherheit geht vor der Lese-Trennung.
@@ -167,7 +168,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
47. **Pflanzen-Meldungen, Neuberechnung und Saisonwechsel**: Rechte: `phenology:read` (Liste), `phenology:create` (Melden), `phenology:update` (Bearbeiten und Zurückziehen); `GET /phenology/state` braucht `apiaries:read` und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); `apiaryId` leer (`""`) ergibt 400, `null` heißt „alle Stände“. Wer `phenology:update` hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. `PhenologyReported` und `PhenologyCorrected` (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. `PhenologyCorrected` trägt `previousApiaryId` (beim Zurückziehen gleich `apiaryId`): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite `null` (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler `phenology.recompute` rechnet für den späteren von Berliner Tag des Ereignisses (`occurredAt`) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (kein Schlüssel), und `phenology.recompute-apiary` rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. **Bewusste Abweichung von der Spec:** Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. `SeasonPhaseChanged` bleibt für den Autopiloten.
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), mit einer Ausnahme (Entscheidung zu Task 27): `Apiary` wird vor `Colony` gesperrt. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Karte im Code führt `Apiary` weiterhin weiter hinten (Stand dieses Dokuments): Die Ausnahme steht als Regel hier, nicht in der Karte.
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), `Apiary` steht seit T6 an erster Stelle der Karte, also vor `Colony` (Entscheidung zu Task 27, in T6 Task 1 in die Karte übernommen). Hinter `NotificationDelivery` stehen die T6-Tabellen `LabelTemplate`, `SiteSettings`, `VeterinaryMedicine`, `VeterinaryMedicineLot`, `ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement` (Schlüssel jeweils `id`, `Product` stand schon in der Karte). Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände (aufsteigend), dann das Volk; `row-lock.spec.ts` hält die Reihenfolge und die Einmaligkeit jeder Tabelle fest. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Umbauten der Aufrufer (`ColoniesService.create/update/move/merge`, `ApiariesService`, `ApiaryRestrictionsService`) folgen mit T6 Task 8; ein Umsetzen, das zwischen Lesen und Sperren den Stand wechselt, endet mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“ ohne automatischen Neuversuch (Abschnitt 12.1).
51. **Telegram: fehlerhaftes Update wird übersprungen (Annahme der Umsetzung)**: Ein Update, dessen Verarbeitung scheitert, wird bis zu 5 Mal oder höchstens 10 Minuten wiederholt (Zähler und erste Fehlzeit je `update_id` im Speicher des Pods; `MAX_UPDATE_ATTEMPTS`, `MAX_UPDATE_RETRY_MS`). Danach überspringt es eine eigene Transaktion: Offset weiter, `lastError` = „Telegram-Nachricht übersprungen“, eine Warnung ohne IDs und Text im Log, der Rest des Batches läuft weiter. Ohne diese Grenze würde ein dauerhaft scheiternder Befehl alle späteren Befehle (etwa jedes `/start`) sperren. Kosten bei falscher Annahme: ein verlorenes `/start`, der Nutzer erzeugt den Link neu. Eine verlorene Lease zählt nie als Fehlversuch, Verbindungsfehler ebenso wenig; die Zähler werden nach jedem Batch bereinigt. Abschnitt 11.13.
52. **Wassergehalt: Bestätigung gilt für den gemessenen Wert**: Die Bestätigung über der gesetzlichen Grenze (`acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']`) trägt zusätzlich `acknowledgedWaterContent` mit dem Wert aus der 409-Antwort. Sie gilt nur, wenn der unter der Sperre geprüfte Wert genau gleich ist; sonst folgt eine frische 409. Wer bestätigt hat, hat also diesen Wert gesehen, und eine zwischenzeitlich neue Messung entwertet die Bestätigung (Lebensmittelsicherheit; Kosten bei falscher Annahme: ein zusätzliches Feld). Streng größer als die Grenze löst die Bestätigung aus, genau 20,0 % warnt nur. Abschnitt 11.12.
53. **Vorbelegungs-Hinweise: Speichern bleibt erlaubt**: Laden die Hinweise einer Vorbelegung (Warnungen, etwa die Wartezeit) nicht, sperrt die Oberfläche das Speichern nicht; eine dauerhafte Zeile weist darauf hin, das Backend prüft beim Speichern. Kosten bei falscher Annahme: Ein Nutzer speichert, ohne die Wartezeitwarnung gesehen zu haben; die Antwort des Speicherns trägt die Warnung trotzdem. Abschnitt 11.12.
@@ -176,6 +177,18 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
56. **Step-up: ein 401 wird nie mit Refresh wiederholt**: Bei Aufrufen mit Nachweis (Telegram verbinden, Backup-Import, Passwort ändern, Konto deaktivieren, MFA bestätigen und abschalten) bedeutet ein 401 „Nachweis falsch“. Der Client erneuert dort das Token nicht und wiederholt nicht (`refreshOn401: false`), weil ein zweiter Versuch den Sperrzähler verdoppeln würde. Ist die Sitzung wirklich abgelaufen, erscheint statt Stille der deutsche Hinweis „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“ (der Client kennt den Ablauf des Tokens nicht und erneuert nicht vorab). Kosten bei falscher Annahme: Eine abgelaufene Sitzung zeigt bei einem Step-up einmal einen Fehler statt einer stillen Erneuerung. Abschnitt 11.13.
57. **Abfüllung: Produktwechsel setzt Artikelwerte zurück**: Wechselt das Produkt, verwirft die Maske von Hand geänderte Glasgröße und MHD und nimmt die Werte des neuen Artikels; ein Datumswechsel lässt eigene Eingaben stehen. Sonst bliebe die Glasgröße eines anderen Artikels unbemerkt im Formular. Kosten bei falscher Annahme: Wer erst tippt und dann das Produkt wählt, muss neu eintragen. Abschnitt 11.12.
58. **„Alle fehlgeschlagenen erneut versuchen“: die Zahl kann zu niedrig sein**: Die API liefert keine Gesamtzahl der `FAILED`-Zeilen, die Rückfrage nennt daher nur „Mindestens N“ (größerer Wert aus `failed24h` und den `FAILED`-Zeilen der Liste) und immer die Grenze „höchstens 100, neueste zuerst“. Kosten bei falscher Annahme: Es gehen mehr (bis 100) Nachrichten erneut hinaus als angezeigt. Abschnitt 10, System-Seite.
59. **T6-Schema: Prüfungen in der Datenbank, Rechtswerte als Einstellung**: CHECK-Constraints sichern `VeterinaryMedicineLot.quantity > 0`, `VeterinaryMedicineMovement.quantity <> 0` und `(type = 'TREATMENT') = ("treatmentId" IS NOT NULL)`, `ApiaryRestriction.endsOn >= startsOn` sowie `LabelTemplate.minXHeightMm` zwischen 0,5 und 5. Katalog, Packung, Behandlung und Stand hängen mit `ON DELETE RESTRICT` aneinander. `AuthorityNotice.openKey` ist nullbar-eindeutig (höchstens ein offener Entwurf je Art und Jahr). Die Vorgabe `ComplianceSettings.netQuantityDigitRules` (bis 50 g: 2 mm, bis 200 g: 3 mm, bis 1000 g: 4 mm, darüber 6 mm) und `minXHeightMm` 1,2 mm sind Annahme – vom Inhaber zu prüfen; im Code stehen keine Rechtswerte. Abschnitt 12.1.
60. **Tierarzneimittel: eigener Katalog und eigenes Bewegungsbuch**: Ergänzung zu Annahme 10. Mittel (`VeterinaryMedicine`), Packungen (`VeterinaryMedicineLot`) und Bewegungen (`VeterinaryMedicineMovement`) sind nicht `StockItem`/`BatchMovement`. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht (es gibt keine Route dafür). Der Rest einer Packung ist `quantity` plus Summe der Bewegungen, auf Tausendstel gerundet, immer berechnet und nie gespeichert; ein negativer Rest ist erlaubt (die Behandlung hat stattgefunden) und erscheint in der Prüfbereitschaft. Der Katalog startet leer, der Seed legt nichts an. Kosten bei falscher Annahme: Wer einen Buchungsfehler erwartet zu löschen, muss gegenbuchen. Abschnitt 12.2.
61. **Ein inaktives Mittel nimmt keinen neuen Bestand**: Eine Packung lässt sich weder zu einem inaktiven Mittel anlegen noch auf ein solches umhängen (400 „Das Mittel ist deaktiviert.“, `active` wird unter der Mittelsperre gelesen). Kosten bei falscher Annahme: Wer eine Lieferung eines stillgelegten Mittels buchen will, muss es erst wieder aktivieren.
62. **Packung und Mittel müssen dieselbe Einheit haben**: Beim Anlegen und beim Umhängen einer Packung auf ein anderes Mittel gilt 400 „Die Einheit des Mittels passt nicht zur Packung.“; die Einheit eines Mittels ist nur änderbar, solange keine Packung existiert, das Mittel einer Packung nur, solange keine Bewegung existiert (jeweils 409).
63. **Wiederherstellung ersetzt das Packungsbuch**: Eine versiegelte Datei überschreibt Mittel, Packungen und Bewegungen nach Id; sie führt das Buch nicht mit dem vorhandenen zusammen. Aus unversiegelten Dateien entsteht kein Packungsbezug einer Behandlung (Abschnitt 8). Kosten bei falscher Annahme: Buchungen, die nach der Sicherung entstanden sind, fehlen nach dem Einspielen und müssen nachgetragen werden.
64. **Beim Wiederherstellen gewinnt der offene Entwurf im Zielsystem**: Hält dort ein Entwurf (`openKey`, etwa der vom Tagesjob angelegte TSK-Entwurf) den Schlüssel unter anderer Id, entfällt die Anzeige der Datei mit ihren Dokumenten; der Restore scheitert nicht am Unique-Constraint. Kosten bei falscher Annahme: Der Entwurf aus der Datei fehlt und muss neu erfasst werden (die Ids stehen in `summary.skippedNoticeIds`).
65. **Etikett-Pflichtangaben prüft eine reine Funktion am aufgelösten Inhalt**: `checkLabel` (`src/label-templates/label-particulars.ts`) bewertet alle Text-Elemente mit eingesetzten Platzhaltern, normalisiert mit NFKC, Kleinschreibung und „alles außer Buchstaben und Ziffern wird zum Leerzeichen“. Ein Text, der nicht in sein Feld passt (gemessene Höhe größer als Feldhöhe plus 0,5 pt), zählt nicht und löst `TEXT_CLIPPED` aus. Nicht geprüft werden die „gleiches Sichtfeld“-Regel und die Nährwertdeklaration (für Honig befreit – Annahme, vom Inhaber zu prüfen). Das Datum auf dem Etikett und das erwartete MHD der Prüfung kommen beide aus `labelDayOf` (Berliner Kalendertag, unabhängig von der Prozess-Zeitzone). Eine Zahl direkt vor der Menge („1,250 g“) gilt nicht als Treffer für 250 g. Eine Anschrift wird auch erkannt, wenn sie auf mehrere Elemente verteilt ist, weil eine unerkannte Anschrift den Druck sperrt. Abschnitt 12.4.
66. **Füllmengen-Höhe: jede Nennung zählt**: `NET_QUANTITY_HEIGHT` ist erfüllt, wenn irgendeine Nennung der Füllmenge hoch genug ist (größte Versalhöhe aller Träger); eine zusätzliche kleine Nennung warnt nicht. Die Warnung sperrt nie. Kosten bei falscher Annahme: Eine große Nennung neben einer zu kleinen wird nicht bemängelt.
67. **Verkehrsbezeichnung: „honig“ in einem beliebigen Wort genügt**: Für `LEGAL_NAME` reicht ein Wort mit „honig“ („Blütenhonig“, auch „Honigtau“); ob die Bezeichnung inhaltlich stimmt, prüft die Software nicht. Kosten bei falscher Annahme: Ein falsch benannter Honig sperrt den Druck nicht.
68. **Losnummer mit „L“ am Anfang erfüllt die Präfix-Regel**: Beginnt die Losnummer schon mit „L“ („L-001“), stellt der Platzhalter `lotLine` kein weiteres „L“ voran und `LOT_PREFIX` warnt nicht. Die Warnung sperrt nie.
69. **`compliance:manage` schließt `compliance:read` nicht ein**: Die Rechte sind getrennt; die Rollenvergabe koppelt sie (nur ADMIN und BEEKEEPER haben `manage`, und diese haben auch `read`). Ein Nutzer mit nur `manage` darf bei Dokumenten hochladen, aber die Liste nicht lesen (403). Kosten bei falscher Annahme: Eine handgebaute Rolle mit nur `manage` sieht nichts, was sie angelegt hat.
70. **Dokumente gelöschter Datensätze bleiben bestehen**: Sie bleiben durch das Recht der Art geschützt und werden nicht mitgelöscht; die löschenden Pfade der späteren Tasks (Entwürfe, Sperrbezirke: Tasks 8 und 9) entfernen ihre Dokumente selbst. Kosten bei falscher Annahme: Bis dahin bleiben Dateien ohne Datensatz im Speicher.
## 7. Kubernetes
@@ -252,7 +265,7 @@ Scheitert die Migration, scheitert der initContainer und der neue Pod startet ni
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Seit T4b zusätzlich `PhenologyReport` (eigene Beobachtungen; Import nach `winterCheck`, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). `ApiaryPhenology` (abgeleiteter Saisonzustand, der Job `daily-evaluation` berechnet ihn neu) und `ApiaryWeatherDay` (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren `HoneyBatch`-Zeilen kein `initialAmount` tragen, werden beim Import wie in Migration `t4_honey_ledger` zurückgerechnet (Annahme 44, `src/backup/legacy-honey-ledger.ts`): `initialAmount` wird gesetzt; die `BOTTLING`-Buchungen mit der stabilen ID `legacy-bottling:<Abfüllung>` entstehen nur aus versiegelten Dateien, nach dem Abschnitt `batchMovement`, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur `initialAmount` bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (`honeyLedgerViolations`) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und `imported.honeyLedgerViolations = <Anzahl>` (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits). Die Telegram-Tabellen werden nie exportiert: `TelegramLink` (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), `TelegramLinkToken` (Einmal-Tokens, nur Minuten gültig), `NotificationDelivery` (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und `TelegramPollState` (Update-Offset und Abfrage-Lease dieses Deployments). Die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Seit T4b zusätzlich `PhenologyReport` (eigene Beobachtungen; Import nach `winterCheck`, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). `ApiaryPhenology` (abgeleiteter Saisonzustand, der Job `daily-evaluation` berechnet ihn neu) und `ApiaryWeatherDay` (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren `HoneyBatch`-Zeilen kein `initialAmount` tragen, werden beim Import wie in Migration `t4_honey_ledger` zurückgerechnet (Annahme 44, `src/backup/legacy-honey-ledger.ts`): `initialAmount` wird gesetzt; die `BOTTLING`-Buchungen mit der stabilen ID `legacy-bottling:<Abfüllung>` entstehen nur aus versiegelten Dateien, nach dem Abschnitt `batchMovement`, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur `initialAmount` bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (`honeyLedgerViolations`) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und `imported.honeyLedgerViolations = <Anzahl>` (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Seit T6 (Abschnitt 12) gehören `VeterinaryMedicine`, `VeterinaryMedicineLot`, `VeterinaryMedicineMovement`, `ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction` und `ComplianceAcknowledgement` zum versiegelten Bundle; keine dieser Tabellen steht in `MODELS_EXCLUDED_FROM_BACKUP` oder `LEGACY_UNSEALED_TABLES`, eine unversiegelte Altdatei überspringt sie und meldet sie unter `skipped`. Beim Einspielen stehen Mittel und Packung vor den Behandlungen, die Buchungen der Packung danach, Sperrbezirke folgen den Ständen. Behandlungen einer unversiegelten Datei verlieren `medicineId`, `medicineLotId` und `doseQuantity` (ohne Bewegungsbuch gäbe es eine Behandlung mit Packung, aber ohne Buchung, also eine Fälschung des Bestands); die Zahl steht als `imported.treatmentMedicineLinksDropped` in der Zusammenfassung, dazu eine Log-Warnung. Eine versiegelte Wiederherstellung ersetzt das Packungsbuch (Annahme 63). Hält im Zielsystem ein Entwurf den `openKey` einer Behördenanzeige unter anderer Id, gewinnt dieser Entwurf: Die Anzeige der Datei wird samt ihren Dokumenten (`AUTHORITY_NOTICE`) übersprungen und als `imported.authorityNoticeSkipped` bzw. `imported.authorityNoticeDocumentsSkipped` gezählt, die Ids stehen in `summary.skippedNoticeIds` (Annahme 64). Die Wiederherstellung schreibt keine Domänenereignisse, also auch kein `ColonyCreated` (Abschnitt 12.5). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits). Die Telegram-Tabellen werden nie exportiert: `TelegramLink` (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), `TelegramLinkToken` (Einmal-Tokens, nur Minuten gültig), `NotificationDelivery` (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und `TelegramPollState` (Update-Offset und Abfrage-Lease dieses Deployments). Die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `keLine truncated
- **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“).
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
@@ -321,7 +334,7 @@ Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivitä
**Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre.
**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen.
**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Neu sind seit T6 `ColonyCreated`, `ColonyMoved`, `ApiaryChanged` und `TskReferenceDayReached` (Abschnitt 12.5); `ColonyCreated` schreiben auch Teilen und CSV-Import. Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen.
**Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin.
- Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`.
@@ -333,7 +346,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen mit einer Ausnahme: `Apiary` kommt vor `Colony` (Annahme 50).
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen; `Apiary` steht seit T6 an erster Stelle, also vor `Colony` (Annahme 50).
**Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.
@@ -346,7 +359,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
- `SeasonPhaseChanged` (Aggregat `Apiary`, Payload `apiaryId`, `phase`, `segment`, `previousPhase`, `previousSegment`, `source`) schreibt `PhenologyStateService`, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über `dailyDedupeKey`. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Der Handler `autopilot.apiary-sync` hört darauf; eine Einzelmeldung je Saisonwechsel gibt es bewusst nicht (Annahme 47).
- `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (Aggregat `Apiary`, ohne Schlüssel) schreibt der Handler `phenology.recompute` je aktivem Stand, wenn eine Meldung für alle Stände gilt; `phenology.recompute-apiary` rechnet damit einen Stand je Zustellung.
- `WinterCheckRecorded { colonyId }` (Aggregat `WinterCheck`) schreiben `WinterChecksService.create` und `update` in ihrer Transaktion (ein Verschieben auf ein anderes Volk meldet auch das alte); `autopilot.colony-sync` löst damit A9/A10 sofort auf.
`Apiary`, `PhenologyReport`, `Extraction` und `HoneyBatch` stehen am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `NotificationDelivery` steht ganz zuletzt (Abschnitt 11.13).
`PhenologyReport`, `Extraction` und `HoneyBatch` stehen hinter `NotificationPreference` am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `Apiary` steht seit T6 an erster Stelle (Annahme 50). `NotificationDelivery` folgt danach, ihr schließen sich die T6-Tabellen an (Abschnitte 11.13 und 12.1).
**Handler-Übersicht (T4).** Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über `tx` schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (`lockRowForUpdate`; fehlt es, ist nichts zu tun), Status über `tx` neu lesen (beendet: nichts), Regel über `readRule(tx, …)` lesen (abgeschaltet: nichts vorschlagen).
@@ -444,7 +457,7 @@ Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:m
## 11. Logik-Ketten und Saison-Autopilot (T4a bis T4c)
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13). Noch offen sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`) und das Verknüpfen und Abfragen von Telegram (Task 36).
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13); T4 ist vollständig (Stand 2026-10-15).
### 11.1 Vorschläge
@@ -452,7 +465,7 @@ Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensc
**PLAN und DO.** `PLAN` hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus `PLAN_STEP_TITLES`, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. `DO` ist genau eine Aufgabe und braucht ein Datum (eigenes oder `dueDate` des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus `renderSuggestion` mit dem `actionTemplate` der Regel. Die Aufgabe entsteht über `TasksService.createInTx` (Audit, `TaskCreated`, danach Kalender und Webhook). `sourceInspectionId` wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter `FOR SHARE` geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein `assignedUserId`, der nicht existiert oder inaktiv ist: 400 mit `issues`.
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference; Ausnahme: `Apiary` vor `Colony`, Annahme 50).
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference; davor seit T6 `Apiary`, Annahme 50).
| von | nach | Auslöser |
|---|---|---|
@@ -566,7 +579,7 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
### 11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`, `SEASON` und seit T6 `COMPLIANCE` („Behörden & Prüfung“, für die Hinweise „Bestand geändert“ und „TSK-Stichtag“, Abschnitt 12.1); die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
**Erinnerung je Aufgabe.** Handler `notifications.task-due` (`RemindersModule`, von `AppModule`, `WorkerModule` und `JobsModule` importiert) schreibt zu `TaskDue` je Empfänger eine Meldung (HINWEIS, `TASK_DUE`, Link `/tasks`, Schlüssel `task-due:<Aufgabe>:<dueOn>:<userId>`): „Heute fällig: <Titel>“, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: <Titel>“. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit `tasks:complete`; immer nur mit `tasks:read` und eingeschaltetem `taskDueEnabled` (fehlende Einstellung = an). Nur offene Aufgaben (`OPEN`, `IN_PROGRESS`); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen.
@@ -668,7 +681,7 @@ Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfange
- `TELEGRAM_BOT_USERNAME` verliert ein führendes `@` und muss `^[A-Za-z][A-Za-z0-9_]{4,31}$` erfüllen, sonst gilt er als nicht gesetzt (`telegramBotAvailable()` falsch) mit Warnung.
- Der Wächter `no-network-outside-loops.spec.ts` hält fest, dass das Netzwerk nur in den erlaubten Dateien und nur aus Schleifen genutzt wird (Abschnitt 9).
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE` und `DIGEST`; `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE`, `DIGEST` und seit T6 `COMPLIANCE` (Migration `20261016100400_t6_notification_compliance_default`, Abschnitt 12.1); `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. `COMPLIANCE` hat wie `SYSTEM` keine Tageszeit (nur die Ruhezeit) und ist 24 Stunden frisch. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
**Texte** (`src/telegram/telegram-texts.ts`). `TELEGRAM_TEMPLATES` hält die festen Texte, `renderTelegramText` baut daraus den Klartext: ohne Details nur „Neue Meldung in ApiCore.“ und Link, Standardlink `/dashboard`, ein Schrägstrich am Ende von `FRONTEND_ORIGIN` wird toleriert. Höchstens 600 Zeichen nach Codepunkten; die Meldung wird mit Auslassungspunkten gekürzt, der Link bleibt ganz, Steuer- und unsichtbare Zeichen entfallen. Ohne `FRONTEND_ORIGIN` entfällt der Link (Annahme 46).
@@ -689,8 +702,72 @@ Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfange
**Betrieb.** Die Karte „Telegram“ unter Verwaltung > System (Abschnitt 10) zeigt Empfänger (Lease, Alter des letzten Abrufs, `lastError`) und Sender (wartend, überfällig, fehlgeschlagen in 24 h, letzte Fehler) und erlaubt das Wiederholen fehlgeschlagener Zustellungen. Die Einstellungsansicht (`GET /profile/notification-settings`) nennt das Feld `telegram.botNameSet` (Bot-Name gesetzt; das Token kennt die api nicht), derselbe Begriff wie am System-Endpunkt. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7.
**Oberfläche: Profil > Telegram** (`TelegramLinkCard`). Der Zustand steht in einer Statuszeile (`role="status"`): „Telegram ist nicht eingerichtet.“ (ohne `botNameSet`, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den `StepUpDialog`; danach erscheint der große Knopf „In Telegram öffnen“ (`target="_blank"`, `rel="noopener noreferrer"`) mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (`ConfirmDialog`) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit `https://t.me/` beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in `localStorage`/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (`ToggleChip`, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per `PATCH`. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).
**Oberfläche: Profil > Telegram** (`TelegramLinkCard`). Der Zustand steht in einer Statuszeile (`role="status"`): „Telegram ist nicht eingerichtet.“ (ohne `botNameSet`, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den `StepUpDialog`; danach erscheint der große Knopf „In Telegram öffnen“ (`target="_blank"`, `rel="noopener noreferrer"`) mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (`ConfirmDialog`) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit `https://t.me/` beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in `localStorage`/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (Stand T4c; `COMPLIANCE` liefert das Backend schon, die Karte zeigt sie mit T6 Task 18; `ToggleChip`, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per `PATCH`. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).
**Step-up** (`src/components/auth/step-up-dialog.tsx`, geteilt mit dem Backup-Import; `pendingLabel`, beim Import „Wird eingespielt…“). Der Dialog fragt das aktuelle Passwort und bei MFA den 6-stelligen Code; `onConfirm(proof)` darf werfen, dann bleibt er offen, leert die Felder, fokussiert das Passwortfeld und zeigt den deutschen Grund in einer dauerhaften Live-Region. Während der Ausführung sind die Eingaben `readOnly`, Esc, Hintergrund und Schließen-Knopf wirkungslos. Die Felder leben nur, solange der Dialog offen ist. Ein fehlgeschlagener Import erscheint im Dialog statt als Toast. Alle Step-up-Aufrufe senden mit `refreshOn401: false` (Annahme 56): `createTelegramLinkToken`, `importBackup`, `changePassword`, `deactivateAccount`, `confirmMfaEnrollment` und `disableMfa`. `stepUpErrorText` (`src/lib/auth/step-up-error.ts`, auch im Passwortformular des Profils) liefert: 429 „Zu viele Versuche. Bitte in einer Minute noch einmal.“, ein 401 ohne eigene Meldung „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“, deutsche Server-Meldungen unverändert. Der Client kennt den Ablauf des Tokens nicht und erneuert vor Step-up-Aufrufen nicht vorab.
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
## 12. Compliance (T6)
Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von `compliance:read` bzw. `veterinary-medicines:read`; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie `COMPLIANCE`, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk.
**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 4 (Dokumente), 5 (Etikett-Pflichtangaben) und 7 (Ereignisse). Task 3 (Behandlung aus Packung), 6 (Druck-Sperre und Probedruck), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 bis 19 (Behördenpaket, Prüfbereitschaft, Handler und Meldungen, Tagesregeln, Oberfläche) folgen und ergänzen diesen Abschnitt.
### 12.1 Schema und Rechte
**Migrationen** (alle additiv, nach `20261015110000_t4_telegram`): `20261016100000_t6_veterinary_medicines` (Enums `MedicineUnit`, `VeterinaryMedicineMovementType`; drei Tabellen; `Treatment.medicineId`, `medicineLotId`, `doseQuantity` mit Fremdschlüsseln `ON DELETE RESTRICT`, Indizes `Treatment_medicineId_idx` und `medicineLotId`; `DocumentEntityType` + `MEDICINE_LOT`; Rechte), `…100100_t6_label_particulars` (`SiteSettings.foodBusinessName`/`foodBusinessAddress`, `Product.legalName`, `LabelTemplate.minXHeightMm` Vorgabe 1,2, `AuditAction` + `PRINT`), `…100200_t6_authority_compliance` (`ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement`; `DocumentEntityType` + `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`; Rechte), `…100300_t6_notification_category_compliance` (nur `ALTER TYPE … ADD VALUE 'COMPLIANCE'`; der Wert wird in derselben Migration nicht benutzt) und `…100400_t6_notification_compliance_default`. Prüfungen und Annahmen zum Schema: Annahme 59. `netQuantityDigitRules` ist ein Prisma-`@default`, damit die SQL-Vorgabe keinen Drift gegen das Schema erzeugt; die Singleton-Zeile `ComplianceSettings` legt der Dienst (Task 9) per `upsert` an. Ein Behandlungs-Löschen mit Buchung scheitert an den RESTRICT-Schlüsseln mit `P2003`; Task 3 bildet das auf 409 „Diese Behandlung hat eine Buchung aus einer Packung und kann nicht gelöscht werden.“ ab.
**Rechte** (Katalog `permission-catalog.ts`; die Migrationen vergeben, der Katalog-Sync berechtigt nur System-Rollen):
| Recht | Gruppe (Sortierung) | ADMIN | BEEKEEPER | STAFF | READONLY | API_CLIENT |
|---|---|---|---|---|---|---|
| `veterinary-medicines:read` | Tierarzneimittel (3700) | ✓ | ✓ | ✓ | ✓ | – |
| `veterinary-medicines:create`, `:update` | Tierarzneimittel | ✓ | ✓ | ✓ | – | – |
| `compliance:read` | Prüfbereitschaft & Behörden (3800) | ✓ | ✓ | ✓ | ✓ | – |
| `compliance:manage` | Prüfbereitschaft & Behörden (3801) | ✓ | ✓ | – | – | – |
Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein eigenes Recht. Sperrbezirke lesen mit `apiaries:read` und schreiben mit `compliance:manage`. `AuditAction` kennt `PRINT` (Etikettendruck, Task 6).
**Sperr-Reihenfolge.** `Apiary` steht an erster Stelle von `LOCKABLE_TABLES`, danach `Colony` und die übrigen Tabellen wie bisher; die acht neuen Tabellen stehen hinter `NotificationDelivery` (Annahme 50). Die Völker-Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände mit `lockRowsForShare(tx, 'Apiary', ids)` (aufsteigend), dann das Volk; `ApiaryRestrictionsService` sperrt die betroffenen Stände mit `lockRowsForUpdate`, damit sich eine Sperre und ein laufendes Umsetzen hintereinander einreihen. Wechselt der Stand eines Volkes zwischen Lesen und Sperren, endet die Anfrage mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, ohne automatischen Neuversuch. Die Umbauten der Aufrufer sind Task 8. Services, die T6 anfasst und `before` bisher außerhalb lasen (`SiteSettingsService.update`, `ProductsService.update`, `LabelTemplatesService.update`), lesen es seit Task 5 nach der Zeilensperre.
**Backup.** Die sieben neuen Tabellen gehören zum versiegelten Bundle; Reihenfolge, Verlust des Packungsbezugs in unversiegelten Dateien und die Regel für Behördenanzeigen stehen in Abschnitt 8 (Annahmen 63 und 64).
**Meldungs-Kategorie `COMPLIANCE`** („Behörden & Prüfung“). Sie trägt die Hinweise „Bestand geändert“ und „TSK-Stichtag“ (Task 11) und ist anders als `SYSTEM` für Telegram vorgewählt: Die Vorgabe `telegramCategories` ist `[TASK_DUE, DIGEST, COMPLIANCE]`, und die Daten-Migration hängt `COMPLIANCE` an jede bestehende, nichtleere Liste an. Eine leere Liste (der Nutzer hat bewusst alles abgewählt, Opt-out) bleibt leer; Kosten bei falscher Annahme: ein Nutzer mit Opt-out bekäme sonst Behördenhinweise. `IsEnum(NotificationCategory)` und die Längengrenze übernehmen den Wert aus dem Enum, es gibt keine zweite Kategorienliste im Code. Das Frontend (Task 18) ergänzt Label und Auswahl; bis dahin zeigt es den Wert als unbekannte Kategorie.
**Null-Verträge.** `describeNullContracts` (`src/common/validation/null-contract.spec-helper.ts`, per `tsconfig.build.json` aus dem Build ausgeschlossen) registriert je Update-DTO die drei Prüfungen der Null-Regel (Annahme 20); jede T6-Task legt ihre Verträge in eine eigene Spec-Datei.
### 12.2 Tierarzneimittel: Katalog, Packungen, Bewegungsbuch
`VeterinaryMedicinesModule` (`src/veterinary-medicines/`, in `AppModule`, nicht im `WorkerModule`: es hat keine Handler) trennt drei Ebenen: das Mittel (`VeterinaryMedicine`, Handelsname, Wirkstoff, Einheit, Wartezeit, Lagerhinweise), die Packung (`VeterinaryMedicineLot`, Beleg, Verfall) und die Bewegung (`VeterinaryMedicineMovement`: `TREATMENT` nur aus einer Behandlung, `CORRECTION` für die Inventur, `DISPOSAL` mit Pflichtgrund). Grundsätze: Annahmen 60 bis 62. Der Rest ist `roundToThousandths(lot.quantity + Σ movement.quantity)` (`src/common/quantity.ts`, die einzige Rundung auf Tausendstel; `roundKg` in `honey-plausibility.ts` ist ein Alias, `bottling.service.ts` ruft sie direkt auf, `-0` wird zu `0`). Die reinen Helfer stehen in `medicine-ledger.ts` (`summarizeLot`, `treatmentBookingDelta` für die Gegenbuchung bei geänderter Packung oder Dosis, Task 3), die Sichten in `medicine-views.ts` (eine `groupBy`-Abfrage je Liste, kein N+1).
**API** (Rechte `veterinary-medicines:read|create|update`, Datumsfelder der Antworten als Tagestexte `JJJJ-MM-TT`):
- `GET /veterinary-medicines?active=`, `GET /veterinary-medicines/suggestions` (eigene, noch nicht katalogisierte Handelsnamen aus `Treatment.product` ohne Beachtung der Groß-/Kleinschreibung, höchstens 50, mit den Werten der jüngsten Behandlung; legt nichts an), `GET/PATCH /veterinary-medicines/:id`, `POST /veterinary-medicines`, `POST …/:id/deactivate|reactivate` (ohne Änderung keine Schreibzeile und kein Audit).
- `GET /veterinary-medicine-lots?medicineId=&selectable=true&includeLotId=` (wählbar: Rest > 0 und Mittel aktiv, älteste Haltbarkeit zuerst; `includeLotId` hält die Packung einer bearbeiteten Behandlung zusätzlich wählbar), `GET/PATCH /veterinary-medicine-lots/:id` (mit Bewegungsbuch), `POST /veterinary-medicine-lots`, `POST …/:id/movements` mit `{ type: 'CORRECTION', actualRemaining }` oder `{ type: 'DISPOSAL', quantity, reason }`.
- Die Anlege-Endpunkte und `:id/movements` tragen `@Idempotent()`. Die Listen sind noch nicht paginiert; mit wachsendem Buch ist ein Limit zu ergänzen.
**Schreiben.** Mittel und Packungen lesen `before` nach der Zeilensperre und schreiben das Audit in derselben Transaktion; die Sperrfolge ist Mittel, dann Packung. Eine Inventur bucht die Differenz zwischen Buchrest und gezähltem Rest unter der Packungssperre (Delta 0 bucht nichts und schreibt kein Audit), zwei gleichzeitige Inventuren laufen nacheinander. Eine Entsorgung, die auf 0 rundet (unter 0,0005), ist 400. `name` und `reason` dürfen nicht nur aus Leerzeichen bestehen. `minTemperatureC <= maxTemperatureC` prüft der Dienst gegen den zusammengeführten Stand (400). Task 3 hält Dosen auf dem Tausendstelraster (eine Dosis unter 0,001 ist 400) und bucht die Packung nach dem Volk. Der Seed legt keine Mittel an; `seed-no-medicines.spec.ts` sichert das (nur die fünf bekannten Seed-Module, keine weitere `.ts` unter `prisma/`).
### 12.3 Dokumente mit Rechten je Art
Für `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` gilt zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes. Die Zuordnung steht in `DOCUMENT_ENTITY_PERMISSIONS` (`documents.service.ts`): Packung lesen `veterinary-medicines:read`, schreiben `veterinary-medicines:update`; Behördenanzeige und Sperrbezirk lesen `compliance:read`, schreiben `compliance:manage`. „Schreiben“ umfasst Hochladen, Umhängen, Umbenennen und Löschen; beim Umhängen zählen die alte und die neue Art. `DocumentsService.assertEntityAccess(userId, entityType, mode)` wirft 403 ohne das Recht; Arten ohne Eintrag (Stand, Volk, Königin, Durchsicht, Honigcharge) prüfen nichts zusätzlich. Die Prüfung kommt vor jeder Datenbankabfrage (`findAll`, `create`) bzw. nach dem Laden des Dokuments und vor dem Lesen der Datei (`readFile`, `update`, `delete`), daher 403 vor 404 und kein Existenz-Orakel für Unberechtigte. Wer nur `documents:read` hat, sieht Belege, Anzeigen und Sperrbescheide weder in der Liste noch per Download-ID. `compliance:manage` schließt `compliance:read` nicht ein (Annahme 69). Speicherordner sind `medicine_lot`, `authority_notice` und `apiary_restriction`; MIME-Allowlist und Signaturprüfung gelten unverändert. Ein Fehler beim Löschen der Datei wird mit Dokument-Id und `loggableError()` protokolliert, Zeile, Audit und Antwort bleiben. Dokumente gelöschter Datensätze bleiben bestehen (Annahme 70). Das Recht wirkt über den 30-s-Cache des Resolvers verzögert, wie überall.
**Zwischenspeicher.** Alle API-Antworten tragen `Cache-Control: no-store` (Abschnitt 9, `noStoreMiddleware`); die e2e-Tests halten das für Liste und Download fest. Der Service Worker beachtet `no-store` bisher nicht (`isKeepableApiAnswer` in `public/sw.js` behält JSON-Antworten per `cache.put`). Task 14 muss deshalb entweder `no-store` dort berücksichtigen oder Pfadregeln für Listen mit den drei Arten ergänzen; das Präfix `/api/v1/documents` wird nicht ganz gesperrt, sonst fehlten die Durchsichtsfotos offline. Nie gecacht werden die Compliance-, Arzneimittel- und Sperrbezirk-Pfade (`NEVER_CACHED_API_PATHS`).
### 12.4 Etikett-Pflichtangaben
`checkLabel` (`src/label-templates/label-particulars.ts`) ist rein und ohne Datenbank; Regeln und Grenzen: Annahmen 65 bis 68. **Pflichtangaben** (sperren den Druck, Code `LABEL_PARTICULARS_MISSING` mit `{ gaps }` folgt mit Task 6): `LEGAL_NAME` (ein Wort mit „honig“), `NET_QUANTITY` (Füllmenge der Abfüllung, „250,5 g“ wie „250.5 g“, „1 kg“ nur bei vollen Kilogramm; als ganze Wörter), `BEST_BEFORE` („Mindestens haltbar bis“ mit dem Datum), `LOT`, `ORIGIN` und `FOOD_BUSINESS` (Name und Anschrift). **Warnungen** (sperren nie): `LOT_PREFIX`, `X_HEIGHT` (unter `LabelTemplate.minXHeightMm`), `NET_QUANTITY_HEIGHT` (Ziffernhöhe unter der Regel aus `ComplianceSettings.netQuantityDigitRules`), `TEXT_CLIPPED`. Das Ergebnis trägt `requirements` (`minXHeightMm`, `netQuantityMinCapHeightMm`) und je Befund `elementIds` (bei Lücken die abgeschnittenen Elemente, die die Angabe getragen hätten). Ist eine Anschrift über mehrere Elemente verteilt, erkennt sie die Gesamtprüfung, ordnet sie aber keinem Element für `X_HEIGHT`/`TEXT_CLIPPED` zu.
**Höhen.** mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica: x-Höhe 523, Versalhöhe 718). Der Zugriff auf das private pdfkit-Feld steckt allein in `font-metrics.ts` (`fontMetricsOf`, je Schnitt gecacht, Fehler bei fehlenden Werten, durch einen Spec-Test gesichert); `pdfFontName` und `MM_TO_PT` wohnen dort. Beispiel: 6 pt ergibt 1,11 mm (Warnung), 7 pt 1,29 mm (in Ordnung). Die Rechtswerte sind Eingaben, die Beispielregeln stehen nur in den Tests.
**Stammdaten und Platzhalter.** `SiteSettings.foodBusinessName` (200) und `foodBusinessAddress` (300), `Product.legalName` (200) und `LabelTemplate.minXHeightMm` (0,5 bis 5) bleiben leer bzw. bei der Vorgabe, bis der Inhaber sie pflegt; leer ist eine Lücke, kein Standardwert. Neue Platzhalter: `legalName`, `originCountry`, `foodBusinessName`, `foodBusinessAddress`, `bestBeforeLine` („Mindestens haltbar bis TT.MM.JJJJ“), `lotLine` („L <Losnummer>“); die Liste liegt als `test/fixtures/placeholder-keys.json` vor (Wächter `placeholder-keys.spec.ts`, das Frontend vergleicht seine Liste damit). `buildPlaceholderValues(bottlingBatch, settings)` setzt unbesetzte Stammdaten auf `''`. Die öffentliche Herkunftsseite und der Shop-Feed zeigen weder Anschrift noch Verkehrsbezeichnung (e2e belegt, auch mit gesetztem `legalName`). `holderName`/`holderAddress` laufen über denselben `trimmedOrNull`-Helfer (`null` löscht). Task 6 ruft `labelDayOf(bottling.bestBeforeDate)` für `expected.bestBefore` und nutzt einen gemeinsamen Messer je Druck (`pdfTextMeasurer()`).
### 12.5 Ereignisse für Völker und Stände
Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Compliance` neu (`'Apiary'` gab es schon). Alle tragen nur Ids, Codes, Jahr und Tag, nie Adresse, Koordinaten oder Namen, und sind nicht in `DOMAIN_EVENT_WEBHOOKS`; der Testhelfer `herd-payload.spec-helper.ts` prüft die wirklich geschriebenen Payloads gegen eine Allow-Liste.
- `ColonyCreated { apiaryId }` (Aggregat `Colony`): `ColoniesService.create` und für den Ableger in `split` (zusätzlich zu `ColonySplit`), jeweils in der Transaktion des Schreibens. Auch der CSV-Import schreibt es je angelegtem Volk in derselben Transaktion mit Datensatz und Audit (`Colony` CREATE), damit die Bestandsänderung die importierten Völker sieht (gemeinsamer Erzeuger `colonyCreatedEvent`, `src/colonies/colony-created-event.ts`). Der Import sendet weiterhin keinen Webhook `colony.created`; der Webhook bleibt im Altpfad (Annahme 11). Fehlerzeilen, Überspringen und Zähler des Imports sind unverändert. Eine Wiederherstellung aus dem Backup schreibt bewusst kein `ColonyCreated`.
- `ColonyMoved { fromApiaryId, toApiaryId }` (Aggregat `Colony`): `move` und `update` mit geändertem Stand. Ein Umsetzen in denselben Stand schreibt Historie und Audit, aber kein Ereignis.
- `ApiaryChanged { change }` (Aggregat `Apiary`), `change` = `CREATED` | `DEACTIVATED` | `REACTIVATED` | `LOCATION_CHANGED`. `LOCATION_CHANGED` nur, wenn Adresse, Ort, Breite oder Länge sich gegenüber dem gesperrten `before` ändern. Je Schreibvorgang ein Ereignis, ohne `dedupeKey`.
- `TskReferenceDayReached { year, referenceDay }` (Aggregat `Compliance`, `referenceDay` als `JJJJ-MM-TT`, tagesbezogen über `dailyDedupeKey`): nur im Katalog; der Erzeuger ist die Tagesauswertung (Task 11).
`ApiariesService` schreibt Datensatz, Audit und Ereignis seit Task 7 in **einer** Transaktion; `update`, `deactivate` und `reactivate` sperren zuerst die Standzeile (`lockRowForUpdate`) und lesen `before` danach, `deactivate` zählt lebende Völker erst danach über `tx`; `reactivate` behält die Audit-Aktion `UPDATE`. Die Handler (`compliance.herd-change`, `compliance.tsk-reference-day`) liegen im künftigen `ComplianceModule` (von `AppModule` und `WorkerModule` importiert, ohne `PermissionsModule`, Task 11); die Erzeuger laufen nur in der API. Frontend: `DOMAIN_EVENT_LABELS` kennt die vier Typen („Volk angelegt“, „Volk umgesetzt“, „Stand geändert“, „TSK-Stichtag erreicht“); Task 14 trägt sie nicht noch einmal ein.