Files
ApiCore/docs/superpowers/plans/2026-10-14-t6-compliance.md
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

1403 lines
128 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# T6 Compliance-Autopilot „prüfungsfest“ Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bei einer Kontrolle durch Veterinäramt oder Lebensmittelüberwachung legt Imkerei Wabenhain alles Verlangte sofort vor: ein Bestandsbuch ohne „FEHLT“ (Katalog und Packungen füllen die Behandlung vor und buchen ab), Etiketten nur mit Pflichtangaben, ein Behördenpaket (Änderungshinweis, Anzeige als PDF, TSK-Zählung, Sperrbezirk) und eine Seite „Prüfbereitschaft“ mit jedem offenen Punkt in Rot und einem Knopf zum Beheben.
**Architecture:** Neue Module `veterinary-medicines` (Katalog, Packungen, Bewegungsbuch), `apiary-restrictions` (Sperrbezirk) und `compliance` (Einstellungen, Anzeigen, Handler, Prüfbereitschaft mit einer reinen Regel-Datei je Prüfpunkt). Folgen laufen über Ereignisse des Rückgrats (`ColonyCreated`, `ColonyMoved`, `ApiaryChanged`, `TskReferenceDayReached`) und Handler, die nur über `tx` schreiben. Die Prüfbereitschaft wird live aus den Fachdaten berechnet; gespeichert werden nur Quittungen. Die Etikett-Prüfung und das Bestandsbuch nutzen je eine gemeinsame reine Funktion für Druck, Designer und Prüfbereitschaft. Rechtswerte sind Einstellungen mit Vermerk „Annahme – vom Inhaber zu prüfen“, nie Code.
**Tech Stack:** NestJS 11, Prisma 7.9 (`@prisma/adapter-pg`), PostgreSQL 18, pdfkit 0.19, Jest 30 (Unit + e2e gegen Test-DB), Next.js 16.3.1 App Router, React 19.2, TanStack Query 5, Vitest 4 + RTL. Keine neue Abhängigkeit.
**Spec:** `docs/superpowers/specs/2026-10-13-t6-compliance-design.md` (bindend, mit Inhaber-Entscheidungen vom 2026-10-13). Bausteine aus `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md` (Rückgrat), `docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md` (T3) und `docs/superpowers/plans/2026-10-13-t4-logik-ketten.md` (T4, läuft vor T6).
---
## Global Constraints
**Voraussetzungen (nicht neu bauen, genau so nutzen)**
- Rückgrat Tasks 1–26 außer 17 und 25 sind im Arbeitsbaum. Genutzt werden: `DomainEventsService.record/recordAll(tx, …)`, `@OnDomainEvent(types, '<consumer>.<name>')` (Handler-ID `^[a-z0-9-]+\.[a-z0-9-]+$`), `dailyDedupeKey`, `utcDay`, `inCatchUpWindow`, `DAILY_EVALUATION_RULES` (`src/jobs/daily-evaluation.rules.ts`), `AuditLogService.record(entry, tx)`, `lockRowForUpdate`/`lockRowsForShare` (`src/prisma/row-lock.ts`), `runBulk` (`src/common/bulk/run-bulk.ts`, Callback `(tx, entry, colony, bulkId)`), `lockColony`/`colonyTransitionEvents` (`src/colonies/colony-events.ts`), `LIVING_COLONY_WHERE`/`isLivingColonyStatus`, `loggableError`, `PermissionResolverService.getPermissions(userId)`, `drainDomainEvents` (`test/utils/domain-events.ts`), `@Idempotent()`, `IsOptionalNotNull`, `optionalDate`, `fifoOrder` (`src/stock/stock-ledger.ts`), `PDF_MARGIN`/`drawTable`/`fillWidth`/`formatDay`/`orDash` (`src/export/pdf-table.ts`).
- T3 ist gemergt: `TreatmentsService.createBulk` mit der gemeinsamen privaten Aufbereitung `treatmentRow` + `recordIn` (T6 erweitert genau diese Funktionen), `GET /treatments/last-by-product`, Frontend `PrefillNote`, `NumberStepper`, `RecordSelect`, `useCaptureMutation`, `applyPrefill`.
- T4 ist gemergt: Meldungen je Nutzer (`NotificationsService.record/recordForUsers/recordForPermission(permission | permissions[], FanOutNotificationInput, tx)`, `NotificationCategory`, `assertAppLink`), `LOCKABLE_TABLES` als Einträge `{ table, key }`, `src/treatments/withdrawal-window.ts` (`withdrawalWindowOf`, `isInWindow`, `WithdrawalWindow`), `TimeEventsStep` über `DAILY_EVALUATION_RULES`, `berlinToday()` (`src/common/dates.ts`), `ColonySplit`.
- Alle Backend-Pfade relativ zu `ApiCore-Backend/`, alle Frontend-Pfade relativ zu `ApiCore-Frontend/`, sofern nicht anders angegeben.
**Migrationen (Zeitstempel fest, alle nach der höchsten T4-Migration `20261015110000_t4_telegram`; alles additiv)**
| Zeitstempel | Name | Inhalt |
|---|---|---|
| 20261016100000 | `t6_veterinary_medicines` | Enums `MedicineUnit`, `VeterinaryMedicineMovementType`; `VeterinaryMedicine`, `VeterinaryMedicineLot`, `VeterinaryMedicineMovement` mit CHECKs `quantity > 0` (Packung), `quantity <> 0` (Bewegung), `(type = 'TREATMENT') = ("treatmentId" IS NOT NULL)`; `Treatment.medicineId`, `medicineLotId`, `doseQuantity` mit FKs (`ON DELETE RESTRICT`) und `@@index([medicineLotId])`; `DocumentEntityType` + `MEDICINE_LOT`; Rechte `veterinary-medicines:*` samt Vergabe |
| 20261016100100 | `t6_label_particulars` | `SiteSettings.foodBusinessName`, `foodBusinessAddress`; `Product.legalName`; `LabelTemplate.minXHeightMm Float @default(1.2)` + CHECK 0,5–5; `AuditAction` + `PRINT` |
| 20261016100200 | `t6_authority_compliance` | `ComplianceSettings` (Vorgabe `netQuantityDigitRules` = `[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]`); `AuthorityNotice` mit Enums; `ApiaryRestriction` + CHECK `"endsOn" IS NULL OR "endsOn" >= "startsOn"`; `ComplianceAcknowledgement`; `DocumentEntityType` + `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`; Rechte `compliance:*` samt Vergabe |
- Erzeugt mit `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script` gegen die Test-DB, von Hand nachgearbeitet (Kopfkommentar wie `20261012100000_calendar_sync_claims`). Nie `db push`, nie `migrate dev`. `ALTER TYPE … ADD VALUE` läuft in der Migrationstransaktion; kein neuer Wert wird in derselben Migration benutzt.
- Test-DB migrieren (Git Bash, in `ApiCore-Backend/`): `export DATABASE_URL="$(grep '^DATABASE_URL=' .env.test | cut -d= -f2- | tr -d '"')" && npx prisma migrate deploy && npx prisma generate`. Läuft Task 1 in einer Welle mit anderen Tasks, wendet er seine Migrationen zuerst an.
**Rechte (Spec 4.8; Migration vergibt, der Katalog-Sync berechtigt nur System-Rollen)**
| Recht | `groupDe` | ADMIN | BEEKEEPER | STAFF | READONLY | API_CLIENT |
|---|---|---|---|---|---|---|
| `veterinary-medicines:read` | Tierarzneimittel | ✓ | ✓ | ✓ | ✓ | – |
| `veterinary-medicines:create` / `:update` | Tierarzneimittel | ✓ | ✓ | ✓ | – | – |
| `compliance:read` | Prüfbereitschaft & Behörden | ✓ | ✓ | ✓ | ✓ | – |
| `compliance:manage` | Prüfbereitschaft & Behörden | ✓ | ✓ | – | – | – |
- Jeder neue Endpunkt trägt `@RequirePermission` (Tabelle in Spec 7); `test/authorization-coverage.e2e-spec.ts` erfasst alle Controller. Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein eigenes Recht. Sperren lesen mit `apiaries:read`, schreiben mit `compliance:manage`.
- Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen nur mit `compliance:read` bzw. `veterinary-medicines:read`; nie in öffentlichen Endpunkten, Shop-Feed, Webhooks oder Ereignis-Payloads.
**Fachwerte aus der Spec**
- Rest einer Packung = `lot.quantity + Σ movement.quantity`, gerundet auf Tausendstel (`roundToThousandths`, `src/common/quantity.ts`; `roundKg` in `bottling.service.ts` nutzt ihn). Immer berechnet, nie gespeichert. Negativer Rest ist erlaubt und wird in der Prüfbereitschaft gemeldet. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht.
- Behandlung (Spec 5.2): Packung plus `supplier`/`purchaseProof`/`batchNumber`/`prescribingVet` → 400 „Diese Angabe kommt aus der Packung“; `medicineId` und `product` weichen ab (ohne Groß-/Kleinschreibung) → 400 „Mittel und Katalogeintrag passen nicht zusammen“; Katalogeintrag inaktiv → Anlegen 400; Packung eines anderen Mittels → 400; `doseQuantity` ≤ 0 → 400; `medicineId: null` bei gesetzter Packung → 400. Der Server füllt `activeIngredient`, `withdrawalPeriodDays`, `method`, `doseQuantity` nie still nach; nur `product` wird zum Handelsnamen.
- Etikett (Spec 5.4): Pflicht `LEGAL_NAME`, `NET_QUANTITY`, `BEST_BEFORE`, `LOT`, `ORIGIN`, `FOOD_BUSINESS` sperren den Druck (409 `LABEL_PARTICULARS_MISSING`, `{ gaps }`); Warnungen `LOT_PREFIX`, `X_HEIGHT`, `NET_QUANTITY_HEIGHT`, `TEXT_CLIPPED` sperren nie. Probedruck `?proof=true` immer möglich, Aufdruck „MUSTER – nicht verkaufsfähig“ in 50 % Grau. Jeder Druck schreibt Audit `PRINT` am `BottlingBatch`: `{ templateId, copies, proof, gapCodes, warningCodes }`. Höhe in mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica x-Höhe 523, Versalhöhe 718); 6 pt → 1,11 mm Warnung, 7 pt → 1,29 mm in Ordnung; 500-g-Glas mit Regel 4 mm → Füllmenge ab 15,8 pt. `minXHeightMm` Vorgabe 1,2, Bereich 0,5–5.
- Sperrbezirk (Spec 5.6): aktiv bei `startsOn` ≤ Tag ≤ `endsOn` (oder `endsOn` leer), Tag = UTC-Tag der DB-Uhr. Sperre bei `move` (Quell- oder Zielstand), `create` im Stand, Stand-Korrektur über `update` (alter oder neuer Stand), `merge` über verschiedene Stände. Keine Sperre bei `split`. Ernte nur Warnung. `restrictionOverrideReason` 10–500 Zeichen, nur mit `compliance:manage` (sonst 403), landet im Audit und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“), wird nie am Volk gespeichert. Fehler: 409 `{ code: 'RESTRICTED_ZONE', message, restrictionIds }`. `apiaryIds` 1–50.
- Behördenpaket (Spec 5.5): Bezugsstand = jüngste `SENT`-Anzeige der Art `REGISTRATION`/`HERD_CHANGE` (nach `sentOn`); `REGISTRATION` nur ohne Bezugsstand, `HERD_CHANGE` nur mit („Erst die Erstanzeige erfassen“, 409); `TSK_REPORT` braucht `year`; mit `sentOn` im Anlegen entsteht `SENT` gleich. Neu zählen und Löschen nur im Entwurf; `openKey` = `REGISTRATION` | `HERD_CHANGE` | `TSK_REPORT:<Jahr>` nur bei `DRAFT`; zweiter Entwurf → 409 `{ code: 'DRAFT_EXISTS', existingId }`. Mutex: Zeile `ComplianceSettings` per `lockRowForUpdate`. Einstellungen: `noticeOnColonyCountChange`/`noticeOnApiaryChange` Vorgabe `true`, `tskReferenceDay` Vorgabe `01-01` (Format `MM-TT`, `02-29` und `1-1` → 400), `tskReportDueDay` leer, `tskExcludedColonyTypes` leer. Lebende Völker über `LIVING_COLONY_WHERE` an aktiven Ständen. Nachholfenster der Tagesauswertung 7 Tage.
- Prüfbereitschaft (Spec 5.7): 18 Prüfpunkte in fünf Bereichen (`STAMMDATEN`, `BEHOERDEN`, `TIERARZNEIMITTEL`, `ETIKETTEN`, `ERNTE`), Schwere `PFLICHT` | `PRUEFEN`; Quittieren nur bei `acknowledgeable`, Notiz 10–2000 Zeichen, Fingerabdruck muss zum aktuell berechneten passen (sonst 409), Quittung zurücknehmbar; Behandlungen der letzten 6 Kalenderjahre, Ernten der letzten 2 Jahre (`HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2`); `subjects` höchstens 50, Rest in `more`. Jeder offene Punkt ist rot, Schwere steht als Wort („Pflicht“/„Prüfen“) daneben.
- Meldungen: höchstens eine ungelesene je Thema und Nutzer (`relatedType = 'ComplianceItem'`, `relatedId` `herd-change` bzw. `tsk-report:<Jahr>`). Texte exakt: „Bestand geändert: jetzt {n} Völker an {m} Ständen. Angezeigt: {a} an {b}. Anzeige ans Veterinäramt prüfen.“ (Priorität `HINWEIS`), „TSK-Stichtag {TT.MM.JJJJ}: {n} Völker gezählt. Bitte prüfen und melden.“ (`WARNUNG`).
- Eingaben: `MaxLength` für jeden Freitext (Namen 200, Anschriften 300, Begründungen 500, Notizen 2000); Mengen `IsPositive` und endlich; IDs im cuid-Zeichensatz (`/^[A-Za-z0-9_-]+$/`).
- Keine Arzneimittel- oder Rechtsdaten im Code oder Seed: Der Katalog startet leer; Wächter `seed-no-medicines.spec.ts`.
**Projektregeln**
- Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-`tx` als erster Parameter, optionaler `tx` als letzter.
- Datensatz, Audit und Ereignis in **einer** Transaktion. Handler sind reine DB-Schreiber (nur `tx`, keine Netzaufrufe, `handler-contract.spec.ts` deckt `compliance.handlers.ts` ab). Zeitvergleiche in SQL gegen `now()`.
- **Read-modify-write nur unter Zeilensperre:** `before` wird **innerhalb** der Transaktion nach `lockRowForUpdate(tx, table, id)` gelesen. Alle neuen Tabellen kommen in `LOCKABLE_TABLES` – in **einem** Task (Task 1). Sperr-Reihenfolge = Reihenfolge der Map; die T6-Tabellen stehen hinter den bisherigen (`Colony` → … → `Treatment` → `VeterinaryMedicine` → `VeterinaryMedicineLot` → `ComplianceSettings` → `AuthorityNotice` → …). Bestehende Services, die T6 anfasst und `before` außerhalb lesen (`SiteSettingsService.update`, `ProductsService.update`, `LabelTemplatesService.update`, `ApiariesService.*`), stellt der jeweilige Task auf die Sperre um.
- Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20). Neue oder geänderte Update-DTOs stehen in einem Null-Vertrags-Spec (Task 1 liefert dafür `describeNullContracts`; jede T6-Task legt ihre Verträge in eine **eigene** Spec-Datei, damit parallele Tasks nicht dieselbe Datei ändern).
- Worker und Jobs nutzen den resolver-only `JobPermissionsModule`, nicht `PermissionsModule`: Kein Provider eines Moduls, das `WorkerModule` importiert, darf `PermissionResolverService` injizieren (die Prüfung von `restrictionOverrideReason` liegt darum in `ColoniesService`). Jedes Modul mit Handlern wird von `AppModule` **und** `WorkerModule` importiert.
- UI deutsch, kurze Sätze, ein Hauptknopf je Ansicht (`DetailHeader`), seltene Felder eingeklappt (`FormSection`), Felder 44 px; Zustand nie nur über Farbe (Symbol + Wort); roter Text mit ≥ 4,5:1 (Token `destructive`); WCAG 2.2 AA; `aria-live="polite"` für Live-Listen; ein gesperrter Druckknopf bleibt fokussierbar (`aria-disabled` + `aria-describedby`); nach einem 409 springt der Fokus auf die Lückenliste. Code-Bezeichner, Log- und Fehlertexte (außer Nutzertexten) englisch.
- Fehler und Logs nur über `loggableError()`, nie Payload oder Freitext. PDFs mit `Cache-Control: no-store`, Dateinamen ohne Nutzereingaben (`anzeige-<id>.pdf`, `pruefbericht.pdf`).
- TDD: zuerst der fehlschlagende Test; Testnamen englisch. **Agents committen nicht.** Jeder Task endet mit „Verifikation“, „kein Commit“ und einer Dateiliste an den Orchestrator.
- Backend-Checks: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci`; einzelne e2e-Datei `npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand`. Frontend: vor Next.js-Code `node_modules/next/dist/docs/` lesen (`AGENTS.md`); Checks `npm run lint && npm run typecheck && npx vitest run <pfade>`.
- `ARCHITECTURE.md` und `TASKS.md` trägt der Orchestrator ein; jeder Task liefert seinen Doku-Text im Bericht („Docs“). Neue Annahmen bekommen die **nächste freie Nummer** zum Zeitpunkt des Eintrags.
---
## Review Focus
1. **Rechte-Leck von Belegen, Anschriften und Sperrdaten.** Der Dokument-Endpunkt prüft nur `documents:*`; ohne Gegenmaßnahme sähe jeder mit `documents:read` Erwerbsbelege, Anzeigen-Kopien und Verfügungen. Dazu Prüfpunkte mit Arzneimittelnamen, Ereignis-Payloads, Webhooks, Herkunftsseite.
- Task 4 e2e `a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document`
- Task 13 unit `hides subject labels of the medicine area without veterinary-medicines:read and keeps the count`
- Task 7 unit `none of the four new events is forwarded to webhooks and payloads carry only ids`
- Task 5 e2e `the public origin page exposes neither foodBusinessAddress nor legalName`; Task 8 e2e `GET /public routes never contain restriction data`
2. **Bestand und Bestandsbuch falsch.** Doppelte oder fehlende Buchung bei Bearbeiten, Packungswechsel, Sammelerfassung, Wiederholung; gleichzeitige Inventuren; Lücken-Helfer und PDF widersprechen sich.
- Task 3 e2e `editing dose and lot books only the delta and the movements sum to the new dose`
- Task 3 e2e `a repeated bulk request with the same Idempotency-Key books once`
- Task 2 e2e `two parallel inventories on one lot: the second computes with the new rest`
- Task 3 unit `prints FEHLT exactly where treatmentLogGaps reports a gap`
3. **Etikett-Prüfung lässt Lücken durch oder sperrt Vollständiges.** Abgeschnittener Text, Zeilenumbrüche und Trennzeichen in der Anschrift, fest getippte „500 g“ auf 250 g, Dezimalkomma, fehlendes Produkt.
- Task 5 unit `treats clipped text as missing`, `matches the address across line breaks and separators`, `flags a static "500 g" on a 250 g bottling`
- Task 6 e2e `409 with the gap list, then 200 and an audit PRINT after the master data is complete`
4. **Sperrbezirk umgehbar oder zu streng.** Jeder Pfad, der ein Volk zwischen Ständen bringt; `restrictionOverrideReason` darf nicht in die Datenbank-Daten gelangen; Ableger und Ernte bleiben frei.
- Task 8 e2e `update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE`
- Task 8 unit `the override reason is never part of the data written to the colony`
- Task 8 e2e `split inside the apiary and a harvest at a restricted apiary succeed`
5. **Doppelte Entwürfe und Meldungen** bei Wiederholung, parallelen Handlern, Nachholen und späterem Wechsel der Einstellung.
- Task 9 e2e `two parallel creates of the same draft: one 201, one 409 with existingId`
- Task 11 e2e `a second colony within one unread notice adds no second notification`
- Task 11 e2e `daily evaluation twice on the reference day: one event, one draft`
- Task 12 unit `a revoked or stale acknowledgement reopens the item`
---
## Abhängigkeiten und Parallelisierung
| Welle | Tasks (parallel, verschiedene Dateien) | wartet auf |
|---|---|---|
| 1 | 1 | Rückgrat, T3, T4 |
| 2 | 2, 4, 5, 7 | 1 |
| 3 | 3, 6, 8, 9 | 3 → 2; 6 → 5; 8 → 1, 7; 9 → 1 |
| 4 | 10, 11, 12 | 10 → 9; 11 → 7, 9; 12 → 8, 9 |
| 5 | 13, 14, 15, 17 | 13 → 3, 6, 12; 14 → 12; 15 → 2, 4; 17 → 5, 6 |
| 6 | 16, 18, 19 | 16 → 3, 15; 18 → 10, 11, 14; 19 → 8, 14 |
| — | **Abschluss und Rollout** | 1–19 |
Geteilte Dateien, deshalb je Welle nur ein Task:
- `prisma/schema.prisma`, `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec), `src/auth/permissions/permission-catalog.ts`: nur Task 1.
- `src/app.module.ts`: 2 (W2), 9 (W3), 12 (W4); `src/worker/worker.module.ts`: nur 11.
- `src/domain-events/domain-event-types.ts`, `src/colonies/colonies.service.ts`, `src/apiaries/apiaries.service.ts`: 7 (W2), dann 8 (W3, nur `colonies.service.ts` und DTOs).
- `src/compliance/compliance.module.ts`: 9 (W3), 11 (W4); `authority-notices.controller.ts`: 9 (W3), 10 (W4); `src/compliance/readiness/readiness-context.ts` und `rules/index.ts`: 12 (W4), 13 (W5).
- `src/label-templates/label-templates.service.ts`, `label-element.types.ts`: 5 (W2), 6 (W3).
- `src/export/treatment-log.ts`, `src/treatments/*`: nur 3. `src/documents/*`: nur 4. `src/notifications/notifications.service.ts`, `src/jobs/daily-evaluation.rules.ts`: nur 11.
- Frontend: `src/app/(app)/nav-config.ts` nur 14; `src/lib/compliance/types.ts` 14 (W5), danach nur lesen; `src/app/(app)/colonies/[id]/*`: 16 (W6, nur `treatment-edit-dialog.tsx` und `page.tsx`) und 19 (W6, nur `colony-actions.tsx`, `colony-edit-card.tsx`) – disjunkte Dateien; `src/app/(app)/storage/page.tsx`: nur 15.
- `ARCHITECTURE.md`, `TASKS.md`, `docs/pruefung-2026-09.md`: nur der Orchestrator.
---
## Tasks
## Lieferung T6a – Backend
### Task 1: Schema, Rechte, Sperren, Backup, Null-Vertrags-Helfer
**Files**
- Modify: `prisma/schema.prisma`, `src/auth/permissions/permission-catalog.ts`, `src/prisma/row-lock.ts` (+ `row-lock.spec.ts`), `src/backup/backup.service.ts` (+ `backup.service.spec.ts`), `src/common/validation/update-dto-null-handling.spec.ts` (nur Umbau auf den Helfer)
- Create: `prisma/migrations/20261016100000_t6_veterinary_medicines/migration.sql`, `…100100_t6_label_particulars/migration.sql`, `…100200_t6_authority_compliance/migration.sql`, `src/common/validation/null-contract.ts`
- Test: neu `test/t6-schema.e2e-spec.ts`, `test/compliance-permissions.e2e-spec.ts`; `test/backup.e2e-spec.ts` (ergänzen)
- Docs: ARCHITECTURE §8 „Backup“: neue Modelle sind Teil des versiegelten Bundles; §10 Ereigniskatalog folgt in Task 7.
**Interfaces**
- Consumes: T4 Task 1 (`LOCKABLE_TABLES` als `{ table, key }`).
- Produces:
```prisma
// Migration 1: enum MedicineUnit { ML G PIECE }, enum VeterinaryMedicineMovementType { TREATMENT CORRECTION DISPOSAL },
// model VeterinaryMedicine / VeterinaryMedicineLot / VeterinaryMedicineMovement exakt wie Spec 4.1,
// Treatment += medicineId String? (-> VeterinaryMedicine), medicineLotId String? (-> VeterinaryMedicineLot), doseQuantity Float?,
// medicineMovements VeterinaryMedicineMovement[]; @@index([medicineLotId]); Gegenrelationen an User
// Migration 2: SiteSettings += foodBusinessName String?, foodBusinessAddress String?; Product += legalName String?;
// LabelTemplate += minXHeightMm Float @default(1.2); AuditAction += PRINT
// Migration 3: enum AuthorityNoticeKind, AuthorityNoticeStatus; model ComplianceSettings, AuthorityNotice, ApiaryRestriction,
// ComplianceAcknowledgement exakt wie Spec 4.4/4.5; Apiary += restrictions ApiaryRestriction[]
```
```ts
// src/common/validation/null-contract.ts
export interface NullContract { dto: new () => object; notNull: string[]; clearable: string[] }
export function describeNullContracts(title: string, contracts: Record<string, NullContract>): void; // registriert die drei Prüfungen je Vertrag (leerer Patch, null auf notNull -> 400 genau dieses Felds, null auf clearable -> ok)
// permission-catalog.ts: veterinary-medicines {read 'Ansehen', create 'Anlegen', update 'Bearbeiten'} Gruppe 'Tierarzneimittel';
// compliance {read 'Ansehen', manage 'Verwalten'} Gruppe 'Prüfbereitschaft & Behörden'; sortOrder hinter dem höchsten vorhandenen Wert
// row-lock.ts: LOCKABLE_TABLES += Apiary, LabelTemplate, Product, SiteSettings, VeterinaryMedicine, VeterinaryMedicineLot,
// ComplianceSettings, AuthorityNotice, ApiaryRestriction, ComplianceAcknowledgement (alle Schlüssel "id"), in dieser Reihenfolge hinter den bisherigen Einträgen
```
- [ ] **Schritt 1: Failing Tests**
```ts
// test/t6-schema.e2e-spec.ts
it('the CHECK rejects a lot with quantity 0', ...);
it('the CHECK rejects a movement with quantity 0', ...);
it('the CHECK rejects a TREATMENT movement without treatmentId and a CORRECTION movement with one', ...);
it('rejects an ApiaryRestriction that ends before it starts', ...);
it('rejects minXHeightMm below 0.5 and above 5', ...);
it('ComplianceSettings default netQuantityDigitRules equals the four rules of the spec', ...);
it('openKey is unique, several rows with openKey NULL are allowed', ...);
it('a medicine with lots cannot be deleted (RESTRICT)', ...);
// test/compliance-permissions.e2e-spec.ts
it('BEEKEEPER holds compliance:manage and veterinary-medicines:update', ...);
it('STAFF holds compliance:read and veterinary-medicines:create but not compliance:manage', ...);
it('READONLY holds the two read permissions only', ...);
it('API_CLIENT holds none of the five new permissions', ...);
// row-lock.spec.ts
it('lists the T6 tables after Treatment in lock order', ...);
it('locks ComplianceSettings by id', ...);
// backup.service.spec.ts
it('no T6 model is excluded from the backup or listed as legacy-unsealed', ...);
it('restores medicine, lot, treatment and movement in foreign-key order', ...);
it('an unsealed legacy file skips every T6 table and reports it', ...);
// update-dto-null-handling.spec.ts
it('keeps every existing contract green after the move to describeNullContracts', ...); // gleiche Fälle wie bisher
```
- [ ] **Schritt 2:** `npx jest src/prisma src/backup src/common/validation` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren**
- Schema und drei Migrationen nach Spec 4.1–4.7; SQL aus `migrate diff` erzeugen und von Hand ergänzen: CHECKs, Vorgabe-JSON, Rechte-INSERTs nach dem Muster von `20260928091348_winter_checks` (Vergabe-Matrix aus „Rechte“ oben, `ON CONFLICT DO NOTHING`).
- `backup.service.ts`: Abschnitte `veterinaryMedicine`, `veterinaryMedicineLot`, `veterinaryMedicineMovement`, `complianceSettings`, `authorityNotice`, `apiaryRestriction`, `complianceAcknowledgement`; Import-Reihenfolge `veterinaryMedicine` → `veterinaryMedicineLot` → `treatment` → `veterinaryMedicineMovement`, `apiaryRestriction` nach `apiary`, `userId`-Spalten über `resolveUserId`/`resolveOptionalUserId`. Keine T6-Tabelle in `LEGACY_UNSEALED_TABLES`.
- `null-contract.ts` aus dem Rumpf des bestehenden Specs herausziehen (Verhalten unverändert); der Spec ruft den Helfer auf.
- [ ] **Schritt 4:** → PASS; Drift-Check `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` → 0.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/t6-schema.e2e-spec.ts test/compliance-permissions.e2e-spec.ts test/backup.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 2: Katalog, Packungen und Bewegungsbuch
**Files**
- Create: `src/common/quantity.ts` (+ Spec), `src/veterinary-medicines/medicine-ledger.ts` (+ Spec), `veterinary-medicines.module.ts`, `veterinary-medicines.service.ts` (+ Spec), `veterinary-medicine-lots.service.ts` (+ Spec), `veterinary-medicines.controller.ts`, `veterinary-medicine-lots.controller.ts`, `dto/create-veterinary-medicine.dto.ts`, `update-veterinary-medicine.dto.ts`, `create-lot.dto.ts`, `update-lot.dto.ts`, `create-movement.dto.ts`, `medicine-list-query.dto.ts`, `lot-list-query.dto.ts`, `veterinary-medicines-null-contract.spec.ts`, `src/seed-no-medicines.spec.ts` (Wächter: `prisma/seed.ts` legt keine Tierarzneimittel an)
- Modify: `src/bottling/bottling.service.ts` (`roundKg` → `roundToThousandths`), `src/app.module.ts`
- Test: neu `test/veterinary-medicines.e2e-spec.ts`; `test/bottling.e2e-spec.ts` (läuft unverändert grün)
- Docs: neue Annahme „Katalog und Packungen“ (eigenes Bewegungsbuch statt `StockItem`/`BatchMovement`, Rest berechnet, negativer Rest erlaubt, Katalog startet leer – Annahme 10).
**Interfaces**
- Consumes: Task 1.
- Produces:
```ts
export function roundToThousandths(value: number): number; // Math.round(v * 1000) / 1000
// medicine-ledger.ts
export interface LotLedgerEntry { quantity: number }
export function summarizeLot(initial: number, entries: readonly LotLedgerEntry[]): { remaining: number; used: number }; // remaining gerundet, used = -Σ(negative TREATMENT/DISPOSAL)... einfach: initial - remaining
export interface BookedPerLot { lotId: string; quantity: number } // vorzeichenbehaftet, bisherige Summe der TREATMENT-Buchungen je Packung
export interface DesiredBooking { lotId: string; dose: number } // dose > 0
export function treatmentBookingDelta(current: readonly BookedPerLot[], desired: DesiredBooking | null): BookedPerLot[]; // Gegenbuchung je alter Packung, Soll -dose auf der neuen; leer wenn nichts zu tun; Rundung auf Tausendstel
export interface MedicineView { id; name; ...alle Felder; totalRemaining: number; nextExpiry: string | null; lotCount: number }
export interface LotView { id; medicineId; medicineName; unit: MedicineUnit; batchNumber; purchasedOn; quantity; remaining: number; expiresOn; supplier; purchaseProof; prescribingVet; notes; movementCount: number }
export interface LotDetailView extends LotView { movements: { id; type; quantity; treatmentId; reason; userId; createdAt }[] }
export interface MedicineSuggestion { name: string; treatmentCount: number; latest: { activeIngredient: string | null; withdrawalPeriodDays: number | null; method: string | null; amount: string | null } }
VeterinaryMedicinesService.findAll(active?: boolean): Promise<MedicineView[]>;
VeterinaryMedicinesService.findOne(id: string): Promise<MedicineView & { lots: LotView[] }>;
VeterinaryMedicinesService.suggestions(): Promise<MedicineSuggestion[]>;
VeterinaryMedicinesService.create(dto: CreateVeterinaryMedicineDto, userId: string): Promise<MedicineView>;
VeterinaryMedicinesService.update(id: string, dto: UpdateVeterinaryMedicineDto, userId: string): Promise<MedicineView>; // unit: 409 sobald eine Packung existiert
VeterinaryMedicinesService.setActive(id: string, active: boolean, userId: string): Promise<MedicineView>;
VeterinaryMedicineLotsService.findAll(query: { medicineId?: string; selectable?: boolean }): Promise<LotView[]>; // selectable: Rest > 0, fifoOrder
VeterinaryMedicineLotsService.findOne(id: string): Promise<LotDetailView>;
VeterinaryMedicineLotsService.create(dto: CreateLotDto, userId: string): Promise<LotView>;
VeterinaryMedicineLotsService.update(id: string, dto: UpdateLotDto, userId: string): Promise<LotView>; // medicineId: 409 sobald eine Bewegung existiert
VeterinaryMedicineLotsService.addMovement(id: string, dto: CreateMovementDto, userId: string): Promise<LotDetailView>; // CORRECTION { actualRemaining } | DISPOSAL { quantity, reason }
// Endpunkte exakt nach Spec 7 (Medicines, Lots); POST-Anlegen, :id/movements und deactivate/reactivate tragen @Idempotent() bzw. nur die Anlege-Endpunkte (Annahme 11)
```
- [ ] **Schritt 1: Failing Tests**
```ts
// quantity.spec.ts
it('rounds to thousandths', () => expect(roundToThousandths(0.0004)).toBe(0));
it('keeps 1.2345 as 1.235 (half up)', ...);
// medicine-ledger.spec.ts
it('summarizeLot rounds to thousandths', () => expect(summarizeLot(10, [{ quantity: -3.3333 }, { quantity: -3.3333 }]).remaining).toBe(3.333));
it('treatmentBookingDelta books the full dose on create', ...); // current [], desired {A, 5} -> [{A, -5}]
it('moves the booking when the lot changes', ...); // current [{A,-5}], desired {B,5} -> [{A,+5},{B,-5}]
it('reverses the booking when dose or lot is cleared', ...); // desired null -> [{A,+5}]
it('books nothing when nothing changed', ...); // -> []
it('books only the difference when the dose changes on the same lot', ...); // -5 -> 7: [{A,-2}]
// veterinary-medicines.service.spec.ts
it('rejects changing the unit once a lot exists with 409', ...);
it('allows editing every other field of a medicine with lots', ...);
it('suggestions skip names already in the catalogue regardless of case and give the latest values', ...);
// veterinary-medicine-lots.service.spec.ts
it('CORRECTION books the delta between the rest and the actual rest and nothing for delta 0', ...);
it('DISPOSAL books the negative quantity and needs a reason', ...);
it('rejects changing medicineId once a movement exists with 409', ...);
it('selectable lots are ordered oldest expiry first and exclude an empty lot', ...);
it('a negative rest is allowed and reported in the view', ...);
// test/veterinary-medicines.e2e-spec.ts
it('lot -> treatment -> rest: a treatment books the dose and the rest shrinks', ...); // wird in Task 3 grün, hier mit direkter Bewegung CORRECTION
it('two parallel inventories on one lot: the second computes with the new rest', ...);
it('STAFF may create a lot, READONLY gets 403, API_CLIENT gets 403 on every route', ...);
it('a POST with a repeated Idempotency-Key creates one lot', ...);
it('rejects a quantity of 0 or NaN with 400', ...);
it('a movement can neither be changed nor deleted (no such routes)', ...);
```
- [ ] **Schritt 2:** `npx jest src/common/quantity.spec.ts src/veterinary-medicines` und `test/veterinary-medicines.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren**
- `CORRECTION`: in der Transaktion `lockRowForUpdate(tx, 'VeterinaryMedicineLot', id)`, Summe der Bewegungen dort lesen, Delta = `actualRemaining − remaining`, Bewegung nur bei Delta ≠ 0, Audit `UPDATE` an der Packung (`beforeData`/`afterData` mit Rest). `DISPOSAL`: gleiche Sperre, `quantity` positiv im DTO, gebucht als negativ.
- `update` der Packung und des Mittels: Sperre → `before` lesen → Prüfung (`unit`: `tx.veterinaryMedicineLot.count`, `medicineId`: `tx.veterinaryMedicineMovement.count`) → schreiben → Audit. Mittelsperre vor Packungssperre.
- Rest in Listen: eine Gruppen-Abfrage (`groupBy lotId, _sum quantity`), kein N+1; `totalRemaining` summiert nur Packungen aktiver Mittel nicht, sondern alle Packungen des Mittels.
- Vorschläge: Roh-SQL gruppiert `lower(trim(product))` über `Treatment` ohne Katalogeintrag mit gleichem Namen (ohne Groß-/Kleinschreibung), höchstens 50, jüngste Behandlung liefert die Werte. Es wird nichts angelegt.
- DTOs: `MaxLength` nach Global Constraints, `IsPositive` für `quantity`/`dosePerColony`, `withdrawalPeriodDays` `IsInt @Min(0)`, Temperaturen endlich und `minTemperatureC ≤ maxTemperatureC` (400), Update-DTO über `PartialType(…, { skipNullProperties: false })`; Null-Verträge in `veterinary-medicines-null-contract.spec.ts` über `describeNullContracts`.
- Modul in `AppModule` eintragen (exportiert beide Services für Task 3).
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/veterinary-medicines.e2e-spec.ts test/bottling.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 3: Behandlung bucht von der Packung, Bestandsbuch aus der Packung, Lücken-Helfer
**Files**
- Modify: `src/treatments/treatments.service.ts` (+ Spec), `src/treatments/treatments.module.ts` (importiert `VeterinaryMedicinesModule`), `src/treatments/dto/create-treatment.dto.ts`, `dto/update-treatment.dto.ts`, `src/export/treatment-log.ts` (+ `treatment-log.spec.ts`)
- Create: `src/treatments/treatments-null-contract.spec.ts`
- Test: `test/treatments.e2e-spec.ts`, `test/export.e2e-spec.ts` (Spec nennt `test/treatment-log.e2e-spec.ts`; die Datei gibt es nicht, der Bestandsbuch-e2e lebt in `export.e2e-spec.ts`)
- Docs: ARCHITECTURE Annahme 15 ergänzen: Lieferant, Beleg, Ch.-B. und Tierarzt kommen bei Behandlung aus einer Packung live aus der Packung.
**Interfaces**
- Consumes: Task 1, Task 2 (`treatmentBookingDelta`, `roundToThousandths`, Services), T3 (`treatmentRow`, `recordIn`, `createBulk`).
- Produces:
```ts
// CreateTreatmentDto += medicineId?: string; medicineLotId?: string; doseQuantity?: number (@IsPositive, @IsFinite);
// product: @ValidateIf((o) => !o.medicineId && !o.medicineLotId) @IsString() @MaxLength(200) (Spec 5.2: fehlt product, wird es der Handelsname)
// UpdateTreatmentDto += apiaryId?: string (notNull; Stand am Behandlungstag korrigieren); medicineId/medicineLotId/doseQuantity clearable
// treatments.service.ts (privat, von create, createBulk und update gemeinsam genutzt)
interface ResolvedMedicine { medicineId: string | null; medicineLotId: string | null; product: string }
resolveMedicineIn(tx: Prisma.TransactionClient, input: { product?: string | null; medicineId?: string | null; medicineLotId?: string | null; supplier?: unknown; purchaseProof?: unknown; batchNumber?: unknown; prescribingVet?: unknown }, mode: 'create' | 'update'): Promise<ResolvedMedicine>;
bookTreatmentIn(tx: Prisma.TransactionClient, treatment: Treatment, userId: string): Promise<void>; // Gegenbuchung/Neubuchung über treatmentBookingDelta, Summe je Packung aus vorhandenen Bewegungen dieser Behandlung
// export/treatment-log.ts
export interface TreatmentLogSource { …bisher; medicineId: string | null; prescriptionOnly: boolean; doseQuantity: number | null; medicineUnit: MedicineUnit | null;
lot: { id: string; batchNumber: string | null; supplier: string | null; purchaseProof: string | null; prescribingVet: string | null } | null }
export interface TreatmentLogRow { …bisher; lotId: string | null; prescriptionOnly: boolean }
export type TreatmentLogGap = 'SUPPLIER' | 'PROOF' | 'AMOUNT' | 'WITHDRAWAL' | 'VET';
export function resolveSourceValues(source: TreatmentLogSource): Pick<TreatmentLogSource, 'batchNumber' | 'supplier' | 'purchaseProof' | 'prescribingVet'> & { amount: string | null }; // Spalten aus genau einer Quelle (Spec 5.3)
export function treatmentLogGaps(row: Pick<TreatmentLogRow, 'supplier' | 'purchaseProof' | 'amount' | 'withdrawalPeriodDays' | 'prescribingVet' | 'prescriptionOnly'>): TreatmentLogGap[];
```
- [ ] **Schritt 1: Failing Tests**
```ts
// treatments.service.spec.ts
it('create books the dose from the lot in the same transaction', ...);
it('rejects a lot of another medicine', ...);
it('rejects supplier text together with a lot', ...);
it('takes the product name from the catalogue', ...);
it('rejects medicine and product that differ ignoring case', ...);
it('rejects an inactive catalogue entry on create but allows editing an existing treatment of it', ...);
it('sets medicineId from the lot when only the lot is given', ...);
it('never fills withdrawal or ingredient from the catalogue', ...);
it('a dose without a lot is stored and books nothing', ...);
it('bulk books one movement per colony', ...);
it('bulk rolls back all bookings when one colony fails', ...);
it('update books only the delta when the dose changes and moves the booking when the lot changes', ...);
it('update with dose null or lot null reverses the booking', ...);
it('update rejects medicineId null while a lot is set', ...);
it('update reads before after locking Treatment and then the lots in ascending id order', ...);
it('re-filing to another colony leaves the stock untouched', ...);
it('update accepts apiaryId and rejects an unknown apiary with 404', ...);
// treatment-log.spec.ts
it('takes supplier, proof, batch and vet from the lot', ...);
it('prints the dose with unit when the amount text is empty', () => expect(resolveSourceValues(withDose(30, 'ML')).amount).toBe('30 ml'));
it('requires the vet only for prescription-only medicines', ...);
it('groups two colonies of the same lot and day into one row', ...);
it('prints FEHLT exactly where treatmentLogGaps reports a gap', ...);
it('footnote says supplier, proof and batch come from the recorded lot', ...);
// test/treatments.e2e-spec.ts
it('editing dose and lot books only the delta and the movements sum to the new dose', ...);
it('a repeated bulk request with the same Idempotency-Key books once', ...);
it('a treatment from an empty lot is stored and the rest turns negative', ...);
it('a treatment after the lot expired is stored', ...);
it('a user with treatments:create but without veterinary-medicines:read can book from a lot', ...);
// test/export.e2e-spec.ts
it('a complete catalogue entry and lot give a Bestandsbuch PDF without FEHLT and treatmentLogGaps empty for every row', ...);
```
- [ ] **Schritt 2:** `npx jest src/treatments src/export` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren**
- `treatmentRow` bleibt die einzige Datenaufbereitung für `create`, `createBulk` und (Teilmenge) `update`; `resolveMedicineIn` wird aus allen drei Pfaden aufgerufen. Bulk: Prüfung einmal, Buchung je Volk in `recordIn` (nach dem `tx.treatment.create`, vor Audit).
- `update`: nach `lockRowForUpdate(tx, 'Treatment', id)` und gelesenem `before` die betroffenen Packungen (alte und neue) in aufsteigender ID sperren (`lockRowForUpdate(tx, 'VeterinaryMedicineLot', …)`), Summe der bisherigen `TREATMENT`-Bewegungen je Packung lesen, `treatmentBookingDelta` anwenden. Der zusammengeführte Zustand (`before` + DTO) wird geprüft, nicht das DTO allein; `null` leert das Feld.
- Buchung: `tx.veterinaryMedicineMovement.create({ type: 'TREATMENT', quantity, treatmentId, lotId, userId })`; Audit der Behandlung bleibt unverändert, `afterData` enthält `medicineLotId`/`doseQuantity`.
- `treatment-log.ts`: `buildTreatmentLog` lädt `medicine` und `medicineLot` per `include`, ruft `resolveSourceValues`, Gruppierungsschlüssel über die aufgelösten Werte; `renderSheet` ruft `treatmentLogGaps(row)` und druckt `MISSING` genau bei gemeldeter Lücke (Tierarzt: Spalte „a) Lieferant …“). Fußnote ergänzen. Layout unverändert.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/treatments.e2e-spec.ts test/export.e2e-spec.ts test/veterinary-medicines.e2e-spec.ts test/follow-up-controls.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 4: Dokumente zu Packung, Anzeige und Verfügung – Rechte je Art
**Files**
- Modify: `src/documents/documents.service.ts` (+ Spec), `src/documents/documents.controller.ts`, `src/documents/documents.module.ts` (falls nötig)
- Test: `test/documents.e2e-spec.ts`
- Docs: ARCHITECTURE Abschnitt Dokumente: für die drei T6-Arten gilt zusätzlich das Fachrecht.
**Interfaces**
- Consumes: Task 1 (Enum-Werte), `PermissionResolverService.getPermissions`.
- Produces:
```ts
export const DOCUMENT_ENTITY_PERMISSIONS: Partial<Record<DocumentEntityType, { read: PermissionKey; write: PermissionKey }>> = {
MEDICINE_LOT: { read: 'veterinary-medicines:read', write: 'veterinary-medicines:update' },
AUTHORITY_NOTICE: { read: 'compliance:read', write: 'compliance:manage' },
APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' },
};
DocumentsService.assertEntityAccess(userId: string, entityType: DocumentEntityType, mode: 'read' | 'write'): Promise<void>; // 403 ohne Recht; Arten ohne Eintrag: nichts zusätzlich
// findAll/create/readFile/update/delete bekommen `actingUserId`; update prüft alte UND neue Art; assertEntityExists kennt die drei neuen Arten (404 wie bisher)
```
- [ ] **Schritt 1: Failing Tests**
```ts
// documents.service.spec.ts
it('assertEntityExists finds a medicine lot, an authority notice and an apiary restriction and 404s a missing one', ...);
it('assertEntityAccess is a no-op for the old entity types', ...);
it('update checks the permission of the old and of the new entity type', ...);
// test/documents.e2e-spec.ts
it('a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document', ...);
it('a user with documents:upload but without compliance:manage gets 403 uploading to an AUTHORITY_NOTICE', ...);
it('re-linking a document to an APIARY_RESTRICTION needs compliance:manage', ...);
it('deleting a MEDICINE_LOT document needs veterinary-medicines:update', ...);
it('STAFF uploads a purchase receipt to a lot and downloads it again', ...);
it('uploads keep the MIME allowlist and signature check for the new types', ...);
```
- [ ] **Schritt 2:** `npx jest src/documents` und `test/documents.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren**
- `readFile`, `update`, `delete` laden zuerst das Dokument, prüfen dann `assertEntityAccess(userId, document.entityType, …)`, erst danach Datei lesen. `findAll` und `create` prüfen vor jeder Datenbankabfrage. Der Controller reicht `actor.id` durch; die bestehenden `@RequirePermission('documents:*')` bleiben.
- Speicherordner `entityType.toLowerCase()` bleibt (neue Ordner `medicine_lot`, `authority_notice`, `apiary_restriction`).
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/documents.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 5: Etikett-Pflichtangaben – Platzhalter, Stammdaten, Prüfregeln, Schriftmetriken
**Files**
- Create: `src/label-templates/label-particulars.ts` (+ `label-particulars.spec.ts`), `src/label-templates/label-text.ts` (+ Spec; aufgelöster Inhalt, Normalisierung), `src/label-templates/font-metrics.ts` (+ Spec), `src/label-templates/label-compliance-null-contract.spec.ts`, `test/fixtures/placeholder-keys.json`, `src/label-templates/placeholder-keys.spec.ts`
- Modify: `src/label-templates/label-element.types.ts` (`PLACEHOLDER_KEYS`), `src/label-templates/label-templates.service.ts` (`buildPlaceholderValues` mit Einstellungen als zweitem Parameter; `update` liest `before` unter Sperre), `src/label-templates/dto/create-label-template.dto.ts`, `update-label-template.dto.ts` (`minXHeightMm`), `src/site-settings/site-settings.service.ts` (+ Spec; `foodBusinessName/Address`, `update` unter Sperre), `src/site-settings/dto/update-site-settings.dto.ts`, `src/products/products.service.ts`, `src/products/dto/create-product.dto.ts` (+ Update; `legalName`)
- Test: `test/products.e2e-spec.ts`, `test/public.e2e-spec.ts`
- Docs: neue Annahme „Etikett-Pflichtangaben“ (Prüfung am aufgelösten Inhalt, Sichtfeld-Regel nicht geprüft, Nährwert befreit – Annahme, Schriftmetriken aus den eingebauten PDF-Schriften).
**Interfaces**
- Consumes: Task 1.
- Produces:
```ts
export const PLACEHOLDER_KEYS = ['batchNumber','honeyType','lotNumber','bottlingDate','bestBeforeDate','jarSize','productName',
'legalName','originCountry','foodBusinessName','foodBusinessAddress','bestBeforeLine','lotLine'] as const; // bestBeforeLine "Mindestens haltbar bis TT.MM.JJJJ", lotLine "L <Losnummer>", jarSize bleibt "500 g"
export function buildPlaceholderValues(bottlingBatch: {…bisher; product: { name: string; legalName: string | null } | null },
settings: { originCountry: string; foodBusinessName: string | null; foodBusinessAddress: string | null }): Record<PlaceholderKey, string>;
// font-metrics.ts
export interface FontMetrics { xHeight: number; capHeight: number } // AFM-Einheiten (Helvetica 523/718)
export function fontMetricsOf(family: LabelFontFamily, bold: boolean, italic: boolean): FontMetrics; // liest die eingebauten pdfkit-AFM-Werte hinter EINER Kapsel (pdfkit-Feld _font ist privat)
export function heightMm(fontSizePt: number, metricPer1000: number): number; // pt * metric / 1000 * 25.4 / 72
export interface TextMeasurer { heightOfStringPt(text: string, element: LabelTextElement): number }
export function pdfTextMeasurer(): TextMeasurer; // wegwerf-PDFDocument, kein Ausgabestrom
// label-text.ts
export function normalizeForCheck(text: string): string; // toLowerCase, NFKC, alles außer Buchstaben/Ziffern -> ' ', zusammengefaltet, getrimmt
export function resolvedTexts(elements: readonly LabelTextElement[], values: Record<PlaceholderKey, string>): { element: LabelTextElement; text: string }[]; // sortiert: y, dann x
// label-particulars.ts
export type LabelFindingCode = 'LEGAL_NAME'|'NET_QUANTITY'|'BEST_BEFORE'|'LOT'|'ORIGIN'|'FOOD_BUSINESS'|'LOT_PREFIX'|'X_HEIGHT'|'NET_QUANTITY_HEIGHT'|'TEXT_CLIPPED';
export interface LabelFinding { code: LabelFindingCode; title: string; detail: string; elementIds: string[] }
export interface LabelCheckResult { printable: boolean; gaps: LabelFinding[]; warnings: LabelFinding[]; sample: boolean;
requirements: { minXHeightMm: number; netQuantityMinCapHeightMm: number | null } } // Frontend braucht keine eigenen Rechtswerte
export interface NetQuantityDigitRule { maxGrams: number | null; minMm: number }
export interface LabelCheckInput {
elements: readonly LabelTextElement[]; minXHeightMm: number; digitRules: readonly NetQuantityDigitRule[];
values: Record<PlaceholderKey, string>;
expected: { jarSizeGrams: number; bestBefore: string /* YYYY-MM-DD */; lotNumber: string; originCountry: string; foodBusinessName: string | null; foodBusinessAddress: string | null };
sample: boolean; measurer?: TextMeasurer;
}
export function checkLabel(input: LabelCheckInput): LabelCheckResult; // rein, ohne Datenbank
export const SAMPLE_BOTTLING: { jarSize: 500; lotNumber: '2026-0001'; … }; // nur Beispielwerte für die Vorschau, keine Rechtswerte
```
- [ ] **Schritt 1: Failing Tests**
```ts
// label-particulars.spec.ts
it('passes a label with all particulars', ...);
it('reports each missing particular by code', ...); // it.each über die sechs Pflichtcodes
it('matches the address across line breaks and separators', ...); // "Bühlweg 10 · 86450 Altenmünster" gegen "Bühlweg 10, 86450 Altenmünster"
it('flags a static "500 g" on a 250 g bottling', ...);
it('accepts 250,5 g for a 250.5 g jar and "1 kg" for a 1000 g jar', ...);
it('does not accept 1250 g for 250 g', ...);
it('best before needs the wording and the date, leading zeros do not matter', ...);
it('treats clipped text as missing and warns TEXT_CLIPPED', ...);
it('FOOD_BUSINESS is a gap when either master data field is empty', ...);
it('a label without product legalName is complete only with a fixed text containing "honig"', ...);
it('Helvetica 6 pt is x-height 1.11 mm (warning), 7 pt is 1.29 mm', ...);
it('x-height exactly at the limit is fine', ...);
it('needs 4 mm digits for 500 g with the default rules (15.8 pt)', ...);
it('uses the rule of the matching weight class for 50 g, 200 g and 1000 g and the open class above', ...);
it('warns on a lot without a leading L', ...);
it('a warning alone leaves printable true', ...);
it('sample result carries sample true', ...);
it('requirements carry minXHeightMm and the digit height of the jar size', ...);
// font-metrics.spec.ts
it('Helvetica metrics are x-height 523 and cap height 718', ...); // Wächter gegen pdfkit-Updates
it('Times and Courier return their own AFM values', ...);
// label-text.spec.ts
it('normalizes umlauts, NFKC compatibility characters and punctuation', ...);
it('sorts elements top first, then left', ...);
// placeholder-keys.spec.ts
it('the fixture file equals PLACEHOLDER_KEYS', ...); // test/fixtures/placeholder-keys.json; das Frontend vergleicht seine Liste damit
// site-settings.service.spec.ts
it('update reads before after locking the SiteSettings row', ...);
it('trims foodBusinessName and foodBusinessAddress and stores empty as null', ...);
// label-compliance-null-contract.spec.ts
// UpdateSiteSettingsDto: foodBusinessName/Address clearable; UpdateProductDto.legalName clearable; UpdateLabelTemplateDto.minXHeightMm notNull
// test/products.e2e-spec.ts
it('stores legalName and clears it with null', ...);
// test/public.e2e-spec.ts
it('the public origin page exposes neither foodBusinessAddress nor legalName', ...);
```
- [ ] **Schritt 2:** `npx jest src/label-templates src/site-settings src/products` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren**
- Prüfregeln wie Spec 5.4 und Global Constraints. `NET_QUANTITY`: normalisiertes `"<n> g"` als ganze Wörter im normalisierten Text (` ${text} ` enthält ` ${needle} `); bei vollen Kilogramm zusätzlich `"<n/1000> kg"`. `BEST_BEFORE`: Text „mindestens haltbar bis“ und Datum als Zahlen `0?T 0?M JJJJ`. `LOT`: Losnummer als Wortfolge; `LOT_PREFIX`: `(^| )l ?<losnummer>`. Abgeschnitten, wenn `measurer.heightOfStringPt(text, element) > element.height × 72/25.4 + 0.5`.
- Beitrag eines Elements: Es trägt zur Prüfung bei, wenn es nicht abgeschnitten ist. `X_HEIGHT` prüft jedes Element, das eine Pflichtangabe (alle sechs Pflichtcodes) trägt; Vergleich mit Toleranz `1e-9`; Titel „Text zu klein (x-Höhe 1,1 mm)“ mit deutschem Dezimalkomma, eine Nachkommastelle. `NET_QUANTITY_HEIGHT`: Versalhöhe des Elements mit der Füllmenge gegen die erste Regel mit `jarSize ≤ maxGrams` (letzte Regel `maxGrams: null`).
- `fontMetricsOf` setzt `doc.font(pdfFontName(...))` und liest `xHeight`/`capHeight` des Schriftobjekts; die Kapselung ist die einzige Stelle mit dem privaten Feld, der Spec-Test fällt bei einem pdfkit-Update.
- `renderLabelsPdf` bleibt in diesem Task unverändert bis auf den neuen Parameter von `buildPlaceholderValues` (Aufrufer: Einstellungen über `siteSettings.get()`); Druck-Sperre folgt in Task 6.
- `LabelTemplatesService.update`: `before` innerhalb der Transaktion nach `lockRowForUpdate(tx, 'LabelTemplate', id)` lesen. `SiteSettingsService.update` und `ProductsService.update` ebenso. Eine `dto.elements`-Prüfung über `PLACEHOLDER_KEYS` (DTO-Enum) übernimmt die neuen Schlüssel automatisch.
- `test/fixtures/placeholder-keys.json` enthält exakt `PLACEHOLDER_KEYS`.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/products.e2e-spec.ts test/public.e2e-spec.ts test/bottling.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 6: Etikettendruck – Sperre, Probedruck, Audit, Prüf-Endpunkte
**Files**
- Modify: `src/label-templates/label-templates.service.ts` (+ Spec), `src/label-templates/label-templates.controller.ts`, `src/label-templates/label-templates.module.ts` (importiert `SiteSettingsModule`, `AuditModule` falls nötig)
- Create: `src/label-templates/dto/check-label-template.dto.ts`, `src/label-templates/label-proof-watermark.ts` (+ Spec), `src/label-templates/label-check.loader.ts` (+ Spec)
- Test: neu `test/label-compliance.e2e-spec.ts`
- Docs: ARCHITECTURE §Etiketten: Druck-Sperre bei Pflichtlücke, Probedruck, Audit `PRINT`.
**Interfaces**
- Consumes: Task 1, Task 5 (`checkLabel`, `SAMPLE_BOTTLING`, `buildPlaceholderValues`).
- Produces:
```ts
export class CheckLabelTemplateDto { // POST /label-templates/check; nur Text-Elemente
elements!: LabelTextElementDto[]; // @ArrayMaxSize(100), @ValidateNested, type 'text' erzwungen (andere Art -> 400)
widthMm!: number; heightMm!: number; minXHeightMm!: number; bottlingBatchId?: string;
}
LabelTemplatesService.checkDraft(dto: CheckLabelTemplateDto): Promise<LabelCheckResult>; // ohne bottlingBatchId: SAMPLE_BOTTLING, sample true
LabelTemplatesService.checkForBottling(templateId: string, bottlingBatchId: string): Promise<LabelCheckResult>;
LabelTemplatesService.renderLabelsPdf(templateId: string, bottlingBatchId: string,
options: { startPosition?: number; proof?: boolean }, actingUserId: string): Promise<Buffer>; // wirft ConflictException({ code: 'LABEL_PARTICULARS_MISSING', message, gaps }) bei Pflichtlücke ohne proof
export function drawProofWatermark(doc: PDFKit.PDFDocument, originX: number, originY: number, widthPt: number, heightPt: number): void; // "MUSTER – nicht verkaufsfähig", diagonal, 50 % Grau
// GET /label-templates/:id/check/:bottlingBatchId @RequirePermission('label-templates:read')
// POST /label-templates/check @RequirePermission('label-templates:read')
// GET /label-templates/:id/print/:bottlingBatchId?startPosition=&proof= @RequirePermission('label-templates:print'); proof über ParseBoolPipe (optional)
```
- [ ] **Schritt 1: Failing Tests**
```ts
// label-templates.service.spec.ts
it('print refuses with 409 and the gap list', ...);
it('proof print renders despite a gap, adds the watermark and writes a PRINT audit entry', ...);
it('the audit entry carries templateId, copies, proof, gapCodes and warningCodes', ...);
it('a print with warnings only is rendered and audited with the warning codes', ...);
it('the audit is written before the PDF is returned and a failing audit fails the print', ...);
it('checkDraft without a bottling uses the sample values and sets sample true', ...);
it('checkForBottling uses the placeholder values of that bottling and the master data', ...);
// label-proof-watermark.spec.ts
it('draws the text MUSTER – nicht verkaufsfähig rotated, grey, half opaque, once per label', ...); // Doc-Fake zeichnet Aufrufe auf
// label-check.loader.spec.ts
it('maps a bottling batch to the expected particulars (jar grams, best-before day, lot, origin, master data)', ...);
// test/label-compliance.e2e-spec.ts
it('409 with the gap list, then 200 and an audit PRINT after the master data is complete', ...);
it('GET print with proof=true answers 200 although a particular is missing', ...);
it('POST check rejects an image element with 400', ...);
it('POST check with 101 elements answers 400', ...);
it('print needs label-templates:print, check needs label-templates:read', ...);
it('the response of a print is no-store', ...);
```
- [ ] **Schritt 2:** `npx jest src/label-templates` und `test/label-compliance.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren**
- `renderLabelsPdf` lädt Vorlage, Abfüllung (mit `honeyBatch`, `product`) und Einstellungen, ruft **dieselbe** Funktion `checkLabel` wie der Check-Endpunkt (über `label-check.loader.ts`), wirft bei `gaps.length > 0 && !proof`, rendert, ruft `drawProofWatermark` je Etikett bei `proof`, schreibt danach `auditLog.record({ entityType: 'BottlingBatch', entityId, action: 'PRINT', userId, afterData: {…} })` und gibt erst dann das PDF zurück (fail closed). Der Controller reicht `actor.id` durch und setzt `Cache-Control: no-store`.
- `checkDraft` lädt `digitRules` aus `ComplianceSettings` (Vorgabe, wenn die Zeile fehlt).
- Route `POST /label-templates/check` steht vor `@Post()`-Varianten mit Parametern; keine Kollision mit `:id`.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/label-compliance.e2e-spec.ts test/bottling.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 7: Ereigniskatalog und Erzeuger für Völker und Stände
**Files**
- Modify: `src/domain-events/domain-event-types.ts` (+ `domain-event-registry.spec.ts` falls Katalog-Zählung), `src/colonies/colonies.service.ts` (+ Spec), `src/apiaries/apiaries.service.ts` (+ Spec), `src/apiaries/apiaries.module.ts`, `src/common/after-commit-usage.spec.ts`
- Create: `src/colonies/colony-created-events.spec.ts` (optional, wenn die Service-Spec zu groß wird)
- Test: `test/colonies.e2e-spec.ts`, `test/apiaries.e2e-spec.ts`
- Docs: ARCHITECTURE §10 „Ereignisse“: `ColonyCreated`, `ColonyMoved`, `ApiaryChanged`, `TskReferenceDayReached` (nicht an Webhooks).
**Interfaces**
- Consumes: Task 1 (`Apiary` lockbar), T4 Task 10 (`ColonySplit`).
- Produces:
```ts
// DomainEventPayloads +=
ColonyCreated: { apiaryId: string };
ColonyMoved: { fromApiaryId: string; toApiaryId: string };
ApiaryChanged: { change: 'CREATED' | 'DEACTIVATED' | 'REACTIVATED' | 'LOCATION_CHANGED' };
TskReferenceDayReached: { year: number; referenceDay: string }; // referenceDay = YYYY-MM-DD des Stichtags
// AggregateType += 'Apiary' | 'Compliance'; CATALOG += die vier Typen
// Erzeuger: ColoniesService.create -> ColonyCreated (in der Transaktion); split -> ColonyCreated für den Ableger ZUSÄTZLICH zu ColonySplit (T4);
// move und update mit Standwechsel -> ColonyMoved; ApiariesService.create/update/deactivate/reactivate: Datensatz + Audit(tx) + ApiaryChanged in EINER Transaktion
```
- [ ] **Schritt 1: Failing Tests**
```ts
// colonies.service.spec.ts
it('create records ColonyCreated with the apiary inside the transaction', ...);
it('split records ColonyCreated for the child next to ColonySplit', ...);
it('move records ColonyMoved with from and to', ...);
it('update with a changed apiary records ColonyMoved and without one records none', ...);
// apiaries.service.spec.ts
it('create writes record, audit and ApiaryChanged CREATED in one transaction', ...);
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', ...);
it('update reads before after locking the Apiary row', ...);
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', ...);
it('reactivate records REACTIVATED', ...);
it('a failing audit write rolls back the apiary change', ...);
// domain-event-registry.spec.ts / webhook.handlers.spec.ts
it('none of the four new events is forwarded to webhooks and payloads carry only ids', ...);
// after-commit-usage.spec.ts
it('apiaries.service.ts has no after-commit side effects', ...);
// e2e
it('a new colony leaves one ColonyCreated event', ...); // test/colonies.e2e-spec.ts
it('updating only the name of an apiary leaves no ApiaryChanged', ...); // test/apiaries.e2e-spec.ts
```
- [ ] **Schritt 2:** `npx jest src/domain-events src/colonies src/apiaries src/common/after-commit-usage.spec.ts src/webhooks` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren**
- Der Webhook `colony.created` bleibt im Altpfad nach dem Commit. `assertApiaryActive` bleibt vor der Transaktion (Fehlerbild unverändert). Ereignisse werden über `this.events.record(tx, …)`/`recordAll` im selben `$transaction`-Block geschrieben.
- `ApiariesService` übernimmt `DomainEventsService` und `lockRowForUpdate` (Apiary); `ApiariesModule` importiert `DomainEventsModule`. `deactivate` zählt lebende Völker nach der Sperre in der Transaktion.
- Die Vergleichsfelder für `LOCATION_CHANGED` kommen aus `before` (unter Sperre) gegen den gespeicherten Stand.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/colonies.e2e-spec.ts test/apiaries.e2e-spec.ts test/domain-event-dispatcher.e2e-spec.ts test/webhooks.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 8: Sperrbezirk – Modell, Wächter-Helfer, vier Völker-Pfade, Ausnahme
**Files**
- Create: `src/compliance/restricted-zone.ts` (+ Spec), `src/apiary-restrictions/apiary-restrictions.module.ts`, `apiary-restrictions.service.ts` (+ Spec), `apiary-restrictions.controller.ts`, `dto/create-apiary-restriction.dto.ts`, `dto/update-apiary-restriction.dto.ts`, `dto/restriction-list-query.dto.ts`, `src/apiary-restrictions/apiary-restrictions-null-contract.spec.ts`
- Modify: `src/colonies/colonies.service.ts` (+ Spec), `src/colonies/colonies.module.ts` (importiert `ApiaryRestrictionsModule`), `src/colonies/dto/create-colony.dto.ts`, `update-colony.dto.ts`, `move-colony.dto.ts`, `merge-colony.dto.ts`
- Test: neu `test/restricted-zone.e2e-spec.ts`; `test/colonies.e2e-spec.ts`
- Docs: neue Annahme „Behördenpaket und Sperrbezirk“ (harte Sperre mit begründeter Ausnahme, Ernte nur Warnung, Ableger frei; BienSeuchV § 10 als Hinweis).
**Interfaces**
- Consumes: Task 1, Task 7 (`ColonyMoved`; `colonies.service.ts` ist frei), `PermissionResolverService`.
- Produces:
```ts
// src/compliance/restricted-zone.ts
export interface RestrictionOverride { reason: string | null | undefined; canOverride: boolean }
export interface ActiveRestrictionInfo { id: string; apiaryId: string; apiaryName: string; disease: string; startsOn: string }
export function isRestrictionActive(r: { startsOn: Date; endsOn: Date | null }, day: string): boolean; // startsOn <= day <= endsOn (oder offen)
export function assertNoActiveRestriction(tx: Prisma.TransactionClient, apiaryIds: readonly string[], day: string,
override?: RestrictionOverride): Promise<{ overridden: boolean; restrictionIds: string[] }>;
// wirft 409 { code: 'RESTRICTED_ZONE', message, restrictionIds, restrictions: ActiveRestrictionInfo[] }; mit reason und canOverride false -> 403; reason ohne aktive Sperre -> ignoriert
export function dbUtcDay(db: DbClient): Promise<string>; // utcDay(SELECT now())
// DTOs: restrictionOverrideReason?: string (@IsOptional @IsString @MinLength(10) @MaxLength(500)) an Create/Update/Move/Merge; null gilt wie weggelassen
// ApiaryRestrictionsService
create(dto: CreateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction[]>; // apiaryIds 1-50, eine Zeile je Stand, eine Transaktion, Audit je Zeile; @Idempotent
update(id: string, dto: UpdateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction>; // endsOn >= startsOn auf dem zusammengeführten Stand (400), unter Sperre
findActive(): Promise<ActiveRestrictionInfo[]>; findForApiary(apiaryId: string): Promise<ApiaryRestriction[]>;
// GET /apiary-restrictions?active=true, GET /apiaries/:id/restrictions (apiaries:read); POST, PATCH (compliance:manage)
```
- [ ] **Schritt 1: Failing Tests**
```ts
// restricted-zone.spec.ts
it('active on the start and end day, inactive the day after', ...);
it('an open end date means until further notice', ...);
it('refuses moving into and out of a restricted apiary', ...);
it('override needs compliance:manage and a reason', ...); // 403 ohne Recht
it('a reason without an active restriction is ignored', ...);
it('the 409 body names apiary, disease and start', ...);
// colonies.service.spec.ts
it('split inside the apiary is allowed', ...);
it('merge across apiaries is refused when one is restricted and allowed within one apiary', ...);
it('update with a changed apiary checks old and new apiary, a name change checks nothing', ...);
it('create in a restricted apiary is refused', ...);
it('the override reason is never part of the data written to the colony', ...);
it('an override writes the reason into the audit entry and into ColonyApiaryHistory.notes', ...);
it('the restriction check runs after the colony row is locked, inside the transaction', ...);
// apiary-restrictions.service.spec.ts
it('creates one row per apiary in one transaction and rolls all back when one apiary is unknown', ...);
it('rejects more than 50 apiaries and an endsOn before startsOn', ...);
it('ending a restriction is an update of endsOn, nothing is deleted', ...);
// apiary-restrictions-null-contract.spec.ts: endsOn/authority/reference/notes clearable, disease/startsOn notNull; UpdateColonyDto.restrictionOverrideReason clearable
// test/restricted-zone.e2e-spec.ts
it('update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE', ...);
it('an override with compliance:manage answers 200, writes audit and the history note', ...);
it('an override without compliance:manage answers 403 and an override reason of 5 characters answers 400', ...);
it('split inside the apiary and a harvest at a restricted apiary succeed', ...);
it('GET /public routes never contain restriction data', ...);
it('a restriction ending yesterday no longer blocks', ...);
it('READONLY may read restrictions but not create them (403)', ...);
```
- [ ] **Schritt 2:** `npx jest src/compliance src/apiary-restrictions src/colonies` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren**
- In `ColoniesService.create/update/move/merge`: `const { restrictionOverrideReason, ...data } = dto;` – nur `data` geht an Prisma (sonst „Unknown argument“; `UpdateColonyDto` ist per `PickType` gebaut und bekommt das Feld ausdrücklich). Berechtigung einmal pro Anfrage: `canOverride = (await resolver.getPermissions(userId)).has('compliance:manage')`. Prüfung **innerhalb** der Transaktion nach `lockColony` (bei `create` vor `tx.colony.create`), Tag aus `dbUtcDay(tx)`.
- `move`: Quell- und Zielstand; `update`: alter und neuer Stand nur bei Wechsel; `merge`: alle beteiligten Stände, nur wenn mehr als ein verschiedener Stand beteiligt ist; `split`: keine Prüfung.
- Override: `notes` der `ColonyApiaryHistory` = `Ausnahme Sperrbezirk: <Begründung>` (bei `update`/`move` vor ein vorhandenes `notes` gestellt), Audit `afterData` bekommt `restrictionOverride: { reason, restrictionIds }`.
- `ApiaryRestrictionsModule` injiziert selbst keinen Resolver und wird nicht in `WorkerModule` benötigt.
- Controller: `GET /apiary-restrictions` und `GET /apiaries/:id/restrictions` in `ApiaryRestrictionsController` mit leerem `@Controller()`-Präfix und vollen Pfaden; beide `@RequirePermission('apiaries:read')`.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/restricted-zone.e2e-spec.ts test/colonies.e2e-spec.ts test/apiaries.e2e-spec.ts test/public.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 9: Behördenpaket I – Einstellungen, Bestandsbild, Anzeigen (API)
**Files**
- Create: `src/compliance/compliance.module.ts`, `compliance-settings.service.ts` (+ Spec), `compliance-settings.controller.ts`, `tsk-day.ts` (+ Spec), `herd-snapshot.ts` (+ `herd-snapshot.spec.ts`), `herd-status.ts` (+ Spec), `authority-notices.service.ts` (+ Spec), `authority-notices.controller.ts`, `dto/update-compliance-settings.dto.ts`, `dto/create-authority-notice.dto.ts`, `dto/update-authority-notice.dto.ts`, `dto/mark-sent.dto.ts`, `dto/notice-list-query.dto.ts`, `src/compliance/compliance-null-contract.spec.ts`
- Modify: `src/app.module.ts`
- Test: neu `test/authority-notices.e2e-spec.ts`
- Docs: Annahme „Behördenpaket und Sperrbezirk“ (Bezugsstand, Abweichung, Lebenszyklus, Mutex, `totalColonies` ist die Zahl der Anzeige und bleibt korrigierbar).
**Interfaces**
- Consumes: Task 1, `LIVING_COLONY_WHERE`, `utcDay`.
- Produces:
```ts
// tsk-day.ts
export function isValidMonthDay(value: string): boolean; // ^\d{2}-\d{2}$, Monat 1-12, Tag im Monat eines Nicht-Schaltjahrs (02-29 abgelehnt)
export function tskReferenceDayOf(year: number, monthDay: string): string; // YYYY-MM-DD
// herd-snapshot.ts
export interface HerdSnapshot { version: 1; takenAt: string; apiaries: { apiaryId: string; name: string; address: string | null; location: string | null;
latitude: number | null; longitude: number | null; livingColonies: number; byType: Partial<Record<ColonyType, number>> }[]; totalLiving: number }
export function buildHerdSnapshot(rows: readonly HerdApiaryRow[], takenAt: Date, excludedTypes?: readonly ColonyType[]): HerdSnapshot; // rein
export function loadHerdApiaryRows(db: DbClient): Promise<HerdApiaryRow[]>; // aktive Stände + lebende Völker (LIVING_COLONY_WHERE)
export interface HerdDeviation { triggered: boolean; colonyCountChanged: boolean; apiariesAdded: string[]; apiariesRemoved: string[]; apiariesRelocated: string[] }
export function herdDeviation(baseline: { snapshot: HerdSnapshot; totalColonies: number }, current: HerdSnapshot,
settings: Pick<ComplianceSettings, 'noticeOnColonyCountChange' | 'noticeOnApiaryChange'>): HerdDeviation; // Zahl: baseline.totalColonies vs current.totalLiving
// herd-status.ts
export interface HerdStatus { settings: ComplianceSettings; baseline: AuthorityNotice | null; current: HerdSnapshot; deviation: HerdDeviation | null } // deviation null ohne Bezugsstand
export function herdStatusIn(db: DbClient, now: Date): Promise<HerdStatus>;
// ComplianceSettingsService
get(): Promise<ComplianceSettings>; // Find-or-create wie SiteSettings
update(dto: UpdateComplianceSettingsDto, userId: string): Promise<ComplianceSettings>; // unter Sperre; Stichtag/Frist über isValidMonthDay, tskExcludedColonyTypes eindeutig,
// netQuantityDigitRules: 1-10 Regeln, maxGrams aufsteigend, letzte null, minMm 0,5-20
// AuthorityNoticesService
create(dto: CreateAuthorityNoticeDto, userId: string): Promise<AuthorityNotice>;
createDraftIn(tx: Prisma.TransactionClient, input: { kind: AuthorityNoticeKind; year?: number; referenceDate: Date }, userId: string | null): Promise<AuthorityNotice>; // gemeinsamer Kern, Task 11 nutzt ihn
recount(id: string, userId: string): Promise<AuthorityNotice>; markSent(id: string, dto: MarkSentDto, userId: string): Promise<AuthorityNotice>;
reopen(id: string, userId: string): Promise<AuthorityNotice>; cancel(id: string, userId: string): Promise<AuthorityNotice>;
update(id: string, dto: UpdateAuthorityNoticeDto, userId: string): Promise<AuthorityNotice>;
findAll(q: { kind?: AuthorityNoticeKind; year?: number }): Promise<AuthorityNotice[]>; findOne(id: string): Promise<AuthorityNotice>;
// Endpunkte nach Spec 7; POST /authority-notices @Idempotent; POST recount | mark-sent | reopen | cancel
```
- [ ] **Schritt 1: Failing Tests**
```ts
// tsk-day.spec.ts
it.each(['02-30', '02-29', '1-1', '13-01', '00-10', 'ab-cd'])('rejects %s', ...);
it('accepts 01-01 and 12-31', ...);
// herd-snapshot.spec.ts
it('no deviation without changes', ...);
it('count change triggers only when enabled', ...);
it('moving between apiaries keeps the total and triggers nothing', ...);
it('new, removed or relocated apiary triggers when enabled', ...);
it('compares the count with the corrected totalColonies of the baseline', ...);
it('excluded colony types do not count', ...);
it('counts only living colonies at active apiaries', ...);
// herd-status.spec.ts
it('no baseline means deviation null, not a herd change', ...);
it('the baseline is the newest SENT registration or herd change by sentOn and ignores TSK reports', ...);
// authority-notices.service.spec.ts
it('one open draft per kind and year', ...);
it('create with sentOn is SENT at once and has no openKey', ...);
it('recount only in DRAFT', ...);
it('herd change needs a baseline and registration needs none', ...);
it('TSK_REPORT needs a year', ...);
it('mark-sent on CANCELLED and recount on SENT answer 409', ...);
it('reopen answers 409 while another draft of the kind exists', ...);
it('every transition locks the notice row and writes an audit entry', ...);
// compliance-settings.service.spec.ts
it('creates defaults on first read', ...);
it('rejects 02-29 as reference day with 400', ...);
it('rejects digit rules that are not ascending or whose last rule has a limit', ...);
// compliance-null-contract.spec.ts: veterinaryOfficeName/Address, registrationNumber, tskReportDueDay, tskMemberNumber clearable; Booleans und tskReferenceDay notNull
// test/authority-notices.e2e-spec.ts
it('two parallel creates of the same draft: one 201, one 409 with existingId', ...);
it('a repeated POST with the same Idempotency-Key returns the stored answer', ...);
it('BEEKEEPER manages notices, STAFF may read but gets 403 on POST', ...);
it('a SENT registration with sentOn in the past is the baseline of the next herd change', ...);
```
- [ ] **Schritt 2:** `npx jest src/compliance` und `test/authority-notices.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren**
- `create`: Transaktion, Einstellungszeile per Upsert sicherstellen, `lockRowForUpdate(tx, 'ComplianceSettings', 'singleton')` als Mutex, Bezugsstand lesen, Regeln nach Global Constraints prüfen, Bestandsbild zählen (`loadHerdApiaryRows(tx)` → `buildHerdSnapshot`), `openKey` nur bei `DRAFT`; Konflikt (vorhandener Entwurf oder `P2002` auf `openKey`) → 409 `DRAFT_EXISTS` mit `existingId`. `referenceDate` Vorgabe: heutiger UTC-Tag der DB-Uhr.
- Zustandswechsel: `lockRowForUpdate(tx, 'AuthorityNotice', id)`, `before` danach lesen, Übergang prüfen (409 mit deutscher Meldung), schreiben, Audit `UPDATE` mit `beforeData`/`afterData`. `reopen` setzt `openKey` neu und prüft den Mutex; `cancel` und `mark-sent` setzen `openKey = null`.
- `markSent` verlangt `sentOn` (Vorgabe: heutiger UTC-Tag); `PATCH` ändert `totalColonies`, `referenceDate`, `sentOn`, `sentVia`, `reference`, `notes` (nicht `kind`/`status`); auf `CANCELLED` 409.
- `ComplianceModule` exportiert die Services; **kein** Provider mit `PermissionResolverService`.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/authority-notices.e2e-spec.ts test/authorization-coverage.e2e-spec.ts test/backup.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 10: Behördenpaket II – PDFs der Anzeigen und des Zählnachweises
**Files**
- Create: `src/compliance/pdf/authority-notice-model.ts` (+ Spec), `src/compliance/pdf/authority-notice-pdf.ts` (+ Spec)
- Modify: `src/compliance/authority-notices.controller.ts` (`GET :id/pdf`), `src/compliance/authority-notices.service.ts` (`pdf`)
- Test: `test/authority-notices.e2e-spec.ts` (ergänzen)
**Interfaces**
- Consumes: Task 9 (`AuthorityNotice`, `HerdSnapshot`, Services), `pdf-table.ts`.
- Produces:
```ts
export interface NoticePdfModel {
kind: AuthorityNoticeKind; title: string; holder: string | null; registrationNumber: string | null; tskMemberNumber: string | null;
office: { name: string | null; address: string | null }; place: string | null; dateLine: string; referenceLine: string;
columns: string[]; rows: { name: string; location: string; cells: string[]; previous: number | null }[]; totals: { current: number; previous: number | null };
changes: string[]; countedNote: string | null; footer: string[];
}
export function buildNoticePdfModel(input: { notice: AuthorityNotice; baseline: AuthorityNotice | null; site: Pick<SiteSettings, 'holderName' | 'holderAddress'>;
settings: Pick<ComplianceSettings, 'veterinaryOfficeName' | 'veterinaryOfficeAddress' | 'registrationNumber' | 'tskMemberNumber'>; now: Date }): NoticePdfModel;
export function renderAuthorityNoticePdf(model: NoticePdfModel): Promise<Buffer>;
AuthorityNoticesService.pdf(id: string): Promise<{ buffer: Buffer; fileName: string }>; // fileName "anzeige-<id>.pdf"
```
- [ ] **Schritt 1: Failing Tests**
```ts
it('prints FEHLT for an empty veterinary office and an empty holder', ...);
it('the first notice has the heading "Anzeige der Bienenhaltung (§ 1a BienSeuchV)" and no "vorher" column', ...);
it('the change notice shows the previous count per apiary and the totals', ...);
it('lists new and abandoned apiaries under "Änderungen seit der Anzeige vom {sentOn}"', ...);
it('prints coordinates with five decimals only when there is no address', ...);
it('the TSK model has one column per counted colony type and a sum', ...);
it('the TSK model says "Gezählt am {countedAt}, nicht am Stichtag. Bitte prüfen." when counted after the reference day', ...);
it('the TSK footer says "Ersetzt nicht die Meldung an die Tierseuchenkasse."', ...);
it('renders a PDF buffer that starts with %PDF for each kind', ...);
// e2e
it('GET /authority-notices/:id/pdf answers application/pdf with Cache-Control no-store and the file name anzeige-<id>.pdf', ...);
it('the pdf route needs compliance:read', ...);
```
- [ ] **Schritt 2:** `npx jest src/compliance/pdf` und `test/authority-notices.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren**
- Modell (rein, testbar) und Renderer (pdfkit, A4 hoch, `PDF_MARGIN`, `drawTable`) getrennt; Layout nach Spec 8.1/8.2; fehlende Pflichtangaben `FEHLT`. Fußzeile: „Stand der Daten {updatedAt} · erstellt mit ApiCore“.
- Controller setzt `Content-Type: application/pdf`, `Cache-Control: no-store`, `Content-Disposition` nur mit der ID.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/authority-notices.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 11: Behördenpaket III – Handler, Meldung ohne Dublette, Regel der Tagesauswertung
**Files**
- Create: `src/compliance/compliance.handlers.ts` (+ `compliance.handlers.spec.ts`)
- Modify: `src/notifications/notifications.service.ts` (+ Spec; `recipientsFor`, `recordForPermissionOnce`), `src/jobs/daily-evaluation.rules.ts` (+ Spec), `src/compliance/compliance.module.ts` (Provider), `src/worker/worker.module.ts` (importiert `ComplianceModule`)
- Test: `test/authority-notices.e2e-spec.ts` (ergänzen), `test/daily-evaluation.e2e-spec.ts`
- Docs: ARCHITECTURE §10 Handler-Tabelle: `compliance.herd-change`, `compliance.tsk-reference-day`; Meldungen ohne Dublette.
**Interfaces**
- Consumes: Task 7 (Ereignisse), Task 9 (`herdStatusIn`, `createDraftIn`, `tskReferenceDayOf`), T4 (`recordForUsers`, Kategorie `SYSTEM`, `DAILY_EVALUATION_RULES`).
- Produces:
```ts
NotificationsService.recipientsFor(db: DbClient, permissions: readonly PermissionKey[]): Promise<string[]>; // die Abfrage aus recordForPermission, herausgezogen (keine zweite Kopie)
NotificationsService.recordForPermissionOnce(permission: PermissionKey | readonly PermissionKey[],
input: Omit<FanOutNotificationInput, 'dedupeKeyPrefix'> & { relatedType: string; relatedId: string }, tx: Prisma.TransactionClient): Promise<Notification[]>;
// pg_advisory_xact_lock(hashtextextended('notify-once:' || relatedType || ':' || relatedId, 0)), dann Empfänger ohne ungelesene Zeile derselben relatedType/relatedId, dann recordForUsers ohne dedupeKey
// compliance.handlers.ts
@OnDomainEvent(['ColonyCreated','ColonyMoved','ColonyEnded','ColonyStatusChanged','ApiaryChanged'], 'compliance.herd-change')
herdChange(event: AnyDomainEventEnvelope<…>, tx: Prisma.TransactionClient): Promise<void>; // ColonyStatusChanged nur beim Wechsel lebend <-> beendet
@OnDomainEvent('TskReferenceDayReached', 'compliance.tsk-reference-day')
tskReferenceDay(event: AnyDomainEventEnvelope<'TskReferenceDayReached'>, tx: Prisma.TransactionClient): Promise<void>;
// daily-evaluation.rules.ts: DailyEventType += 'TskReferenceDayReached'; neue Regel in DAILY_EVALUATION_RULES
export const tskReferenceDayRule: DailyRule<'TskReferenceDayReached'>; // Jahr und Vorjahr, Tag = tskReferenceDayOf(Jahr, settings.tskReferenceDay), Vorgabe 01-01 ohne Einstellungszeile
```
- [ ] **Schritt 1: Failing Tests**
```ts
// compliance.handlers.spec.ts
it('herd-change notifies each permitted user once while unread', ...);
it('ignores a status change within living states and one within ended states', ...);
it('does nothing without a baseline or without deviation', ...);
it('the message uses the exact wording with counts and never an address', ...);
it('tsk handler creates one draft and skips a year already sent', ...);
it('tsk handler with an existing draft changes nothing', ...);
it('tsk handler notifies with WARNUNG and the exact wording', ...);
it('a second handler run for the same event adds no second draft or notification', ...);
// notifications.service.spec.ts
it('recordForPermissionOnce skips users with an unread notification of the same topic', ...);
it('recordForPermissionOnce writes again after the user read the earlier one', ...);
it('recordForPermissionOnce takes the advisory lock before reading the unread rows', ...);
it('recordForPermission still works through recipientsFor', ...);
// daily-evaluation.rules.spec.ts
it('emits TskReferenceDayReached on the configured day', ...);
it('catches up within 7 days exactly once', ...);
it('covers the previous year\'s day in early January', ...);
it('uses 01-01 when no settings row exists', ...);
// handler-contract.spec.ts läuft unverändert und deckt die neue Datei ab
// e2e
it('a new colony changes the herd: after draining one unread notification per permitted user', ...); // Messkriterium 3
it('a second colony within one unread notice adds no second notification', ...);
it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', ...);
it('daily evaluation twice on the reference day: one event, one draft', ...);
it('a user without compliance:manage gets no herd notification', ...);
```
- [ ] **Schritt 2:** `npx jest src/compliance src/notifications src/jobs src/domain-events` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren**
- Meldungen: `category: 'SYSTEM'` (T4 hat keinen Wert für Behörden; die Telegram-Kategorienliste bleibt unverändert), `relatedType: 'ComplianceItem'`, `relatedId: 'herd-change'` bzw. `tsk-report:<Jahr>`, `link: '/compliance'` bzw. `/compliance/notices/<id>`, Empfänger `compliance:manage`. T4 vergibt `dedupeKey` dauerhaft eindeutig; hier bewusst **keiner**, die Dublette steuert nur „ungelesen“.
- Handler lesen nur über `tx`; `herdStatusIn(tx, now)` mit `now` aus `SELECT now()` über `tx`. `herdChange` legt nie eine Anzeige an.
- `tskReferenceDay`: `lockRowForUpdate(tx, 'ComplianceSettings', 'singleton')`; gibt es für das Jahr `SENT` oder einen offenen Entwurf, kehrt der Handler zurück; sonst `createDraftIn(tx, { kind: 'TSK_REPORT', year, referenceDate }, null)` und Meldung.
- Regel: je Jahr eine `DailyOccurrence` mit `aggregateType: 'Compliance'`, `aggregateId: 'tsk:<Jahr>'`, `payload: { year, referenceDay: <Tag> }`; Schlüssel über `catchUpEvents` → `dailyDedupeKey`.
- `WorkerModule` importiert `ComplianceModule`; `AppModule` hat es seit Task 9.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/authority-notices.e2e-spec.ts test/daily-evaluation.e2e-spec.ts test/notifications.e2e-spec.ts test/domain-event-dispatcher.e2e-spec.ts test/worker-runtime.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 12: Prüfbereitschaft I – Gerüst, Quittungen, Punkte Stammdaten und Behörden
**Files**
- Create: `src/compliance/readiness/readiness.types.ts`, `fingerprint.ts` (+ Spec), `readiness-context.ts`, `readiness.service.ts` (+ Spec), `readiness.controller.ts`, `readiness.module.ts`, `acknowledgements.service.ts` (+ Spec), `dto/create-acknowledgement.dto.ts`, `rules/index.ts`, `rules/holder-data.rule.ts`, `food-business-data.rule.ts`, `vet-office.rule.ts`, `registration.rule.ts`, `herd-change.rule.ts`, `tsk-report.rule.ts`, `restriction-active.rule.ts` (je + `.rule.spec.ts`)
- Modify: `src/app.module.ts` (Eintrag `ReadinessModule`)
- Test: neu `test/compliance-readiness.e2e-spec.ts`
- Docs: neue Annahme „Prüfbereitschaft“ (live berechnet, Quittung gilt nur bei passendem Fingerabdruck, jeder offene Punkt rot).
**Interfaces**
- Consumes: Task 8 (Sperren), Task 9 (`herdStatusIn`, Anzeigen, Einstellungen), Task 11 (`ComplianceModule` komplett).
- Produces:
```ts
export type ReadinessSeverity = 'PFLICHT' | 'PRUEFEN';
export type ReadinessArea = 'STAMMDATEN' | 'BEHOERDEN' | 'TIERARZNEIMITTEL' | 'ETIKETTEN' | 'ERNTE';
export interface ReadinessSubject { type: string; id: string; label: string; missing?: string[]; parentId?: string } // parentId: z. B. colonyId einer Behandlung (Link-Ziel; Erweiterung der Spec)
export interface ReadinessFinding { subjectId: string; title?: string; detail: string; fingerprintParts: unknown; subjects: ReadinessSubject[]; more?: number }
export interface ReadinessRule { readonly key: string; readonly area: ReadinessArea; readonly severity: ReadinessSeverity; readonly title: string;
readonly legalBasis: string | null; readonly acknowledgeable: boolean; readonly subjectPermission?: PermissionKey; evaluate(ctx: ReadinessContext): ReadinessFinding[] }
export interface ReadinessItem { key: string; area: ReadinessArea; severity: ReadinessSeverity; subjectId: string; title: string; detail: string; legalBasis: string | null;
acknowledgeable: boolean; fingerprint: string; subjects: ReadinessSubject[]; more: number;
state: 'OPEN' | 'DONE' | 'ACKNOWLEDGED'; acknowledgement: { id: string; note: string; userName: string; createdAt: string } | null }
export interface ReadinessReport { status: 'READY' | 'OPEN'; generatedAt: string; openCount: number; items: ReadinessItem[] }
export interface ReadinessContext { now: Date; today: string; site: SiteSettings; settings: ComplianceSettings; herd: HerdStatus;
notices: AuthorityNotice[]; restrictions: (ApiaryRestriction & { apiary: { id: string; name: string } })[] } // Task 13 ergänzt weitere Felder
export function fingerprintOf(parts: unknown): string; // sha256 (hex) des kanonischen JSON (sortierte Schlüssel)
ReadinessService.evaluate(now: Date, permissions: ReadonlySet<string>): Promise<ReadinessReport>;
ReadinessService.summary(now: Date): Promise<{ status: 'READY' | 'OPEN'; openCount: number; firstItemKey: string | null }>;
AcknowledgementsService.create(dto: { itemKey: string; subjectId: string; fingerprint: string; note: string }, userId: string): Promise<ReadinessItem>;
AcknowledgementsService.revoke(id: string, userId: string): Promise<void>;
// GET /compliance/readiness, GET /compliance/readiness/summary (compliance:read); POST /compliance/acknowledgements (@Idempotent), POST …/:id/revoke (compliance:manage)
export const READINESS_RULES: readonly ReadinessRule[]; // rules/index.ts; Task 13 hängt die übrigen elf an
```
- [ ] **Schritt 1: Failing Tests**
```ts
// readiness.service.spec.ts
it('is READY when no rule has an open finding', ...);
it('a rule without findings yields one DONE item with subjectId global', ...);
it('overall status is OPEN as long as one item is OPEN, an ACKNOWLEDGED item does not count', ...);
it('limits subjects to 50 and reports the rest in more', ...);
it('hides subject labels without the rule subjectPermission and keeps the count', ...);
it('summary names the first open PFLICHT item, else the first open item', ...);
// fingerprint.spec.ts
it('is stable for the same parts regardless of key order and differs when a value changes', ...);
// acknowledgements.service.spec.ts
it('refuses a stale fingerprint with 409', ...);
it('refuses a PFLICHT item and an unknown item key with 400', ...);
it('a note under 10 characters is a 400', ...);
it('a revoked acknowledgement reopens the item', ...);
it('a new fingerprint after changed data reopens an acknowledged item', ...);
it('revoke locks the acknowledgement row and refuses an already revoked one', ...);
// rules/*.rule.spec.ts – je Regel: offen, erledigt (und quittiert, wo acknowledgeable)
// holder-data: open when name or address is empty | food-business-data: open when either is empty | vet-office: PRUEFEN, open without office name
// registration: open without a SENT REGISTRATION | herd-change: open when the deviation is triggered, not without a baseline
// tsk-report: open from the reference day without a SENT report; "überfällig" after tskReportDueDay; a DRAFT of last year keeps last year's item open
// restriction-active: one finding listing the apiaries with an active restriction
// test/compliance-readiness.e2e-spec.ts
it('an empty system reports the Stammdaten and registration items open and status OPEN', ...);
it('complete master data and a SENT registration make those items DONE', ...);
it('READONLY may read the report but gets 403 acknowledging; STAFF gets 403 on acknowledgements; API_CLIENT gets 403 everywhere', ...);
```
- [ ] **Schritt 2:** `npx jest src/compliance/readiness` und `test/compliance-readiness.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren**
- `evaluate`: Kontext einmal laden (begrenzt, nur benötigte Spalten), jede Regel ausführen, Findings zu Items (`fingerprint = fingerprintOf({ key, subjectId, parts })`), gültige Quittung (gleicher `itemKey`, `subjectId`, `fingerprint`, `revokedAt` null; neueste gewinnt) → `ACKNOWLEDGED`. Regeln ohne Findings → ein `DONE`-Item mit dem Regeltitel.
- Maskierung: Hat der Aufrufer `rule.subjectPermission` nicht, bleiben `subjects` leer und `more` = Anzahl; Titel und Detailtexte der Regeln enthalten keine Namen.
- Quittieren: Item aus der aktuellen Auswertung nehmen (`key` + `subjectId`), `acknowledgeable` prüfen, Fingerabdruck vergleichen, in einer Transaktion schreiben mit Audit `CREATE` (`entityType: 'ComplianceAcknowledgement'`). `revoke`: Sperre auf der Quittung, `revokedAt = now()` (SQL), `revokedByUserId`, Audit.
- Detailtexte deutsch, ein kurzer Satz; `legalBasis` aus der Spec-Tabelle. `ReadinessModule` importiert `ComplianceModule`.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/compliance-readiness.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
### Task 13: Prüfbereitschaft II – Punkte Tierarzneimittel, Etiketten, Ernte, Prüfbericht
**Files**
- Create: `src/compliance/readiness/rules/treatment-log-gaps.rule.ts`, `treatment-location-uncertain.rule.ts`, `lot-gaps.rule.ts`, `lot-prescription.rule.ts`, `medicine-withdrawal-missing.rule.ts`, `lot-expired-stock.rule.ts`, `lot-negative.rule.ts`, `treatment-expired-lot.rule.ts`, `label-template-gaps.rule.ts`, `label-template-form.rule.ts`, `harvest-withdrawal.rule.ts` (je + `.rule.spec.ts`), `src/compliance/readiness/readiness-report.ts` (+ Spec), `src/compliance/readiness/loaders/treatments.loader.ts`, `lots.loader.ts`, `labels.loader.ts`, `harvests.loader.ts` (je + Spec)
- Modify: `src/compliance/readiness/readiness-context.ts`, `rules/index.ts`, `readiness.controller.ts` (`report.pdf`), `readiness.module.ts`
- Test: `test/compliance-readiness.e2e-spec.ts` (ergänzen); `test/export.e2e-spec.ts` (Lücken-Helfer und PDF stimmen überein)
**Interfaces**
- Consumes: Task 3 (`resolveSourceValues`, `treatmentLogGaps`, `groupTreatmentLog`), Task 5/6 (`checkLabel`, `label-check.loader`), Task 2 (`summarizeLot`), T4 (`withdrawalWindowOf`, `isInWindow`), `summarizeStock`, Task 12.
- Produces:
```ts
// ReadinessContext += treatments (letzte 6 Kalenderjahre, nur benötigte Spalten), moves, lots (mit Rest), medicines (aktive), templates (aktive) mit Prüfziel, harvests (letzte 2 Jahre) mit Wartezeit-Fenstern
export const HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2;
export const TREATMENT_LOOKBACK_CALENDAR_YEARS = 6; // laufendes Jahr + 5
// Schlüssel – Bereich – Schwere – quittierbar – subjectPermission:
// treatment-log-gaps TIERARZNEIMITTEL PFLICHT nein 'treatments:read' (subjectId = Jahr) | treatment-location-uncertain PRUEFEN nein 'treatments:read'
// lot-gaps PFLICHT nein 'veterinary-medicines:read' | lot-prescription PFLICHT nein | medicine-withdrawal-missing PRUEFEN nein
// lot-expired-stock PRUEFEN ja (subjectId = Packung) | lot-negative PRUEFEN nein | treatment-expired-lot PRUEFEN ja (subjectId = Behandlung)
// label-template-gaps ETIKETTEN PFLICHT nein (subjectId = Vorlage) | label-template-form PRUEFEN ja (subjectId = Vorlage)
// harvest-withdrawal ERNTE PRUEFEN ja 'treatments:read' (subjectId = Ernte)
export function renderReadinessReportPdf(report: ReadinessReport, holder: { name: string | null; address: string | null }): Promise<Buffer>;
// GET /compliance/readiness/report.pdf (compliance:read), Datei pruefbericht.pdf, no-store
```
- [ ] **Schritt 1: Failing Tests**
```ts
// rules/treatment-log-gaps.rule.spec.ts
it('opens one item per year with gap rows', ...);
it('reports a lot with missing supplier once as a lot subject, not once per treatment', ...);
it('reports treatments without a lot as treatment subjects with the missing fields and the colony as parentId', ...);
it('the gap codes equal the codes treatmentLogGaps reports for the same row', ...); // Übereinstimmung mit dem PDF
it('requires the vet only for prescription-only medicines', ...);
it('looks back over 6 calendar years and not further', ...);
// weitere Regeln je offen | erledigt | quittiert
// treatment-location-uncertain: row flagged with * | lot-gaps: lot without supplier or proof | lot-prescription: prescription-only medicine, lot without vet
// medicine-withdrawal-missing: active medicine without withdrawal | lot-expired-stock: expired with rest > 0, acknowledgeable per lot, new fingerprint when the rest changes
// lot-negative: rest < 0, not acknowledgeable | treatment-expired-lot: treatment date after expiry, acknowledgeable per treatment
// label-template-gaps: uses the newest bottling with remaining stock, else the newest, else the sample
// label-template-form: warnings only, acknowledgeable, new fingerprint when the warning codes change
// harvest-withdrawal: harvest inside an active window of a participating colony; harvest after the window is clean; window logic equals withdrawalWindowOf
// readiness.service.spec.ts
it('hides subject labels of the medicine area without veterinary-medicines:read and keeps the count', ...);
// readiness-report.spec.ts
it('lists status, areas, items with severity word, legal basis and acknowledgement note, user and date', ...);
it('prints FEHLT for an empty holder', ...);
// e2e
it('complete data in every area makes GET /compliance/readiness free of open items with the status READY', ...); // Messkriterium 4
it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', ...); // Messkriterium 1
it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', ...);
it('a negative rest after a treatment from a too small lot opens lot-negative', ...);
```
- [ ] **Schritt 2:** `npx jest src/compliance/readiness` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren**
- `treatment-log-gaps`: dieselbe Funktionskette wie das PDF (`resolveSourceValues` → `groupTreatmentLog` → `treatmentLogGaps`); je Quelle ohne Packung Subjekt `treatment` (`parentId` = `colonyId`), mit Packung und nur Packungsfeldern Subjekt `lot`. `missing` nennt deutsche Feldnamen („Lieferant“, „Beleg“, „Menge“, „Wartezeit“, „Tierarzt“).
- Lader: Behandlungen mit `select` der benötigten Spalten und `date >= 1. Januar (Jahr − 5)`; Packungen mit einer Gruppen-Abfrage der Bewegungssummen; Ernten ab `heute − 2 Jahre`; Vorlagen mit der Zielabfüllung (jüngste mit Restbestand über `summarizeStock` der `BatchMovement`, sonst jüngste, sonst `SAMPLE_BOTTLING`).
- `label-template-*`: `checkLabel` mit den Textelementen der gespeicherten Vorlage (Bild-, Linien- und QR-Elemente bleiben außen vor).
- Bericht: A4 hoch, Kopf mit Halter und Datum, Gesamtstatus, je Bereich eine Tabelle (Status „Offen“/„Erledigt“/„Quittiert“, Schwere, Titel, Norm, bei Quittungen Notiz, Nutzer, Datum).
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/compliance-readiness.e2e-spec.ts test/export.e2e-spec.ts test/label-compliance.e2e-spec.ts --runInBand` → PASS
- kein Commit
---
## Lieferung T6b – Frontend
Gemeinsam für alle Frontend-Tasks: Vor Next.js-Code `node_modules/next/dist/docs/` lesen. Die Typen spiegeln die Backend-Interfaces der Tasks 2–13 (Namen gleich). Server-Fehler `409`/`403` zeigen die deutsche Meldung des Servers. Offline gilt die Behandlungs-Warteschlange aus T3; Packungsbuchungen entstehen erst beim Synchronisieren auf dem Server.
### Task 14: Prüfbereitschaft – Typen, Seite, Quittieren, Navigation, Dashboard-Zeile
**Files**
- Create: `src/lib/compliance/types.ts`, `src/lib/compliance/api.ts` (+ Spec), `src/lib/compliance/fix-links.ts` (+ Spec), `src/lib/compliance/use-readiness.ts`, `src/app/(app)/compliance/page.tsx` (+ `page.spec.tsx`), `src/app/(app)/compliance/readiness-item-card.tsx` (+ Spec), `src/app/(app)/compliance/acknowledge-dialog.tsx` (+ Spec), `src/components/today/compliance-line.tsx` (+ Spec)
- Modify: `src/app/(app)/nav-config.ts` (+ `nav-config.spec.ts`), `src/app/(app)/dashboard/page.tsx` (die Zeile)
**Interfaces**
- Consumes: Task 12/13 (`ReadinessReport`, `ReadinessItem`, `ReadinessSubject`), `DetailHeader`, `FormSection`, `ConfirmDialog`.
- Produces:
```ts
export type { ReadinessReport, ReadinessItem, ReadinessSubject, ReadinessSeverity, ReadinessArea }; // types.ts
export function fetchReadiness(): Promise<ReadinessReport>; export function fetchReadinessSummary(): Promise<{ status: 'READY' | 'OPEN'; openCount: number; firstItemKey: string | null }>;
export function acknowledge(input: { itemKey: string; subjectId: string; fingerprint: string; note: string }): Promise<void>; export function revokeAcknowledgement(id: string): Promise<void>;
export function fixLinkFor(itemKey: string, subject: { type: string; id: string; parentId?: string } | null): { href: string; label: string }; // die EINE Stelle, die Subjekte auf Seiten abbildet
// Zuordnung: lot -> /medicines/lots/{id}; treatment -> /colonies/{parentId}?editTreatment={id}; template -> /settings/label-templates/{id}; harvest -> /harvests/{id};
// notice -> /compliance/notices/{id}; apiary/restriction -> /compliance/restrictions; global je itemKey: holder-data, food-business-data -> Stammdaten & Web (Verwaltung > Automatisierung);
// vet-office -> /compliance/settings; registration, herd-change, tsk-report -> /compliance/notices; medicine-withdrawal-missing -> /medicines
```
- Nav: Eintrag „Prüfbereitschaft“ (Symbol `ShieldCheck`, Gruppe „Betrieb“, Recht `compliance:read`).
- [ ] **Schritt 1: Failing Tests**
```ts
// page.spec.tsx
it('shows Prüfbereit in green when nothing is open and offers Bestandsbuch drucken as the main action', ...);
it('shows "3 offen" in red with the symbol and the word Offen for every open item', ...);
it('the main action jumps to the first open PFLICHT item', ...);
it('shows the severity as a word, Pflicht or Prüfen, next to the title', ...);
it('lists affected records collapsed with a Beheben link each', ...);
it('shows Erledigt (n) collapsed', ...);
it('shows the more-count when subjects are cut off', ...);
// acknowledge-dialog.spec.tsx
it('asks for a note of at least 10 characters before it submits', ...);
it('offers Quittieren only for acknowledgeable items and Zurücknehmen for acknowledged ones', ...);
it('shows the server message on a 409 stale fingerprint and reloads the report', ...);
// fix-links.spec.ts
it.each([...])('maps %s to %s', ...); // jede Zeile der Zuordnung oben
// compliance-line.spec.tsx
it('renders only when openCount > 0 and the user holds compliance:read', ...);
// nav-config.spec.ts
it('shows Prüfbereitschaft only with compliance:read', ...);
```
- [ ] **Schritt 2:** `npx vitest run src/lib/compliance "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"` → FAIL.
- [ ] **Schritt 3: Implementieren**
- Seite `/compliance` nach Spec 9: Status oben, Punkte nach Bereich (fünf Abschnitte), offene Punkte rot mit Symbol + „Offen“ + Schwere-Chip + Titel + eine Zeile Detail + Norm klein; „Mehr“-Menü mit Links auf Bericht (PDF, authentifizierter Download über den vorhandenen Export-Helfer), Bestandsbuch, `/compliance/notices`, `/compliance/restrictions`, `/compliance/settings`.
- Hauptaktion „Nächsten Punkt lösen“ springt auf das `fixLinkFor` des ersten offenen `PFLICHT`-Punkts; alles grün: „Bestandsbuch {Jahr} drucken“.
- Links auf Seiten, die spätere Tasks liefern, sind einfache `Link`s; die Seiten entstehen in den Tasks 15, 18, 19.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- `npm run lint && npm run typecheck && npx vitest run src/lib/compliance "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"` → PASS
- Browser: Desktop und 390 px, Leerzustand „Prüfbereit“, Tastatur (Tab-Reihenfolge, sichtbarer Fokus), `prefers-reduced-motion`
- kein Commit
---
### Task 15: Tierarzneimittel-Katalog, Packungen, Bewegungsbuch, Lager-Karte
**Files**
- Create: `src/lib/medicines/types.ts`, `src/lib/medicines/api.ts` (+ Spec), `src/app/(app)/medicines/page.tsx` (+ Spec), `src/app/(app)/medicines/medicine-form-dialog.tsx` (+ Spec), `src/app/(app)/medicines/[id]/page.tsx`, `src/app/(app)/medicines/[id]/lot-form-dialog.tsx`, `src/app/(app)/medicines/lots/[id]/page.tsx`, `src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx`, `src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx` (je + Spec)
- Modify: `src/app/(app)/storage/page.tsx` (Karte „Tierarzneimittel“ mit Link auf `/medicines`)
**Interfaces**
- Consumes: Task 2 (`MedicineView`, `LotView`, `LotDetailView`, `MedicineSuggestion`), `DocumentUploadCard` (Dokumentart `MEDICINE_LOT`), `ChoiceGroup`, `NumberStepper`.
- Produces:
```ts
export function fetchMedicines(active?: boolean): Promise<MedicineView[]>; export function fetchMedicineSuggestions(): Promise<MedicineSuggestion[]>;
export function fetchMedicine(id: string): Promise<MedicineView & { lots: LotView[] }>; export function createMedicine(input: MedicineInput): Promise<MedicineView>;
export function updateMedicine(id: string, input: Partial<MedicineInput>): Promise<MedicineView>; export function setMedicineActive(id: string, active: boolean): Promise<MedicineView>;
export function fetchSelectableLots(medicineId: string): Promise<LotView[]>; // Task 16 nutzt sie
export function fetchLot(id: string): Promise<LotDetailView>; export function createLot(input: LotInput): Promise<LotView>; export function updateLot(id: string, input: Partial<LotInput>): Promise<LotView>;
export function bookCorrection(lotId: string, actualRemaining: number): Promise<LotDetailView>; export function bookDisposal(lotId: string, quantity: number, reason: string): Promise<LotDetailView>;
```
- [ ] **Schritt 1: Failing Tests**
```ts
it('the empty catalogue offers "Aus deinen Behandlungen übernehmen?" with the suggestions', ...);
it('applying a suggestion opens the form prefilled with the values of the latest treatment', ...);
it('lists name, rest and next expiry per medicine', ...);
it('hides the unit field once lots exist and shows the server message "Einheit ist festgelegt, sobald Packungen erfasst sind"', ...);
it('the lot page shows the rest, the movement book and the receipt upload card', ...);
it('Inventur asks for the actual rest and shows the delta before saving', ...);
it('Entsorgung needs a reason', ...);
it('a negative rest is shown in red with the word Negativ', ...);
it('the main action is Mittel anlegen on the list, Packung erfassen on a medicine and Inventur on a lot', ...);
it('hides create buttons without veterinary-medicines:create', ...);
it('the storage page shows the Tierarzneimittel card only with veterinary-medicines:read', ...);
```
- [ ] **Schritt 2:** `npx vitest run src/lib/medicines "src/app/(app)/medicines" "src/app/(app)/storage"` → FAIL.
- [ ] **Schritt 3: Implementieren**
- Seiten nach Spec 9 (Zeile `/medicines…`); Felder 44 px, seltene Felder (Temperaturfenster, Dosis-Hinweis, Notizen) in `FormSection` eingeklappt; Beleg-Upload über `DocumentUploadCard` mit Dokumentart `MEDICINE_LOT`. Kein eigener Nav-Eintrag.
- Anlege-Anfragen tragen einen `Idempotency-Key` wie die anderen Formulare.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- `npm run lint && npm run typecheck && npx vitest run src/lib/medicines "src/app/(app)/medicines" "src/app/(app)/storage"` → PASS
- Browser: Desktop und 390 px, Leer- und Fehlerzustand, Tastatur
- kein Commit
---
### Task 16: Behandlungsformular und Sammelerfassung mit Mittel, Packung und Dosis
**Files**
- Create: `src/components/treatments/medicine-lot-fields.tsx` (+ Spec), `src/lib/treatments/booking-line.ts` (+ Spec), `src/lib/treatments/temperature-hint.ts` (+ Spec)
- Modify: `src/app/(app)/treatments/new/page.tsx` (+ Spec), `src/app/(app)/treatments/new/treatment-form-values.ts`, `src/lib/treatments/types.ts`, `src/lib/treatments/api.ts`, `src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx`, `src/app/(app)/colonies/[id]/page.tsx` (nur `?editTreatment=<id>` öffnet den Dialog)
**Interfaces**
- Consumes: Task 3 (DTO-Felder), Task 15 (`fetchMedicines`, `fetchSelectableLots`), T3 (`applyPrefill`, `PrefillNote`, `useCaptureMutation`, Sammelerfassung).
- Produces:
```ts
export function bookingLine(dose: number | null, unit: MedicineUnit | null, colonyCount: number): string | null; // "Bucht 30 ml je Volk ab (× 6 = 180 ml)."; null ohne Packung oder Dosis; deutsches Dezimalkomma
export function temperatureHint(today: { min: number | null; max: number | null }, window: { min: number | null; max: number | null }): { text: string; warn: boolean } | null; // "Heute 9–17 °C. Laut Katalog 12–25 °C."; warn, wenn außerhalb
// TreatmentFormValues += medicineId, medicineLotId, doseQuantity; Payload lässt supplier/purchaseProof/batchNumber/prescribingVet weg, sobald eine Packung gewählt ist (Server lehnt sie sonst mit 400 ab)
```
- [ ] **Schritt 1: Failing Tests**
```ts
it('offers the catalogue medicines and the last option "Anderes Mittel" with the free-text field as today', ...);
it('shows only "Anderes Mittel" without veterinary-medicines:read and raises no error', ...);
it('a catalogue medicine prefills ingredient, withdrawal, method and dose with the note "aus Katalog"', ...);
it('the catalogue prefill wins over the last-treatment prefill for the same field', ...);
it('preselects the oldest lot and says so in a note', ...);
it('shows lots as "Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027"', ...);
it('replaces supplier, proof and vet by one line "aus Packung: …" when a lot is chosen', ...);
it('shows the booking line above Speichern, multiplied by the number of colonies in bulk mode', ...);
it('warns before saving when the lot is expired or too small, but does not block', ...);
it('shows the temperature hint with a warning icon when today is outside the catalogue window', ...);
it('sends medicineId, medicineLotId and doseQuantity and omits the lot-sourced text fields', ...);
it('bulk mode sends one POST /treatments/bulk with the shared medicine fields', ...);
it('the colony page opens the edit dialog for ?editTreatment=<id>', ...);
// booking-line.spec.ts / temperature-hint.spec.ts: die Beispiele der Interfaces, null-Fälle, Grenzwerte inklusive
```
- [ ] **Schritt 2:** `npx vitest run src/components/treatments src/lib/treatments "src/app/(app)/treatments" "src/app/(app)/colonies"` → FAIL.
- [ ] **Schritt 3: Implementieren**
- Die Vorbelegung bleibt sichtbar im Formular (Server füllt nie still nach). Das Wetter für den Temperaturhinweis kommt aus `GET /apiaries/:id/weather` (`apiaries:read`); fehlt es, entfällt der Hinweis ohne Fehler.
- Dosis im `NumberStepper` mit Einheit des Mittels; Offline wie T3 (Warteschlange); Idempotency-Key unverändert.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- `npm run lint && npm run typecheck && npx vitest run src/components/treatments src/lib/treatments "src/app/(app)/treatments" "src/app/(app)/colonies"` → PASS
- Browser: Behandlung mit Packung (Desktop und 390 px), Sammelerfassung, Tastatur
- kein Commit
---
### Task 17: Etikett – Pflichtangaben-Panel, Druckkarte, Stammdaten, Produkt
**Files**
- Create: `src/lib/label-templates/check.ts` (+ Spec; Typen `LabelCheckResult`, Funktionen, Hook), `src/lib/label-templates/placeholder-keys.ts` (+ `placeholder-keys.spec.ts`), `src/lib/label-templates/compliance-block.ts` (+ Spec), `src/components/labels/label-compliance-panel.tsx` (+ Spec)
- Modify: `src/app/(app)/settings/label-templates/designer/label-designer.tsx` (+ Spec; nur Panel einhängen, Feld „Mindest-x-Höhe“, Knopf „Pflichtangaben-Block einfügen“), `src/app/(app)/bottling/[id]/print-labels-card.tsx` (+ Spec), `src/lib/label-templates/types.ts`, `src/lib/label-templates/api.ts`, `src/app/(app)/settings/automation/social-media-tab.tsx` (+ Spec; Felder Lebensmittelunternehmer und „Vom Bienenhalter übernehmen“), `src/app/(app)/products/*` (Formular: „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“, `legalName`), `src/lib/products/*` (Typ)
**Interfaces**
- Consumes: Task 5 (`LabelCheckResult`, `PLACEHOLDER_KEYS`, `ApiCore-Backend/test/fixtures/placeholder-keys.json`), Task 6 (Endpunkte).
- Produces:
```ts
export function checkDraftTemplate(input: { elements: LabelElement[]; widthMm: number; heightMm: number; minXHeightMm: number; bottlingBatchId?: string }): Promise<LabelCheckResult>; // filtert auf Text-Elemente
export function checkTemplateForBottling(templateId: string, bottlingBatchId: string): Promise<LabelCheckResult>;
export function useDebouncedLabelCheck(draft: DraftInput, delayMs?: number): { result: LabelCheckResult | null; pending: boolean; error: string | null }; // 500 ms
export function buildComplianceBlock(template: { widthMm: number; heightMm: number }, requirements: LabelCheckResult['requirements']): LabelTextElement[]; // alle neuen Platzhalter, Schriftgrößen aus requirements, keine Rechtswerte im Frontend
export function printLabels(templateId: string, bottlingBatchId: string, options: { startPosition?: number; proof?: boolean }): Promise<Blob>; // wirft ApiError mit body.gaps bei 409
```
- [ ] **Schritt 1: Failing Tests**
```ts
// placeholder-keys.spec.ts
it('the frontend list equals the backend fixture ApiCore-Backend/test/fixtures/placeholder-keys.json', ...); // Monorepo-Pfad, per fs gelesen
// label-compliance-panel.spec.tsx
it('shows each particular with a check mark or a cross and the word Erfüllt or Fehlt', ...);
it('lists warnings separately and announces changes via aria-live polite', ...);
it('shows "Beispielwerte" when the result is a sample', ...);
// compliance-block.spec.ts
it('inserts one text element per new placeholder, sized to meet the required x-height', ...);
it('uses the digit height requirement for the net quantity element when present', ...);
// label-designer.spec.tsx
it('debounces the check by 500 ms and sends text elements only', ...);
it('Pflichtangaben-Block einfügen adds the block and Rückgängig removes it again', ...);
it('the minimum x-height field accepts 0.5 to 5 and mentions 0,9 mm for small jars', ...);
// print-labels-card.spec.tsx
it('the main button is aria-disabled with the reason in aria-describedby while a particular is missing, and stays focusable', ...);
it('shows the red gap list with links to Stammdaten, Produkt and Designer', ...);
it('Probedruck (MUSTER) stays possible and requests proof=true', ...);
it('moves focus to the gap list after a 409 LABEL_PARTICULARS_MISSING', ...);
it('prints the PDF when the check has no gaps; warnings do not block', ...);
// social-media-tab.spec.tsx
it('Vom Bienenhalter übernehmen fills the fields visibly and saves only on Speichern', ...);
```
- [ ] **Schritt 2:** `npx vitest run src/lib/label-templates src/components/labels "src/app/(app)/settings" "src/app/(app)/bottling" "src/app/(app)/products"` → FAIL.
- [ ] **Schritt 3: Implementieren**
- `LabelCompliancePanel` ist eine eigene Datei (der Designer hat schon 695 Zeilen) und bekommt nur das Ergebnis; der Designer ruft den Hook mit den aktuellen Textelementen.
- Druckkarte: ruft vor dem Anzeigen `checkTemplateForBottling`; bei Lücken rote Liste mit Links (Stammdaten → Verwaltung > Automatisierung, Produkt → `/products/{id}`, Designer → `/settings/label-templates/{id}`); Hauptknopf „Etiketten als PDF“ mit `aria-disabled`, daneben „Probedruck (MUSTER)“.
- Stammdaten-Tab: Felder `foodBusinessName` (200) und `foodBusinessAddress` (300), Knopf übernimmt `holderName`/`holderAddress` in die Formularwerte, ohne zu speichern.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- `npm run lint && npm run typecheck && npx vitest run src/lib/label-templates src/components/labels "src/app/(app)/settings" "src/app/(app)/bottling" "src/app/(app)/products"` → PASS
- Browser: Designer, Druckkarte (Lücke und vollständig), Desktop und 390 px, Tastatur, `prefers-reduced-motion`
- kein Commit
---
### Task 18: Anzeigen und Einstellungen (Behörden)
**Files**
- Create: `src/lib/compliance/notices-api.ts` (+ Spec), `src/lib/compliance/settings-api.ts` (+ Spec), `src/app/(app)/compliance/notices/page.tsx`, `src/app/(app)/compliance/notices/[id]/page.tsx`, `src/app/(app)/compliance/notices/create-notice-dialog.tsx`, `src/app/(app)/compliance/settings/page.tsx` (je + Spec)
**Interfaces**
- Consumes: Task 9/10/11 (Endpunkte), Task 14 (`types.ts` nur lesen).
- Produces:
```ts
export function fetchNotices(q?: { kind?: AuthorityNoticeKind; year?: number }): Promise<AuthorityNotice[]>; export function fetchNotice(id: string): Promise<AuthorityNotice>;
export function createNotice(input: { kind: AuthorityNoticeKind; year?: number; referenceDate?: string; sentOn?: string }): Promise<AuthorityNotice>; // 409 DRAFT_EXISTS liefert existingId
export function noticeAction(id: string, action: 'recount' | 'mark-sent' | 'reopen' | 'cancel', body?: { sentOn?: string; sentVia?: string; reference?: string }): Promise<AuthorityNotice>;
export function updateNotice(id: string, input: Partial<NoticeEdit>): Promise<AuthorityNotice>; export function openNoticePdf(id: string): Promise<void>; // authentifizierter Download, kein Link mit Token
export function fetchComplianceSettings(): Promise<ComplianceSettings>; export function updateComplianceSettings(input: Partial<ComplianceSettings>): Promise<ComplianceSettings>;
```
- [ ] **Schritt 1: Failing Tests**
```ts
it('the detail page shows three steps: 1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen, and the main action follows the step', ...);
it('Als gesendet markieren asks for date, channel and reference and sends them', ...);
it('a 409 DRAFT_EXISTS links to the existing draft', ...);
it('"bereits angezeigt am …" creates a SENT registration with the entered date', ...);
it('recount is offered only for drafts and cancel only for drafts', ...);
it('the list shows kind, year, status, count and reference date', ...);
it('settings show the note "Annahme – bitte prüfen" at the trigger switches, the reference day and the due day', ...);
it('settings keep counted colony types and digit heights collapsed under Erweitert', ...);
it('an invalid reference day like 02-29 shows the server message', ...);
it('STAFF sees the pages read-only without action buttons', ...);
```
- [ ] **Schritt 2:** `npx vitest run src/lib/compliance "src/app/(app)/compliance"` → FAIL.
- [ ] **Schritt 3: Implementieren**
- Detailseite nach Spec 9 mit Schritt-Anzeige (Symbol + Wort je Schritt); Formulare mit `FormSection`; „Annahme – bitte prüfen“ als sichtbarer Vermerk am Feld, nicht nur als Tooltip. Anlege-Anfragen mit `Idempotency-Key`. Dokument-Upload (Kopie, Eingangsbestätigung) über `DocumentUploadCard` mit `AUTHORITY_NOTICE`.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- `npm run lint && npm run typecheck && npx vitest run src/lib/compliance "src/app/(app)/compliance"` → PASS
- Browser: Desktop und 390 px, Fehlerzustände (409), Tastatur
- kein Commit
---
### Task 19: Sperrbezirk – Seite, Dialoge, Stand-Band, Ernte-Warnung
**Files**
- Create: `src/lib/compliance/restrictions-api.ts` (+ Spec), `src/lib/compliance/use-active-restrictions.ts`, `src/components/compliance/restriction-notice.tsx` (+ Spec), `src/components/compliance/restriction-override.tsx` (+ Spec), `src/app/(app)/compliance/restrictions/page.tsx` (+ Spec), `src/app/(app)/compliance/restrictions/create-restriction-dialog.tsx` (+ Spec)
- Modify: `src/app/(app)/colonies/[id]/colony-actions.tsx` (Umsetzen, Vereinigen), `src/app/(app)/colonies/[id]/colony-edit-card.tsx` (Standkorrektur), `src/app/(app)/colonies/new/page.tsx`, `src/app/(app)/apiaries/[id]/page.tsx` (rotes Band), `src/app/(app)/harvests/new/page.tsx` und `src/app/(app)/harvests/[id]/harvest-edit-dialog.tsx` (Warnung) – je mit Specs
**Interfaces**
- Consumes: Task 8 (`GET /apiary-restrictions?active=true`, `POST/PATCH`, `RESTRICTED_ZONE`-409), Task 14 (Typen lesen).
- Produces:
```ts
export function fetchActiveRestrictions(): Promise<ActiveRestriction[]>; export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; isLoading: boolean };
export function createRestrictions(input: { apiaryIds: string[]; disease: string; authority?: string; reference?: string; startsOn: string; endsOn?: string; notes?: string }): Promise<ApiaryRestriction[]>;
export function endRestriction(id: string, endsOn: string): Promise<ApiaryRestriction>;
export function restrictionsAmong(apiaryIds: readonly string[], active: readonly ActiveRestriction[]): ActiveRestriction[];
// <RestrictionNotice restrictions /> rote Box "Stand {Name} liegt im Sperrbezirk ({Krankheit}, seit {Datum}). Umsetzen ist gesperrt." + fester BienSeuchV-§-5-Hinweis (nur im Umsetzen-Dialog)
// <RestrictionOverride onReason={(reason | null) => void} /> nur mit compliance:manage: aufklappbar "Ausnahme mit Begründung", Rückfrage im ConfirmDialog
```
- [ ] **Schritt 1: Failing Tests**
```ts
it('the move dialog shows the red notice and disables Umsetzen while the source or target apiary is restricted', ...);
it('the exact BienSeuchV § 5 hint appears in the move dialog', ...); // "Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen."
it('the override section shows only with compliance:manage and enables Umsetzen after a reason of 10+ characters and a confirmation', ...);
it('sends restrictionOverrideReason with the move request only when the override is used', ...);
it('shows the server 409 RESTRICTED_ZONE message when a race slips past the client check', ...);
it('new colony, merge and apiary correction show the same notice', ...);
it('the apiary page shows a red band with a link while a restriction is active', ...);
it('the harvest form warns "Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen." and still saves', ...);
it('the restrictions page lists active and ended restrictions and creates one for several apiaries', ...);
it('the disease is chosen via ChoiceGroup (Amerikanische Faulbrut, Kleiner Beutenkäfer, Andere mit Freitext)', ...);
it('ending a restriction sets endsOn and keeps the entry', ...);
it('the Verfügung is uploaded via DocumentUploadCard for APIARY_RESTRICTION', ...);
```
- [ ] **Schritt 2:** `npx vitest run src/lib/compliance src/components/compliance "src/app/(app)/compliance" "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/harvests"` → FAIL.
- [ ] **Schritt 3: Implementieren**
- Aktive Sperren einmal je Seite laden (`useActiveRestrictions`, gemeinsamer Query-Key). Der Server entscheidet endgültig; die Client-Prüfung blendet nur früh aus. Die Berechtigung für die Ausnahme kommt aus dem vorhandenen Auth-Kontext.
- Roter Text mit Token `destructive`, Symbol + Wort; Fokus springt bei einer 409-Antwort auf die Meldung.
- [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation**
- `npm run lint && npm run typecheck && npx vitest run src/lib/compliance src/components/compliance "src/app/(app)/compliance" "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/harvests"` → PASS
- Browser: Umsetzen im Sperrbezirk (Desktop und 390 px), Tastatur, Fehlerzustand
- kein Commit
---
## Abschluss und Rollout (Orchestrator)
- [ ] Gesamtprüfung Backend: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci`, `npm run test:e2e`, `npm run build`; Frontend: `npm run lint && npm run typecheck && npm run test && npm run build`. Drift-Check `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` → 0.
- [ ] Wächter prüfen: Backup-Modellabdeckung, `handler-contract.spec.ts`, `after-commit-usage.spec.ts`, Null-Vertrags-Specs der Tasks 2, 3, 5, 8, 9, Platzhalter-Fixture (Task 5 und 17) und der Seed-Wächter `src/seed-no-medicines.spec.ts` (Task 2 legt ihn an: `prisma/seed.ts` enthält kein `veterinaryMedicine`).
- [ ] Docs aus den Berichten: `ARCHITECTURE.md` vier neue Annahmen mit je der nächsten freien Nummer (Katalog und Packungen; Etikett-Pflichtangaben; Behördenpaket und Sperrbezirk; Prüfbereitschaft), Annahme 15 ergänzen, §10 um die vier Ereignisse und die beiden Handler, Dokumente-Rechte je Art; `docs/pruefung-2026-09.md` Abschnitt „Prüfbereitschaft“; `TASKS.md` (T6).
- [ ] Rollout nach Spec 13 (jeder Schritt mit Freigabe des Inhabers): Voraussetzung Rückgrat-Runbook bis Schritt 3 und T4 live; Image mit den drei Migrationen (alles additiv, Rückweg ohne Verlust); danach Checkliste für den Inhaber: 1. Stammdaten Lebensmittelunternehmer, 2. Produkte Bezeichnung, 3. Etikettenvorlagen im Designer („Pflichtangaben-Block einfügen“), 4. Einstellungen Veterinäramt, Registriernummer, Stichtag und Frist der Tierseuchenkasse (Annahme), 5. Katalog aus den bisherigen Behandlungen übernehmen und aktuelle Packungen erfassen, 6. Erstanzeige „bereits angezeigt am …“, 7. Prüfbereitschaft durchgehen, bis sie grün ist. Vor dem Rollout einmal die Paragraphen-Nummern der Spec gegen gesetze-im-internet.de bzw. EUR-Lex abgleichen.
- [ ] Ein zentraler Commit, Nachricht endet mit `Co-Authored-By: Claude Sonnet 5.5 <[email protected]>`; Push gebündelt.
---
## Abdeckung der Spec
| Spec-Punkt | Tasks |
|---|---|
| 1 Messkriterium 1 (Behandlung aus vollständiger Packung ohne „FEHLT“) | 3, 13 |
| 1 Messkriterium 2 (`print` 409 mit Lückenliste, sonst PDF) | 5, 6 |
| 1 Messkriterium 3 (eine ungelesene Meldung je berechtigtem Nutzer) | 7, 9, 11 |
| 1 Messkriterium 4 (`GET /compliance/readiness` leer bei vollständigen Daten) | 12, 13 |
| 1 Entscheidungen (Packungen, live aus Packung, Katalog nur im Formular, Sperren, Meldungen) | 2, 3, 6, 8, 11, 16 |
| 3 Rechtliche Leitplanken (Annahmen als Einstellungen) | 5, 9, 18 |
| 4.1 Tierarzneimittel-Modelle | 1, 2 |
| 4.2 Behandlung (`medicineId`, `medicineLotId`, `doseQuantity`, `apiaryId` im Update) | 1, 3 |
| 4.3 Etikett-Felder, Platzhalter | 1, 5, 17 |
| 4.4 Behörden und Sperrbezirk | 1, 8, 9 |
| 4.5 Quittungen | 1, 12 |
| 4.6 Dokumente (drei Arten) | 1, 4 |
| 4.7 Migrationen | 1 |
| 4.8 Rechte | 1 |
| 4.9 Backup | 1 |
| 5.1 Katalog, Packungen, Bewegungen, Vorschläge | 2 |
| 5.2 Behandlung | 3 |
| 5.3 Bestandsbuch, Lücken-Helfer | 3, 13 |
| 5.4 Etikett-Pflichtangaben, Druck | 5, 6 |
| 5.5 Behördenpaket | 9, 10, 11 |
| 5.6 Sperrbezirk | 8 |
| 5.7 Prüfbereitschaft (18 Punkte, Quittungen) | 12, 13 |
| 6.1 Ereignisse | 7 |
| 6.2 Handler | 11 |
| 6.3 Tägliche Auswertung | 11 |
| 7 API | 2, 3, 6, 8, 9, 10, 12, 13 |
| 8.1–8.2 Anzeige-PDFs | 10 |
| 8.3 Bestandsbuch | 3 |
| 8.4 Probedruck | 6 |
| 8.5 Prüfbericht | 13 |
| 9 Oberfläche | 14–19 |
| 10 Fehlerfälle | über die Tests der jeweiligen Tasks (Review Focus) |
| 11 Sicherheit | Global Constraints; 4, 5, 8, 12, 13 |
| 12 Tests und Wächter | in den Tasks benannt; Seed-Wächter und Platzhalter-Fixture im Abschluss |
| 13 Rollout | Abschluss |
---
## Self-Review
**Spec-Lücken und Widersprüche, hier aufgelöst (bitte beim Review bestätigen)**
- **Dokumente und Rechte:** Spec 11 verlangt `compliance:read` bzw. `veterinary-medicines:read` für Belege, der Dokument-Endpunkt prüft aber nur `documents:*`. Gelöst in Task 4: Fachrecht je Dokumentart zusätzlich zum Dokumentrecht, auch beim Umhängen eines Dokuments.
- **Meldungen „höchstens eine ungelesene“ gegen T4:** T4 vergibt `dedupeKey` dauerhaft eindeutig (nach dem Lesen käme nie wieder eine Meldung). Gelöst: `recordForPermissionOnce` ohne `dedupeKey`, Dublette nur über „ungelesen“ unter Advisory-Lock (Task 11). T4 hat keine Kategorie für Behörden; die Meldungen tragen `SYSTEM`, damit `NotificationCategory` und die Telegram-Kategorienliste unverändert bleiben.
- **`totalColonies` „korrigierbar“, aber ungenutzt:** Die Abweichung vergleicht mit `baseline.totalColonies`, nicht mit dem Bestandsbild. Sonst hielte „bereits angezeigt am …“ den heutigen Stand statt der tatsächlich angezeigten Zahl für angezeigt.
- **Ableger:** Spec 6.1 lässt `split` `ColonyCreated` schreiben, T4 Task 10 `ColonySplit`. Gelöst: beide Ereignisse (Task 7).
- **`restrictionOverrideReason` gegen `{ ...dto }`:** Der Service reicht das ganze DTO an Prisma; das neue Feld würde als unbekanntes Argument scheitern, und `UpdateColonyDto` (per `PickType`) kennt es nicht. Gelöst in Task 8: Feld ausdrücklich ergänzen und vor dem Schreiben herauslösen; Test hält es fest.
- **Worker-DI:** `ComplianceModule` wird vom `WorkerModule` importiert, der Worker hat nur den resolver-only Provider. Die Ausnahme-Berechtigung liegt darum in `ColoniesService` (nur AppModule), nicht im Modul mit den Handlern.
- **Prüfpunkte je Gegenstand:** Die Spec-Tabelle nennt 18 Punkte, Quittung und `ReadinessItem` gelten aber je `subjectId`. Gelöst: Eine Regel liefert Findings; jedes Finding ist ein Item (`lot-expired-stock` je Packung, `label-template-*` je Vorlage, `treatment-log-gaps` je Jahr); eine Regel ohne Finding erscheint einmal als „Erledigt“. `ReadinessItem` bekommt `state` und `acknowledgement`.
- **Subjekte brauchen den Eltern-Bezug:** Eine Behandlung ist nur über ihr Volk erreichbar. `ReadinessSubject.parentId` (Erweiterung der Spec) trägt die Volk-ID; `fix-links.ts` bildet auf `/colonies/{id}?editTreatment=…` ab (Tasks 12, 14, 16).
- **Leck über Prüfpunkt-Texte:** `compliance:read` allein soll keine Arzneimittel- oder Behandlungsnamen zeigen. Regeln mit `subjectPermission` liefern ohne dieses Recht nur die Anzahl (Tasks 12, 13).
- **Schriftmetriken:** Die AFM-Werte stehen am privaten pdfkit-Feld `_font`. Gelöst: eine Kapsel (`font-metrics.ts`) und ein Wächter-Test mit 523/718 (Task 5).
- **Keine Rechtswerte im Frontend:** Der Designer braucht Mindestgrößen für den Pflichtangaben-Block. Gelöst: `LabelCheckResult.requirements` liefert x-Höhe und Ziffernhöhe vom Server (Erweiterung der Spec, Annahme 10 bleibt gewahrt).
- **`product` ist Pflicht im DTO, Spec 5.2 lässt es weg:** `CreateTreatmentDto.product` wird nur ohne Katalogeintrag und Packung verlangt (Task 3).
- **Testdatei der Spec existiert nicht:** `test/treatment-log.e2e-spec.ts` gibt es nicht; die Erweiterung steht in `test/export.e2e-spec.ts` (Tasks 3, 13).
- **Parallele Tasks gegen den zentralen Null-Vertrags-Spec:** Task 1 zieht den Rumpf in `describeNullContracts`; jede T6-Task legt ihre Verträge in einer eigenen Spec-Datei ab. Die Projektregel („steht in `update-dto-null-handling.spec.ts`“) gilt damit über den gemeinsamen Helfer.
- **Read-modify-write in angefassten Altservices:** `SiteSettingsService`, `ProductsService`, `LabelTemplatesService`, `ApiariesService` lesen `before` außerhalb der Transaktion. Die Tasks 5 und 7 stellen sie auf die Sperre um; die Tabellen kommen in Task 1 in `LOCKABLE_TABLES`.
- **Migrationsstempel:** T6 liegt hinter `20261015110000_t4_telegram`.
- **Nicht spezifiziert, hier festgelegt:** Der Prüfpunkt `tsk-report` wertet das laufende Jahr und Jahre mit offenem TSK-Entwurf aus; `mark-sent` ohne `sentOn` nimmt den DB-Tag; `GET /veterinary-medicine-lots` ohne `selectable` liefert alle Packungen; Probedruck-Aufdruck pro Etikett; die Vorlage-Prüfung in der Prüfbereitschaft nutzt nur Textelemente.
**Geprüft**
- Jeder Spec-Abschnitt hat einen Task (Tabelle oben). Kein Task enthält „TBD“; Funktionsrümpfe stehen nur als Signaturen, Testnamen und festgelegte Formeln.
- Typen und Namen über die Tasks gleich: `roundToThousandths`, `treatmentBookingDelta`, `treatmentLogGaps`/`resolveSourceValues`, `checkLabel`/`LabelCheckResult`/`SAMPLE_BOTTLING`, `assertNoActiveRestriction`/`RestrictionOverride`, `HerdStatus`/`herdStatusIn`/`createDraftIn`, `recordForPermissionOnce`, `ReadinessRule`/`ReadinessFinding`/`ReadinessItem`/`READINESS_RULES`, `fixLinkFor`. Die Consumes-Zeilen stimmen mit den Produces-Blöcken überein.
- Jeder Task schließt mit Verifikation und „kein Commit“; Doku-Texte gehen über den Bericht an den Orchestrator.
- Geteilte Dateien je Welle höchstens einmal (Liste unter „Abhängigkeiten und Parallelisierung“). Paare, die nur durch Wellen getrennt sind: 2/9/12 (`app.module.ts`), 7/8 (`colonies.service.ts`), 9/11 (`compliance.module.ts`), 9/10 (`authority-notices.controller.ts`), 12/13 (`readiness-context.ts`, `rules/index.ts`), 5/6 (`label-templates.service.ts`).
- Proportion: Code steht nur als Signaturen, Testnamen mit Kern-Assertions und gepinnte Formeln und Texte; die Spec bleibt die Quelle der Einzelheiten.