Files
ApiCore/docs/superpowers/specs/2026-10-13-t6-compliance-design.md
T
Nick SzaunigandClaude Opus 5.5 edc193f128
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s
feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

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

856 lines
68 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“ — Design
Stand: 2026-10-13 · Betrifft: ApiCore (Backend, Frontend) · Grundlage: Spec `2026-10-12-plattform-rueckgrat-und-fahrplan-design.md` §10 „T6“, Inhaber-Entscheidungen vom 2026-10-13
## 1. Ziel
Bei einer Kontrolle durch Veterinäramt oder Lebensmittelüberwachung soll Imkerei Wabenhain alles Verlangte sofort vorlegen können. ApiCore hält die Pflichten nach, die aus der Arbeit am Stand folgen:
- **Bestandsbuch ohne „FEHLT“:** Ein Tierarzneimittel-Katalog und erfasste Packungen füllen die Behandlung vor. Die Behandlung bucht die verbrauchte Menge von der Packung ab.
- **Etiketten nur mit Pflichtangaben:** Vor dem Druck prüft die App das fertige Etikett der konkreten Abfüllung. Fehlt eine Pflichtangabe, sperrt sie den Druck und nennt die Lücke.
- **Behördenpaket:** Ändern sich Völkerzahl oder Stände, kommt ein Hinweis. Die Anzeige ans Veterinäramt entsteht als PDF. Zum Stichtag der Tierseuchenkasse zählt die App die Völker selbst. Ein Stand im Sperrbezirk sperrt das Umsetzen.
- **Seite „Prüfbereitschaft“:** Jeder offene Punkt steht rot da, mit einem Knopf zum Beheben.
Die App warnt und listet Lücken. Sie gibt keine Rechtsberatung. Wo ein konkreter Wert nicht sicher belegt ist, ist er eine Einstellung mit Vorgabe und dem Vermerk **„Annahme – vom Inhaber zu prüfen“**.
Messbar fertig ist T6, wenn:
1. Eine Behandlung aus einer vollständig erfassten Packung ergibt eine Bestandsbuch-Zeile ohne „FEHLT“.
2. `GET /label-templates/:id/print/:bottlingBatchId` liefert bei fehlender Pflichtangabe 409 mit Lückenliste und sonst das PDF.
3. Eine geänderte Völkerzahl erzeugt nach dem Abarbeiten der Ereignisse genau eine ungelesene Meldung je berechtigtem Nutzer.
4. `GET /compliance/readiness` ist bei vollständigen Daten leer und meldet „prüfbereit“.
### Entscheidungen
| Frage | Entscheidung |
|---|---|
| Fremdhonig und Mischungen | **Nicht im Umfang** (Inhaber 2026-10-13). Nur eigener deutscher Honig. Länderanteile in Prozent entfallen. Fremdhonig ist im Modell ohnehin nicht abbildbar: `HoneyBatch` hängt immer an einer eigenen `Extraction`. |
| Wanderung (BienSeuchV § 5) | **Entfällt** (Inhaber 2026-10-13: keine Wanderimkerei). Keine Bescheinigungen, kein Stand-Schild. Ein fester Hinweistext im Umsetzen-Dialog bleibt, siehe 2.2. |
| Wo liegt der Lagerbestand der Tierarzneimittel? | **Eigene Packungen mit eigenem Bewegungsbuch** (`VeterinaryMedicineLot` + `VeterinaryMedicineMovement`). Nicht `StockItem`: dort gibt es keine Charge, keinen Lieferanten und keinen Beleg je Kauf. Nicht `BatchMovement`: das gehört zum Shop-Bestand, löst `StockChanged` und Webhooks aus und rechnet in ganzen Stück bzw. Gramm. **Inhaber 2026-10-13.** Ein Lagerartikel ohne Charge hätte etwa 1,25 PT gespart, aber Lieferant und Beleg wären Freitext je Behandlung geblieben. |
| Lieferant, Beleg, Ch.-B. und Tierarzt bei Behandlung aus einer Packung | **Werden live aus der Packung gelesen, nicht kopiert.** Ein Tippfehler im Beleg wird einmal an der Packung korrigiert und ist in jedem Bestandsbuch richtig. Die Freitextfelder der Behandlung gelten nur noch ohne Packung. |
| Vorbelegung aus dem Katalog | **Nur sichtbar im Formular**, mit Herkunftsnotiz wie in T3. Der Server leitet nichts still ab. Einzige Ausnahme: `product` wird zum Handelsnamen des gewählten Katalogeintrags (ein Verweis, keine Beobachtung). |
| Wie wird ein Etikett geprüft? | **Am aufgelösten Inhalt** des Etiketts für die konkrete Abfüllung: alle Texte mit eingesetzten Platzhaltern. So zählt auch fest getippter Text, und ein festes „500 g“ auf einer 250-g-Abfüllung fällt auf. |
| Was sperrt den Etikettendruck? | **Fehlende Pflichtangabe** (auch eine abgeschnittene) → 409. Formmängel (x-Höhe, Ziffernhöhe der Füllmenge, fehlendes „L“) → Warnung. Probedruck mit Aufdruck „MUSTER“ bleibt immer möglich. **Inhaber 2026-10-13.** Grund: Die Ziffernhöhen sind eine Annahme; eine Sperre darauf würde echten Druck verhindern. |
| Name und Anschrift auf dem Etikett | **Eigene Felder „Lebensmittelunternehmer“** (`SiteSettings.foodBusinessName`, `foodBusinessAddress`) mit Knopf „Vom Bienenhalter übernehmen“. Nicht die Bienenhalter-Felder des Bestandsbuchs. **Inhaber 2026-10-13.** Auf das Glas gehört die Marke, ins Bestandsbuch die Person. |
| Rechtswerte im Code | **Keine.** Der Katalog startet leer (Annahme 10). Stichtag der Tierseuchenkasse, Auslöser der Änderungsanzeige und Ziffernhöhen der Füllmenge sind Einstellungen mit markierter Annahme. |
| Zustand der Prüfbereitschaft | **Live berechnet** aus den Fachdaten. Gespeichert werden nur Quittungen für „Prüfen“-Punkte. Kein zweiter Datenstand, der veralten kann. |
| Farbe | **Jeder offene Punkt ist rot** (Inhaber). Die Schwere steht als Wort daneben: „Pflicht“ oder „Prüfen“. „Prüfbereit“ (grün) heißt: nichts offen. |
| Sperrbezirk | **Harte Sperre** für Umsetzen, Anlegen im Stand, Standkorrektur und Vereinigen über Stände hinweg. Ausnahme nur mit Begründung, Recht `compliance:manage` und Audit-Eintrag. **Inhaber 2026-10-13.** |
| Ernte am Stand im Sperrbezirk | **Nur Warnung**, keine Sperre. **Inhaber 2026-10-13.** Was erlaubt ist, steht in der Verfügung des Amts. Eine Ernte nachträglich zu dokumentieren muss möglich bleiben. |
| Ereigniskatalog | **Wird um vier Typen erweitert** (`ColonyCreated`, `ColonyMoved`, `ApiaryChanged`, `TskReferenceDayReached`). Der Rückgrat-Plan nennt seinen Katalog „vollständig“ für die erste Stufe; T6 ist die nächste Stufe. |
| Meldungen | **Höchstens eine ungelesene Meldung** je Thema und Nutzer. Sonst meldet jede Ablegerbildung im Juni neu. |
### Abhängigkeiten
- Rückgrat: Task 3 (`events.record`, `recordForPermission`), Task 13 (Völker schreiben in der Transaktion mit Ereignis), Task 15 (`JobRun`, `cli.ts`), Task 18 (`daily-evaluation`, `dailyDedupeKey`, `inCatchUpWindow`). Im Cluster: Runbook-Schritt 3 (Worker und CronJobs) ist live.
- T3 Task 4 (`POST /treatments/bulk`, gemeinsame private Datenaufbereitung von Einzel- und Sammel-Anlegen) und Task 2 (`last-by-product`). T6 erweitert genau diese gemeinsame Funktion.
- T4 ist keine Voraussetzung. T5-Farbtokens werden genutzt, wenn vorhanden.
## 2. Umfang
### 2.1 Im Umfang
| Baustein | Inhalt |
|---|---|
| Tierarzneimittel-Katalog | Handelsname, Wirkstoff, Zulassungsnummer, verschreibungspflichtig ja/nein, Wartezeit Honig, Einheit, Dosis je Volk mit Hinweis, Anwendungsart, Temperaturfenster, Standard-Lieferant. Vom Imker gepflegt. Übernahme-Vorschläge aus den eigenen bisherigen Behandlungen. |
| Packungen und Bewegungsbuch | Erwerb mit Ch.-B., Datum, Menge, Verfall, Lieferant, Beleg (Text und Scan), Tierarzt. Rest aus dem Bewegungsbuch. Abgang je Behandlung, Inventur-Korrektur, Entsorgung. |
| Behandlung | Mittel aus dem Katalog, Packung (älteste zuerst vorgeschlagen), Dosis je Volk. Lagerabgang in derselben Transaktion, auch bei der Sammelerfassung. Korrektur bucht gegen. Hinweis zum Temperaturfenster aus dem Stand-Wetter. |
| Bestandsbuch | Spalten aus der Packung, Menge aus der Dosis, Tierarzt nur bei verschreibungspflichtigen Mitteln Pflicht. Ein gemeinsamer Lücken-Helfer für PDF und Prüfbereitschaft. Stand am Behandlungstag korrigierbar. |
| Etikett-Pflichtangaben | Neue Platzhalter, Prüfregeln für Bezeichnung, Füllmenge, MHD, Los, Ursprungsland, Name und Anschrift. Warnungen für Form. Live-Liste im Designer, Knopf „Pflichtangaben-Block einfügen“, Druck-Sperre, Probedruck, Audit je Druck. |
| Behördenpaket | Einstellungen (Veterinäramt, Registriernummer, Auslöser, Stichtag). Erstanzeige und Änderungsanzeige als PDF. Zählnachweis zum TSK-Stichtag, automatisch als Entwurf. Sperrbezirk je Stand mit Verfügung als Dokument. |
| Prüfbereitschaft | 18 Prüfpunkte in fünf Bereichen, Quittieren von „Prüfen“-Punkten mit Notiz, Kurzstatus fürs Dashboard, Bericht als PDF. |
### 2.2 Nicht im Umfang
| Thema | Grund |
|---|---|
| Mischungen, Länderanteile, Fremdhonig | Inhaber-Entscheidung 2026-10-13. Die geänderte HonigV (Länderanteile ab 14.06.2026, siehe Fahrplan-Spec) betrifft nur Mischungen. |
| Wanderbescheinigung, Stand-Schild, Rückkehr-Aufgabe | Inhaber-Entscheidung 2026-10-13. Rest-Risiko: Auch das Umsetzen zwischen eigenen festen Ständen kann eine Gesundheitsbescheinigung verlangen (BienSeuchV § 5). Der Umsetzen-Dialog zeigt dazu einen festen Hinweis: „Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen.“ Keine Logik dahinter. |
| Prüfung, ob die Bezeichnung zulässig ist | Die App prüft nur, ob eine Bezeichnung mit „Honig“ da ist. Ob z. B. „Rapshonig“ stimmt (überwiegend aus dieser Tracht, HonigV), weiß nur der Imker oder ein Labor. |
| Sichtfeld-Regel (LMIV Art. 13 Abs. 5) | Bezeichnung und Füllmenge im selben Sichtfeld: Die Glasform kennt die App nicht. Ein Etikett je Glas wird angenommen. |
| Nährwertdeklaration | Annahme – vom Inhaber zu prüfen: Honig ist als unverarbeitetes Erzeugnis aus einer Zutat davon befreit (LMIV Anhang V Nr. 1; Direktabgabe kleiner Mengen Anhang V Nr. 19). Die App prüft das nicht. |
| Pflichtangaben im Online-Shop (LMIV Art. 14, Fernabsatz) | Gehört zum Wabenhain-Shop. Eine spätere Erweiterung von `shop-sync` kann `legalName`, Ursprungsland und Lebensmittelunternehmer liefern. |
| Registrierung als Lebensmittelunternehmer (VO (EG) 852/2004 Art. 6) | Einmaliger Vorgang ohne Datenbezug zur App. |
| Meldung an die TSK selbst (Portal oder Meldebogen) | Die App zählt und dokumentiert. Melden tut der Inhaber. Das PDF sagt das ausdrücklich. |
| Arzneimitteldaten von außen (Datenbanken, Beipackzettel) | Annahme 10: keine fremden, rechtlich relevanten Daten im Code oder im Seed. |
## 3. Rechtliche Leitplanken
Spalte „Stand“: **Norm** = die App setzt die zitierte Vorschrift um. **Annahme** = Wert oder Auslegung ist eine Einstellung, „Annahme – vom Inhaber zu prüfen“.
Die Paragraphen-Nummern wurden nicht gegen die amtliche Fassung abgeglichen. Vor dem Rollout gleicht sie jemand einmal gegen gesetze-im-internet.de bzw. EUR-Lex ab. Die Texte in der App nennen die Norm nur als Hinweis.
| Pflicht | Norm | Umsetzung in der App | Stand |
|---|---|---|---|
| Bestandsbuch: Datum, Mittel, Menge, Lieferant, Erwerbsbeleg, Tiere, verschreibender Tierarzt (falls zutreffend), Wartezeit auch bei 0, Dauer; 5 Jahre aufbewahren | Art. 108 VO (EU) 2019/6 | Bestehendes PDF (Annahme 15), Felder aus der Packung, Lücken-Helfer, Prüfbereitschaft über 6 Kalenderjahre (laufendes + 5) | Norm |
| Anzeige der Bienenhaltung mit Völkerzahl und Standort | BienSeuchV § 1a | Erstanzeige als PDF oder „bereits angezeigt am …“ | Norm |
| Anzeige von Änderungen (Völkerzahl, Stände) | BienSeuchV § 1a; ob jede Änderung der Völkerzahl anzuzeigen ist, regelt das Amt unterschiedlich | Einstellungen `noticeOnColonyCountChange`, `noticeOnApiaryChange`, beide Vorgabe an | Annahme |
| Registriernummer des Halters | ViehVerkV § 26; ob und wie sie für Bienen vergeben wird, hängt vom Landratsamt ab | nur ein Feld, wird ins PDF gedruckt | Annahme |
| Meldestichtag und Meldefrist der Bayerischen Tierseuchenkasse, gezählte Völkerarten | Landesrecht Bayern (Ausführungsgesetz zum TierGesG, Beitragssatzung der Tierseuchenkasse) | `tskReferenceDay` Vorgabe `01-01`, `tskReportDueDay` leer, `tskExcludedColonyTypes` leer (alle zählen) | Annahme |
| Sperrbezirk bei Amerikanischer Faulbrut: Völker dürfen nicht entfernt und nicht hineingebracht werden; weitere Auflagen (u. a. Honig, Waben) | BienSeuchV § 10; Einzelheiten in der Verfügung des Amts | harte Sperre für Bewegungen von Völkern; Warnung bei Ernte; Verfügung als Dokument | Norm (Sperre); Ernte nur Warnung (Inhaber 2026-10-13) |
| Bezeichnung des Lebensmittels | LMIV Art. 9 Abs. 1 lit. a; HonigV § 3 mit Anlage 1 | Pflicht: Text mit „honig“ | Norm |
| Kein Zutatenverzeichnis bei Honig | LMIV Art. 19 Abs. 1 lit. e (eine Zutat, Name identisch) | wird nicht geprüft | Norm |
| Nettofüllmenge | LMIV Art. 9 Abs. 1 lit. e, Art. 23, Anhang IX | Pflicht: „{Glasgröße} g“ im Text | Norm |
| Mindestziffernhöhe der Nennfüllmenge | Fertigpackungsverordnung, Grundlage RL 76/211/EWG Anhang I Nr. 3.1 | Warnung, Werte als Einstellung: bis 50 g 2 mm, bis 200 g 3 mm, bis 1000 g 4 mm, darüber 6 mm | Annahme |
| Mindesthaltbarkeitsdatum mit „mindestens haltbar bis …“ | LMIV Art. 9 Abs. 1 lit. f, Art. 24, Anhang X | Pflicht: Wortlaut und Datum der Abfüllung | Norm |
| Los-Kennzeichnung, Buchstabe „L“ vorangestellt, außer deutlich unterscheidbar | Los-Kennzeichnungs-Verordnung (LKV), Grundlage RL 2011/91/EU. Die frühere LMKV ist durch die LMIV abgelöst. | Pflicht: Losnummer im Text; Warnung ohne „L“. Bewusst Pflicht, obwohl die LKV bei MHD mit Tag und Monat befreit: Rückverfolgbarkeit nach Art. 18 VO (EG) 178/2002 | Norm |
| Ursprungsland | HonigV § 3 | Pflicht: `SiteSettings.originCountry` im Text | Norm |
| Name oder Firma und Anschrift des Lebensmittelunternehmers | LMIV Art. 8, Art. 9 Abs. 1 lit. h | Pflicht: beide Werte aus den Stammdaten im Text | Norm |
| Mindestschriftgröße: x-Höhe 1,2 mm, bei größter Verpackungsfläche unter 80 cm² 0,9 mm | LMIV Art. 13 Abs. 2 und 3, Anhang IV | Warnung, Grenzwert je Vorlage (`minXHeightMm`, Vorgabe 1,2) | Norm |
## 4. Datenmodell
### 4.1 Tierarzneimittel
```prisma
enum MedicineUnit {
ML
G
PIECE
}
// Katalog des Imkers. Jeder Wert kommt aus Packungsbeilage oder Rezept und
// wird vom Imker eingetragen; der Code liefert keine Arzneimitteldaten
// (ARCHITECTURE Annahme 10).
model VeterinaryMedicine {
id String @id @default(cuid())
name String // Handelsname
activeIngredient String?
authorisationNumber String? // Zulassungsnummer laut Packung
prescriptionOnly Boolean @default(false)
withdrawalPeriodDays Int? // Wartezeit Honig in Tagen, auch 0
unit MedicineUnit // nur änderbar, solange keine Packung existiert
dosePerColony Float? // in `unit`
doseNote String? // z. B. "5 ml je besetzter Wabengasse"
applicationMethod String? // z. B. Träufeln, Verdunsten
minTemperatureC Float?
maxTemperatureC Float?
defaultSupplier String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lots VeterinaryMedicineLot[]
treatments Treatment[]
@@index([active])
}
// Eine gekaufte Packung. Ch.-B., Lieferant, Beleg und Tierarzt stehen hier
// einmal und gelten für jede Behandlung aus dieser Packung.
model VeterinaryMedicineLot {
id String @id @default(cuid())
medicineId String // nur änderbar, solange keine Bewegung existiert
medicine VeterinaryMedicine @relation(fields: [medicineId], references: [id], onDelete: Restrict)
batchNumber String? // Ch.-B.
purchasedOn DateTime
quantity Float // Anfangsmenge in medicine.unit, > 0
expiresOn DateTime?
supplier String? // Name, Anschrift
purchaseProof String? // z. B. "Rechnung 4711 vom 01.08.2026"
prescribingVet String?
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements VeterinaryMedicineMovement[]
treatments Treatment[]
@@index([medicineId, expiresOn])
}
enum VeterinaryMedicineMovementType {
TREATMENT
CORRECTION
DISPOSAL
}
// Bewegungsbuch der Packungen: nie geändert, nie gelöscht; Fehler werden
// gegengebucht. Rest = quantity der Packung + Summe aller quantity.
model VeterinaryMedicineMovement {
id String @id @default(cuid())
lotId String
lot VeterinaryMedicineLot @relation(fields: [lotId], references: [id], onDelete: Restrict)
type VeterinaryMedicineMovementType
quantity Float // mit Vorzeichen
treatmentId String?
treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Restrict)
reason String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([lotId])
@@index([treatmentId])
}
```
CHECK-Constraints in der Migration (Stil wie `BatchMovement`): `quantity > 0` an der Packung; `quantity <> 0` an der Bewegung; `(type = 'TREATMENT') = ("treatmentId" IS NOT NULL)`.
### 4.2 Behandlung
`Treatment` bekommt:
| Feld | Typ | Bedeutung |
|---|---|---|
| `medicineId` | `String?` → `VeterinaryMedicine` | Katalogeintrag |
| `medicineLotId` | `String?` → `VeterinaryMedicineLot` | Packung, aus der behandelt wurde |
| `doseQuantity` | `Float?` | Menge je Volk in der Einheit des Mittels |
| `medicineMovements` | Relation | Buchungen dieser Behandlung |
Dazu `@@index([medicineLotId])`. `amount` (Freitext) bleibt. `apiaryId` (Stand am Behandlungstag) kommt ins Update-DTO, damit eine mit `*` markierte Zeile korrigiert werden kann.
### 4.3 Etikett
| Modell | Feld | Bedeutung |
|---|---|---|
| `SiteSettings` | `foodBusinessName String?` | Name oder Firma auf dem Etikett, z. B. „Imkerei Wabenhain · Nick Rosenlöcher“ |
| `SiteSettings` | `foodBusinessAddress String?` | Postanschrift auf dem Etikett |
| `Product` | `legalName String?` | Bezeichnung des Lebensmittels, z. B. „Blütenhonig“ |
| `LabelTemplate` | `minXHeightMm Float @default(1.2)` | Grenzwert der x-Höhe für diese Vorlage, 0,5–5 |
| `AuditAction` | neuer Wert `PRINT` | Etikettendruck im Audit-Log |
Neue Platzhalter (`PLACEHOLDER_KEYS`, Backend und Frontend):
| Schlüssel | Wert | Beispiel |
|---|---|---|
| `legalName` | `Product.legalName` | Blütenhonig |
| `originCountry` | `SiteSettings.originCountry` | Deutschland |
| `foodBusinessName` | `SiteSettings.foodBusinessName` | Imkerei Wabenhain · Nick Rosenlöcher |
| `foodBusinessAddress` | `SiteSettings.foodBusinessAddress` | Bühlweg 10, 86450 Altenmünster |
| `bestBeforeLine` | „Mindestens haltbar bis “ + MHD | Mindestens haltbar bis 05.03.2028 |
| `lotLine` | „L “ + Losnummer | L 2026-0007 |
`jarSize` bleibt („500 g“) und ist die Nettofüllmenge. `buildPlaceholderValues` bekommt die Einstellungen als zweiten Parameter. Druck und Prüfung nutzen dieselbe Funktion.
### 4.4 Behörden und Sperrbezirk
```prisma
// Singleton wie SiteSettings. Werte mit "Annahme" siehe Abschnitt 3.
model ComplianceSettings {
id String @id @default("singleton")
veterinaryOfficeName String? // Empfänger der Anzeige
veterinaryOfficeAddress String?
registrationNumber String?
noticeOnColonyCountChange Boolean @default(true) // Annahme
noticeOnApiaryChange Boolean @default(true) // Annahme
tskReferenceDay String @default("01-01") // MM-TT, Annahme
tskReportDueDay String? // MM-TT
tskMemberNumber String?
tskExcludedColonyTypes ColonyType[] @default([]) // Annahme: alle zählen
netQuantityDigitRules Json // Annahme, Vorgabe in der Migration (5.4)
updatedAt DateTime @updatedAt
}
enum AuthorityNoticeKind {
REGISTRATION // Erstanzeige
HERD_CHANGE // Änderungsanzeige
TSK_REPORT // Zählnachweis zum TSK-Stichtag
}
enum AuthorityNoticeStatus {
DRAFT
SENT
CANCELLED
}
model AuthorityNotice {
id String @id @default(cuid())
kind AuthorityNoticeKind
status AuthorityNoticeStatus @default(DRAFT)
year Int? // nur TSK_REPORT
referenceDate DateTime // "Stand am" bzw. Stichtag
countedAt DateTime // wann gezählt wurde
snapshot Json // HerdSnapshot, siehe 5.5
totalColonies Int // aus dem Snapshot, korrigierbar
sentOn DateTime?
sentVia String? // Post, E-Mail, Portal
reference String? // Aktenzeichen, Eingangsbestätigung
notes String?
// Nur bei DRAFT gesetzt ("REGISTRATION", "HERD_CHANGE", "TSK_REPORT:2027"),
// sonst null: höchstens ein offener Entwurf je Art und Jahr. Nullbares
// @unique statt partiellem Index, wie dedupeKey im Rückgrat.
openKey String? @unique
createdByUserId String?
createdBy User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([kind, status])
@@index([year])
}
model ApiaryRestriction {
id String @id @default(cuid())
apiaryId String
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Restrict)
disease String // z. B. "Amerikanische Faulbrut"
authority String? // anordnende Behörde
reference String? // Aktenzeichen der Verfügung
startsOn DateTime
endsOn DateTime? // null = gilt bis auf Weiteres
notes String?
createdByUserId String?
createdBy User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([apiaryId, startsOn])
}
```
CHECK `"endsOn" IS NULL OR "endsOn" >= "startsOn"`.
### 4.5 Prüfbereitschaft
```prisma
// "Geprüft, in Ordnung" für einen Prüfen-Punkt. Gilt nur, solange der
// Fingerabdruck zum aktuellen Befund passt; ändern sich die Daten, ist der
// Punkt wieder offen.
model ComplianceAcknowledgement {
id String @id @default(cuid())
itemKey String
subjectId String
fingerprint String
note String
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
revokedAt DateTime?
revokedByUserId String?
@@index([itemKey, subjectId])
}
```
### 4.6 Dokumente
`DocumentEntityType` bekommt `MEDICINE_LOT` (Erwerbsbeleg, Rezept), `AUTHORITY_NOTICE` (Kopie, Eingangsbestätigung) und `APIARY_RESTRICTION` (Verfügung). `DocumentsService.assertEntityExists` kennt die drei neuen Arten. Upload, MIME-Allowlist und Signaturprüfung bleiben unverändert.
### 4.7 Migrationen
Zeitstempel beim Bau. Reihenfolge fest, alles additiv. Erzeugt wie im Rückgrat-Plan mit `prisma migrate diff`, dann von Hand nachgearbeitet.
| Migration | Inhalt |
|---|---|
| `<ts>_t6_veterinary_medicines` | Enums `MedicineUnit`, `VeterinaryMedicineMovementType`; drei Tabellen mit CHECKs; `Treatment.medicineId`, `medicineLotId`, `doseQuantity` mit FKs und Index; `DocumentEntityType` + `MEDICINE_LOT`; Rechte `veterinary-medicines:*` samt Vergabe |
| `<ts>_t6_label_particulars` | `SiteSettings.foodBusinessName`, `foodBusinessAddress`; `Product.legalName`; `LabelTemplate.minXHeightMm`; `AuditAction` + `PRINT` |
| `<ts>_t6_authority_compliance` | `ComplianceSettings` mit Vorgabe für `netQuantityDigitRules`; `AuthorityNotice` mit Enums; `ApiaryRestriction` mit CHECK; `ComplianceAcknowledgement`; `DocumentEntityType` + `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`; Rechte `compliance:*` samt Vergabe |
`ALTER TYPE … ADD VALUE` läuft in der Migrationstransaktion (Postgres ≥ 12). Kein neuer Wert wird in derselben Migration benutzt.
### 4.8 Rechte
Der Katalog-Sync berechtigt nur System-Rollen. Die Migrationen vergeben die neuen Rechte darum selbst, im Stil von `20260928091348_winter_checks` (Annahme 16).
| Recht | Gruppe (`groupDe`) | ADMIN | BEEKEEPER | STAFF | READONLY | API_CLIENT |
|---|---|---|---|---|---|---|
| `veterinary-medicines:read` | Tierarzneimittel | ✓ | ✓ | ✓ | ✓ | – |
| `veterinary-medicines:create` | Tierarzneimittel | ✓ | ✓ | ✓ | – | – |
| `veterinary-medicines:update` | Tierarzneimittel | ✓ | ✓ | ✓ | – | – |
| `compliance:read` | Prüfbereitschaft & Behörden | ✓ | ✓ | ✓ | ✓ | – |
| `compliance:manage` | Prüfbereitschaft & Behörden | ✓ | ✓ | – | – | – |
`API_CLIENT` bekommt nichts: Belege, Rechnungsnummern und Anschriften braucht der Shop nicht. Eine Behandlung aus einer Packung bucht mit `treatments:create` ab. Ein eigenes Recht dafür gibt es nicht.
### 4.9 Backup
Alle neuen Modelle kommen ins Backup. Keines steht in `MODELS_EXCLUDED_FROM_BACKUP`, keines in `LEGACY_UNSEALED_TABLES`: Sie kommen nur aus versiegelten Dateien. Der bestehende Wächter-Test erzwingt das.
## 5. Fachlogik
### 5.1 Tierarzneimittel und Packungen
- **Rest** = `lot.quantity + Σ movement.quantity`, gerundet auf Tausendstel. Er wird immer berechnet, nie gespeichert. Pure Funktion `summarizeLot` in `src/veterinary-medicines/medicine-ledger.ts`.
- **Rundung:** gemeinsamer Helfer `roundToThousandths` in `src/common/quantity.ts`. `roundKg` in `bottling.service.ts` nutzt ihn ebenfalls, statt eine eigene Kopie zu haben.
- **Vorschlag der Packung:** `fifoOrder` aus `src/stock/stock-ledger.ts` wird wiederverwendet (`expiresOn` als Haltbarkeit, `purchasedOn` als Datum). Wählbar sind Packungen mit Rest > 0 und die bereits gewählte Packung der Behandlung.
- **Bewegungen:**
- `TREATMENT` entsteht nur über die Behandlung (5.2).
- `CORRECTION` (Inventur): Der Nutzer gibt den tatsächlichen Rest ein. Der Server sperrt die Packung (`SELECT … FOR UPDATE`), berechnet das Delta und bucht es. Ein Delta von 0 bucht nichts.
- `DISPOSAL` (Entsorgung): positive Menge, gebucht als negativ. `reason` ist Pflicht.
- **Negativer Rest ist erlaubt.** Die Behandlung hat stattgefunden. Sie abzulehnen würde die Lücke nur verstecken. Die Prüfbereitschaft meldet den negativen Rest (gleiche Haltung wie `allocateSale` beim Shop).
- **Änderungssperren:** `VeterinaryMedicine.unit` ist änderbar, solange keine Packung existiert. `lot.medicineId` ist änderbar, solange keine Bewegung existiert. Sonst 409. Alle anderen Felder sind jederzeit änderbar (Null-Regel, Annahme 20).
- **Übernahme-Vorschläge:** `GET /veterinary-medicines/suggestions` liefert die eigenen, noch nicht katalogisierten Handelsnamen aus `Treatment.product` (ohne Groß-/Kleinschreibung) mit Anzahl und den Werten der jüngsten Behandlung. Angelegt wird erst nach Bestätigung im Formular.
### 5.2 Behandlung
Einzel- und Sammel-Anlegen nutzen die gemeinsame Datenaufbereitung aus T3 Task 4. Dort kommen Prüfung und Buchung hinein, also für beide Wege gleich.
| Fall | Verhalten |
|---|---|
| `medicineId` gesetzt, `product` fehlt | `product` = `medicine.name` |
| `medicineId` und `product` weichen voneinander ab (ohne Groß-/Kleinschreibung) | 400 „Mittel und Katalogeintrag passen nicht zusammen“ |
| Katalogeintrag inaktiv | Anlegen: 400. Bestehende Behandlungen bleiben bearbeitbar. |
| `medicineLotId` ohne `medicineId` | `medicineId` = `lot.medicineId` |
| Packung gehört zu einem anderen Mittel | 400 |
| Packung plus `supplier`, `purchaseProof`, `batchNumber` oder `prescribingVet` | 400 „Diese Angabe kommt aus der Packung“ |
| `doseQuantity` ≤ 0 | 400 |
| Packung und Dosis gesetzt | Buchung `TREATMENT`, Menge −Dosis, in derselben Transaktion wie Behandlung, Audit und `TreatmentRecorded` |
| Dosis ohne Packung | wird gespeichert, keine Buchung |
| Bearbeiten ändert Packung oder Dosis, oder setzt eine davon auf `null` | Delta je Packung: Soll minus bisherige Summe dieser Behandlung. Gegenbuchung auf der alten, neue Buchung auf der neuen Packung. Pure Funktion `treatmentBookingDelta(current, desired)`. |
| `medicineId: null` bei gesetzter Packung | 400 |
| Behandlungsdatum nach Verfall der Packung | wird gespeichert; Prüfbereitschaft „Prüfen“ |
| Sammelerfassung | je Volk eigene Buchung, alles in der einen Transaktion; fehlt ein Volk, wird wie in T3 nichts gespeichert |
| Volk wird umgebucht (`colonyId`) | keine Wirkung auf den Bestand |
Die Vorbelegung bleibt im Formular. Der Server füllt `activeIngredient`, `withdrawalPeriodDays`, `method` und `doseQuantity` nie still nach. Fehlt die Wartezeit, steht im Bestandsbuch „FEHLT“, und die Prüfbereitschaft meldet es. Das ist ehrlich.
### 5.3 Bestandsbuch
`TreatmentLogSource` bekommt Packung und Mittel. Die Spalten kommen aus genau einer Quelle:
| Spalte | ohne Packung | mit Packung |
|---|---|---|
| Arzneimittel, Ch.-B. | `product`, `batchNumber` | `product`, `lot.batchNumber` |
| Lieferant; Tierarzt | `supplier`; `prescribingVet` | `lot.supplier`; `lot.prescribingVet` |
| Beleg für den Erwerb | `purchaseProof` | `lot.purchaseProof` |
| Verabreichte Menge | `amount` | `amount`, sonst `doseQuantity` + Einheit („30 ml“) |
| Wartezeit | `withdrawalPeriodDays` | gleich |
- **Gemeinsamer Lücken-Helfer** `treatmentLogGaps(row)` in `export/treatment-log.ts`. Er liefert die fehlenden Pflichtfelder: Lieferant, Beleg, Menge, Wartezeit, und Tierarzt nur bei `medicine.prescriptionOnly`. Das PDF druckt „FEHLT“ genau dort, wo der Helfer eine Lücke meldet. Die Prüfbereitschaft nutzt denselben Helfer. Ein Test hält fest, dass beide übereinstimmen.
- **Gruppierung** wie bisher, aber über die aufgelösten Werte. Zwei Völker aus derselben Packung am selben Tag stehen in einer Zeile.
- **Layout** unverändert. Fußnote neu: „Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.“
### 5.4 Etikett-Pflichtangaben
**Aufgelöster Inhalt.** Alle Text-Elemente der Vorlage, Platzhalter eingesetzt für die konkrete Abfüllung. Sortiert nach Position (oben, dann links) und mit Leerzeichen verbunden.
**Normalisieren:** Kleinbuchstaben, Unicode NFKC, jedes Zeichen außer Buchstaben und Ziffern wird zu einem Leerzeichen. So passt „Bühlweg 10, 86450 Altenmünster“ auch zu „Bühlweg 10 · 86450 Altenmünster“ oder einem Zeilenumbruch.
**Abgeschnittener Text zählt nicht.** Passt der Text eines Elements nicht in seine Box (pdfkit `heightOfString` bei Elementbreite größer als Elementhöhe + 0,5 pt), trägt das Element nichts zur Prüfung bei. Es gibt die Warnung `TEXT_CLIPPED`. Eine nur abgeschnitten vorhandene Pflichtangabe fehlt also.
| Code | Art | Erfüllt, wenn | Norm |
|---|---|---|---|
| `LEGAL_NAME` | Pflicht | ein Wort enthält „honig“ | LMIV Art. 9 Abs. 1 lit. a; HonigV § 3, Anlage 1 |
| `NET_QUANTITY` | Pflicht | „{Glasgröße} g“ steht im Text; bei vollen Kilogramm auch „{n} kg“; Dezimalkomma | LMIV Art. 9 Abs. 1 lit. e, Art. 23, Anhang IX |
| `BEST_BEFORE` | Pflicht | „mindestens haltbar bis“ und das MHD der Abfüllung (TT.MM.JJJJ, führende Null egal) | LMIV Art. 9 Abs. 1 lit. f, Art. 24, Anhang X |
| `LOT` | Pflicht | die Losnummer der Abfüllung | LKV; Art. 18 VO (EG) 178/2002 |
| `ORIGIN` | Pflicht | `SiteSettings.originCountry` | HonigV § 3 |
| `FOOD_BUSINESS` | Pflicht | `foodBusinessName` und `foodBusinessAddress` sind gesetzt und beide im Text | LMIV Art. 8, Art. 9 Abs. 1 lit. h |
| `LOT_PREFIX` | Warnung | vor der Losnummer steht das Wort „L“ | LKV |
| `X_HEIGHT` | Warnung | jedes Element mit einer Pflichtangabe hat eine x-Höhe ≥ `minXHeightMm` | LMIV Art. 13 Abs. 2, 3, Anhang IV |
| `NET_QUANTITY_HEIGHT` | Warnung | das Element mit der Füllmenge hat eine Versalhöhe ≥ Regel aus `netQuantityDigitRules` | Fertigpackungsverordnung / RL 76/211/EWG (Annahme) |
| `TEXT_CLIPPED` | Warnung | kein Element mit Pflichtangabe ist abgeschnitten | – |
**Schriftmetriken** kommen aus den eingebauten PDF-Schriften (AFM: x-Höhe, Versalhöhe je Schnitt). Nichts davon ist hartkodiert. Formel: Höhe in mm = Schriftgröße in pt × Metrik / 1000 × 25,4 / 72. Beispiele (Helvetica, x-Höhe 523, Versalhöhe 718):
- 6 pt → x-Höhe 1,11 mm → Warnung. 7 pt → 1,29 mm → in Ordnung.
- 500-g-Glas, Regel 4 mm → Füllmenge ab 15,8 pt.
**`netQuantityDigitRules`** (Vorgabe in der Migration, Annahme – vom Inhaber zu prüfen): `[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]`.
**Ergebnis** (`LabelCheckResult`): `{ printable: boolean; gaps: LabelFinding[]; warnings: LabelFinding[]; sample: boolean }`, mit `LabelFinding = { code; title; detail; elementIds: string[] }`. Titel auf Deutsch, kurz: „Füllmenge fehlt“, „Text zu klein (x-Höhe 1,1 mm)“. `sample: true` heißt: Prüfung mit Beispielwerten, weil keine Abfüllung gewählt ist.
**Druck.** `renderLabelsPdf` ruft zuerst die Prüfung auf.
- Pflichtlücke und kein Probedruck → `409 { code: 'LABEL_PARTICULARS_MISSING', gaps }`.
- Probedruck (`?proof=true`) → PDF mit diagonalem Aufdruck „MUSTER – nicht verkaufsfähig“ in 50 % Grau auf jedem Etikett.
- Jeder Druck schreibt ein Audit `PRINT` am `BottlingBatch`: `{ templateId, copies, proof, gapCodes, warningCodes }`. So ist belegt, mit welcher Vorlage welches Los gedruckt wurde.
### 5.5 Behördenpaket
**Bestandsbild** (`HerdSnapshot`, pure Funktion `buildHerdSnapshot` in `src/compliance/herd-snapshot.ts`):
```ts
interface HerdSnapshot {
version: 1;
takenAt: string; // ISO
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;
}
```
Gezählt werden lebende Völker über `LIVING_COLONY_WHERE` (Annahme 21) an aktiven Ständen.
**Bezugsstand** ist die jüngste `SENT`-Anzeige der Art `REGISTRATION` oder `HERD_CHANGE` (nach `sentOn`). TSK-Zählnachweise zählen nicht: Sie gehen an einen anderen Empfänger.
**Abweichung** (`herdDeviation(baseline, current, settings)`):
- `noticeOnApiaryChange`: ein Stand neu, aufgegeben, oder Adresse, Ort bzw. Koordinaten geändert.
- `noticeOnColonyCountChange`: `totalLiving` anders. Ein Umsetzen zwischen Ständen ändert die Summe nicht und löst darum nichts aus. Das PDF zeigt die Verteilung trotzdem.
- Ohne Bezugsstand gibt es keine Abweichung, sondern den Punkt „Erstanzeige fehlt“.
**Lebenszyklus einer Anzeige:**
```
mark-sent reopen
DRAFT ───────────────▶ SENT ───────────────▶ DRAFT
│
└── cancel ──▶ CANCELLED
```
- **Anlegen:** `REGISTRATION` nur ohne Bezugsstand. `HERD_CHANGE` nur mit Bezugsstand. `TSK_REPORT` braucht `year`. Mit `sentOn` im Anlegen entsteht die Anzeige gleich als `SENT`. Das ist der Weg „bereits angezeigt am …“ für den Altbestand.
- **Neu zählen** ersetzt Bestandsbild und Summe, nur im Entwurf.
- **Mehrfach-Sicherheit:** Anlegen sperrt die `ComplianceSettings`-Zeile (`SELECT … FOR UPDATE`) als Mutex. Dazu kommt `openKey` als nullbares `@unique`. Ein zweiter Entwurf derselben Art und desselben Jahres ergibt 409 mit der ID des vorhandenen.
**TSK-Zählung.** Die tägliche Auswertung erzeugt am Stichtag `TskReferenceDayReached`. Der Handler legt den Entwurf an und zählt sofort. Liegt der Zähltag nach dem Stichtag (Job ausgefallen, Nachholen bis 7 Tage), steht im PDF: „Gezählt am {countedAt}, nicht am Stichtag. Bitte prüfen.“ Gibt es für das Jahr schon eine `SENT`-Zählung, passiert nichts. Fehlt der Entwurf ganz (mehr als 7 Tage Ausfall oder neu eingeführt), zeigt die Prüfbereitschaft den Punkt trotzdem. Der Knopf „Jetzt zählen“ legt ihn an. Völkerarten in `tskExcludedColonyTypes` zählen nicht.
### 5.6 Sperrbezirk
- **Aktiv** ist eine Sperre, wenn `startsOn` ≤ Tag ≤ `endsOn` gilt (oder `endsOn` leer ist). „Tag“ ist der **Berliner Kalendertag** (`berlinToday()`, Inhaber-Entscheidung 2026-10-15; nicht UTC). Es gibt keinen `dbUtcDay`.
- **Gemeinsamer Helfer** `assertNoActiveRestriction(tx, apiaryIds, day, override)` in `src/compliance/restricted-zone.ts`. Er wirft `409 { code: 'RESTRICTED_ZONE', message, restrictionIds, restrictions }`.
| Vorgang | Wirkung |
|---|---|
| Volk umsetzen (`move`) | Sperre, wenn Quell- oder Zielstand gesperrt ist |
| Volk anlegen im gesperrten Stand | Sperre (Hineinbringen) |
| Stand in „Volk bearbeiten“ korrigieren | Sperre, wenn alter oder neuer Stand gesperrt ist |
| Vereinigen | Sperre, wenn die Völker an verschiedenen Ständen stehen und einer davon gesperrt ist |
| Ableger bilden | keine Sperre: Der Ableger bleibt am Stand |
| Ernte am gesperrten Stand | nur Warnung im Formular (Inhaber 2026-10-13) |
- **Ausnahme:** Feld `restrictionOverrideReason` (10–500 Zeichen) an `CreateColonyDto`, `UpdateColonyDto`, `MoveColonyDto` und `MergeColonyDto`.
- Nur mit `compliance:manage`. Der Service prüft das über `PermissionResolverService.getPermissions`, sonst 403 - **immer**, auch wenn gar keine Sperre aktiv ist, und gleich zu Beginn der Anfrage. Ohne Passwort-Abfrage (Inhaber-Entscheidung 2026-10-15).
- Die Begründung landet im Audit-Eintrag und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“).
- Das Feld wird nicht am Volk gespeichert. Für die Null-Regel gilt `null` wie weggelassen. `update-dto-null-handling.spec.ts` hält das fest.
- **Gleichzeitigkeit:** Die Völker-Pfade sperren zuerst die Stände (`FOR SHARE`, aufsteigend), dann das Volk; ändert ein Volk zwischen ungesperrtem Lesen und Sperre seinen Stand, antwortet der Server 409 „Der Stand wurde gerade geändert. Bitte neu laden.“ (kein automatischer Neuversuch). `ApiaryRestrictionsService` sperrt die Stände `FOR UPDATE`.
- **CSV-Import:** Völker, die der Import in einem gesperrten Stand anlegen würde, werden als Zeilenfehler gemeldet (Sperrbezirk-Meldung, nichts geschrieben); der Import hat keine Ausnahme.
- **Lesen:** `GET /apiary-restrictions` und `GET /apiaries/:id/restrictions` liefern Behörde, Aktenzeichen, Notiz und Ersteller nur mit `compliance:read`; mit nur `apiaries:read` gibt es `id`, `apiaryId`, `disease`, `startsOn`, `endsOn`.
- **Erfassen:** `POST /apiary-restrictions` mit `apiaryIds` (1–50) legt eine Zeile je Stand in einer Transaktion an. Beendet wird eine Sperre über `endsOn`. Gelöscht wird nichts.
### 5.7 Prüfbereitschaft
`ComplianceReadinessService.evaluate(now)` lädt die Daten begrenzt und ruft je Bereich eine pure Regel in `src/compliance/rules/*.rule.ts` auf:
```ts
type ReadinessSeverity = 'PFLICHT' | 'PRUEFEN';
interface ReadinessItem {
key: string; // z. B. 'treatment-log-gaps'
area: 'STAMMDATEN' | 'BEHOERDEN' | 'TIERARZNEIMITTEL' | 'ETIKETTEN' | 'ERNTE';
severity: ReadinessSeverity;
subjectId: string; // z. B. Jahr, Packung, Vorlage; 'global' bei Einzelpunkten
title: string; // Deutsch, ein kurzer Satz
detail: string;
legalBasis: string | null;
acknowledgeable: boolean;
fingerprint: string; // sha256 der für den Befund maßgeblichen Werte
subjects: { type: string; id: string; label: string; missing?: string[] }[]; // höchstens 50, Rest in `more`
more: number;
}
```
- **Gesamtstatus:** `READY`, wenn kein Punkt offen ist, sonst `OPEN`. Offen ist jeder Punkt ohne gültige Quittung.
- **Quittieren** geht nur bei `acknowledgeable: true`. Die Notiz ist Pflicht (mindestens 10 Zeichen). Der Fingerabdruck muss zum aktuell berechneten passen, sonst 409. Ändern sich die Daten, ist der Punkt wieder offen. Eine Quittung kann zurückgenommen werden (`revokedAt`).
- **Links:** Das Backend liefert `subjects` mit Typ und ID, keine UI-Pfade. Das Frontend bildet sie an einer Stelle auf Seiten ab (`lib/compliance/fix-links.ts`).
- **Begrenzung:** Behandlungen der letzten 6 Kalenderjahre, Ernten der letzten 2 Jahre (`HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2`; Annahme: Honig wird innerhalb von 2 Jahren verkauft), nur benötigte Spalten.
| Schlüssel | Bereich | Schwere | Offen, wenn | Beheben | Norm | quittierbar |
|---|---|---|---|---|---|---|
| `holder-data` | Stammdaten | Pflicht | Name oder Anschrift des Bienenhalters leer | Stammdaten | Art. 108 VO (EU) 2019/6 | nein |
| `food-business-data` | Stammdaten | Pflicht | Name oder Anschrift des Lebensmittelunternehmers leer | Stammdaten („Vom Bienenhalter übernehmen“) | LMIV Art. 8 | nein |
| `vet-office` | Stammdaten | Prüfen | Veterinäramt (Empfänger) leer | Einstellungen | – | nein |
| `registration` | Behörden | Pflicht | keine `SENT`-Erstanzeige | Anzeige erstellen oder „bereits angezeigt am …“ | BienSeuchV § 1a | nein |
| `herd-change` | Behörden | Pflicht | Abweichung zum Bezugsstand laut Einstellung | Änderungsanzeige | BienSeuchV § 1a (Umfang: Annahme) | nein |
| `tsk-report` | Behörden | Pflicht | Stichtag des Jahres erreicht, keine `SENT`-Zählung; nach `tskReportDueDay` mit „überfällig“ | Zählung öffnen oder anlegen | Landesrecht Bayern (Annahme) | nein |
| `restriction-active` | Behörden | Pflicht | Stand mit aktiver Sperre | Verfügung ansehen; Ende eintragen, wenn aufgehoben | BienSeuchV § 10 | nein |
| `treatment-log-gaps` | Tierarzneimittel | Pflicht | Bestandsbuch-Zeilen eines Jahres mit Lücke (ein Punkt je Jahr; Behandlungen aus derselben lückenhaften Packung erscheinen einmal als Packung) | Behandlung oder Packung bearbeiten | Art. 108 VO (EU) 2019/6 | nein |
| `treatment-location-uncertain` | Tierarzneimittel | Prüfen | Zeile mit `*` (Stand am Behandlungstag unsicher) | Stand bestätigen (setzt `Treatment.apiaryId`) | Art. 108 (Identifizierung) | nein |
| `lot-gaps` | Tierarzneimittel | Pflicht | Packung ohne Lieferant oder Beleg | Packung bearbeiten | Art. 108 | nein |
| `lot-prescription` | Tierarzneimittel | Pflicht | verschreibungspflichtiges Mittel, Packung ohne Tierarzt | Packung bearbeiten | Art. 108 | nein |
| `medicine-withdrawal-missing` | Tierarzneimittel | Prüfen | aktiver Katalogeintrag ohne Wartezeit | Mittel bearbeiten | Annahme 10 | nein |
| `lot-expired-stock` | Tierarzneimittel | Prüfen | Verfall überschritten, Rest > 0 | Entsorgung buchen | – | ja |
| `lot-negative` | Tierarzneimittel | Prüfen | Rest < 0 | Inventur oder fehlenden Erwerb erfassen | – | nein |
| `treatment-expired-lot` | Tierarzneimittel | Prüfen | Behandlung nach Verfall der Packung | prüfen | – | ja |
| `label-template-gaps` | Etiketten | Pflicht | aktive Vorlage mit Pflichtlücke, geprüft gegen die jüngste Abfüllung mit Restbestand (sonst die jüngste, sonst Beispielwerte) | Designer | LMIV, HonigV, LKV | nein |
| `label-template-form` | Etiketten | Prüfen | aktive Vorlage mit Warnung | Designer | LMIV Art. 13; Fertigpackungsverordnung (Annahme) | ja |
| `harvest-withdrawal` | Ernte | Prüfen | Ernte in einem aktiven Wartezeit-Fenster eines beteiligten Volks (Logik von `getWithdrawalStatus`) | prüfen, Notiz | Wartezeit laut Behandlung (Annahme 10) | ja |
## 6. Ereignisse, Handler, tägliche Auswertung
### 6.1 Neue Ereignistypen
`DomainEventPayloads`, `CATALOG` und `AggregateType` (+ `'Apiary'`, `'Compliance'`) in `src/domain-events/domain-event-types.ts` werden erweitert. Payloads enthalten nur IDs und Werte, Daten als `YYYY-MM-DD`.
| Ereignis | Payload | Aggregat | Erzeuger |
|---|---|---|---|
| `ColonyCreated` | `{ apiaryId }` | Colony | `ColoniesService.create`, `split` (für den Ableger) |
| `ColonyMoved` | `{ fromApiaryId, toApiaryId }` | Colony | `move`, `update` mit Standwechsel |
| `ApiaryChanged` | `{ change: 'CREATED' \| 'DEACTIVATED' \| 'REACTIVATED' \| 'LOCATION_CHANGED' }` | Apiary | `ApiariesService.create`, `deactivate`, `reactivate`, `update` (Adresse, Ort, Koordinaten) |
| `TskReferenceDayReached` | `{ year, referenceDay }` | Compliance, ID `tsk:<year>` | Job `daily-evaluation` |
`ApiariesService` schreibt dafür wie die anderen Fachservices: Datensatz, Audit (`auditLog.record(…, tx)`) und Ereignis in einer Transaktion. Der Webhook `colony.created` bleibt im Altpfad (Rückgrat Task 13). Die neuen Ereignisse gehen nicht an Webhooks: Behördendaten verlassen das System nicht.
### 6.2 Handler (`src/compliance/compliance.handlers.ts`)
| Handler-ID | Ereignisse | Wirkung (nur über `tx`) |
|---|---|---|
| `compliance.herd-change` | `ColonyCreated`, `ColonyMoved`, `ColonyEnded`, `ColonyStatusChanged` (nur beim Wechsel lebend ↔ beendet), `ApiaryChanged` | Bestandsbild und Abweichung berechnen. Bei Abweichung: Meldung `HINWEIS` „Bestand geändert: jetzt 14 Völker an 3 Ständen. Angezeigt: 12 an 2. Anzeige ans Veterinäramt prüfen.“ an `compliance:manage`. |
| `compliance.tsk-reference-day` | `TskReferenceDayReached` | Entwurf `TSK_REPORT` anlegen (Mutex, `openKey`), sofort zählen, Meldung `WARNUNG` „TSK-Stichtag 01.01.2027: 14 Völker gezählt. Bitte prüfen und melden.“ |
- **Dubletten:** Neuer Helfer `NotificationsService.recordForPermissionOnce(permission, input, tx)`. Er überspringt Nutzer, die zu `relatedType = 'ComplianceItem'` und derselben `relatedId` (`herd-change`, `tsk-report:2027`) schon eine ungelesene Meldung haben. Derselbe Helfer passt für die geplante Dublettensperre bei gescheiterten Jobs (Rückgrat Task 15, Carry-forward).
- **Handler-Vertrag:** keine Netzwerk-Clients; der Wächter `handler-contract.spec.ts` deckt die Datei ab.
- **Zustellweg:** Die Meldungen sind normale `Notification`-Zeilen. Später kann sie der Telegram-Bot-Kanal aus der T4-Spec zustellen. In T6 ist dafür nichts zu bauen.
### 6.3 Tägliche Auswertung
Neue Regel in `src/jobs/daily-evaluation.rules.ts`:
- Für das laufende und das vorige Jahr gilt Tag = `<Jahr>-<tskReferenceDay>`.
- Liegt der Tag im Nachholfenster (`inCatchUpWindow`), entsteht `TskReferenceDayReached` mit `dailyDedupeKey('TskReferenceDayReached', 'tsk:<Jahr>', Tag)`. Es entsteht höchstens einmal.
- Das Ergebnis im `JobRun` bekommt `TskReferenceDayReached: n`.
- Abgelaufene Packungen und endende Sperren brauchen kein Ereignis. Die Prüfbereitschaft rechnet sie live.
## 7. API
Alle Pfade unter `/api/v1`. Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können, tragen `@Idempotent()` (Annahme 11). Update-DTOs folgen der Null-Regel (Annahme 20).
| Methode | Pfad | Recht | Zweck |
|---|---|---|---|
| GET | `/veterinary-medicines?active=` | `veterinary-medicines:read` | Katalog mit Gesamtrest und nächstem Verfall |
| GET | `/veterinary-medicines/suggestions` | `veterinary-medicines:read` | Übernahme-Vorschläge aus eigenen Behandlungen |
| GET | `/veterinary-medicines/:id` | `veterinary-medicines:read` | Eintrag mit Packungen und Rest |
| POST | `/veterinary-medicines` | `veterinary-medicines:create` | anlegen, idempotent |
| PATCH | `/veterinary-medicines/:id` | `veterinary-medicines:update` | bearbeiten |
| POST | `/veterinary-medicines/:id/deactivate`, `/reactivate` | `veterinary-medicines:update` | Historie statt Löschen |
| GET | `/veterinary-medicine-lots?medicineId=&selectable=true` | `veterinary-medicines:read` | Packungen, älteste zuerst (für das Behandlungsformular) |
| GET | `/veterinary-medicine-lots/:id` | `veterinary-medicines:read` | Packung mit Bewegungsbuch |
| POST | `/veterinary-medicine-lots` | `veterinary-medicines:create` | Erwerb erfassen, idempotent |
| PATCH | `/veterinary-medicine-lots/:id` | `veterinary-medicines:update` | bearbeiten |
| POST | `/veterinary-medicine-lots/:id/movements` | `veterinary-medicines:update` | `{ type: 'CORRECTION', actualRemaining }` oder `{ type: 'DISPOSAL', quantity, reason }`, idempotent |
| POST, PATCH | `/treatments`, `/treatments/bulk`, `/treatments/:id` | wie bisher | + `medicineId`, `medicineLotId`, `doseQuantity`; PATCH + `apiaryId` |
| POST | `/label-templates/check` | `label-templates:read` | Prüfung eines ungespeicherten Entwurfs: Text-Elemente, Maße, `minXHeightMm`, optional `bottlingBatchId` |
| GET | `/label-templates/:id/check/:bottlingBatchId` | `label-templates:read` | Prüfung vor dem Druck |
| GET | `/label-templates/:id/print/:bottlingBatchId?startPosition=&proof=` | `label-templates:print` | Druck; 409 bei Pflichtlücke ohne `proof` |
| GET | `/compliance/readiness` | `compliance:read` | alle Punkte mit Status |
| GET | `/compliance/readiness/summary` | `compliance:read` | `{ status, openCount, firstItemKey }` fürs Dashboard |
| GET | `/compliance/readiness/report.pdf` | `compliance:read` | Prüfbericht |
| POST | `/compliance/acknowledgements` | `compliance:manage` | `{ itemKey, subjectId, fingerprint, note }`, idempotent |
| POST | `/compliance/acknowledgements/:id/revoke` | `compliance:manage` | Quittung zurücknehmen |
| GET, PATCH | `/compliance/settings` | `compliance:read` / `compliance:manage` | Einstellungen |
| GET | `/authority-notices?kind=&year=` | `compliance:read` | Liste |
| GET | `/authority-notices/:id`, `/authority-notices/:id/pdf` | `compliance:read` | Anzeige, PDF |
| POST | `/authority-notices` | `compliance:manage` | `{ kind, year?, referenceDate?, sentOn? }`, idempotent |
| PATCH | `/authority-notices/:id` | `compliance:manage` | `totalColonies`, `referenceDate`, `sentOn`, `sentVia`, `reference`, `notes` |
| POST | `/authority-notices/:id/recount`, `/mark-sent`, `/reopen`, `/cancel` | `compliance:manage` | Zustandswechsel nach 5.5 |
| GET | `/apiary-restrictions?active=true` | `apiaries:read` | aktive Sperren (für Umsetzen- und Ernte-Dialog) |
| GET | `/apiaries/:id/restrictions` | `apiaries:read` | Sperren eines Stands |
| POST | `/apiary-restrictions` | `compliance:manage` | `{ apiaryIds, disease, authority?, reference?, startsOn, endsOn?, notes? }`, idempotent |
| PATCH | `/apiary-restrictions/:id` | `compliance:manage` | bearbeiten, beenden |
| POST, PATCH | Völker `create`, `update`, `move`, `merge` | wie bisher | + `restrictionOverrideReason` |
Sperren lesen mit `apiaries:read`: Wer Völker bewegt, muss die Sperre sehen. Schreiben erfordert `compliance:manage`.
## 8. PDFs
Alle PDFs entstehen mit pdfkit und den Helfern aus `src/export/pdf-table.ts` (`PDF_MARGIN`, `drawTable`, `formatDay`, `orDash`). Fehlende Pflichtangaben stehen als „FEHLT“ da, wie im Bestandsbuch. Antworten bleiben `Cache-Control: no-store`.
### 8.1 Anzeige ans Veterinäramt (`REGISTRATION`, `HERD_CHANGE`)
A4 hoch. Quellen: `SiteSettings` (Halter), `ComplianceSettings` (Empfänger, Registriernummer), `AuthorityNotice.snapshot`, Bestandsbild des Bezugsstands.
```
┌──────────────────────────────────────────────────────┐
│ {holderName} · {holderAddress} │
│ Registriernummer: {registrationNumber | –} │
│ │
│ An {veterinaryOfficeName | FEHLT} │
│ {veterinaryOfficeAddress} │
│ {Ort}, {Datum} │
│ Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV) │
│ Stand der Völker am {referenceDate} │
│ ┌───────────┬────────────────────────┬──────┬───────┐ │
│ │ Stand │ Lage (Adresse/Koord.) │Völker│ vorher│ │
│ ├───────────┼────────────────────────┼──────┼───────┤ │
│ │ {name} │ {address | lat, long} │ 8 │ 6 │ │
│ │ … │ │ │ │ │
│ │ Summe │ │ 14 │ 12 │ │
│ └───────────┴────────────────────────┴──────┴───────┘ │
│ Änderungen seit der Anzeige vom {sentOn}: │
│ – Stand „{name}“ neu – Stand „{name}“ aufgegeben │
│ │
│ _________________________ Unterschrift │
│ Stand der Daten {updatedAt} · erstellt mit ApiCore │
└──────────────────────────────────────────────────────┘
```
Die Erstanzeige hat die Überschrift „Anzeige der Bienenhaltung (§ 1a BienSeuchV)“ und keine Spalte „vorher“. Koordinaten mit 5 Nachkommastellen, nur wenn keine Adresse da ist.
### 8.2 Zählnachweis zum TSK-Stichtag (`TSK_REPORT`)
A4 hoch. Kopf wie 8.1, mit `tskMemberNumber`. Überschrift „Zählnachweis Bienenvölker zum Stichtag {Datum}“. Tabelle je Stand mit Spalten je gezählter Völkerart und Summe. Darunter „Gezählt am {countedAt}“, gegebenenfalls der Hinweis aus 5.5, und fest: „Ersetzt nicht die Meldung an die Tierseuchenkasse.“
### 8.3 Bestandsbuch
Layout bleibt. Neu sind die Quellen aus 5.3 und die Fußnote. Ein Bestandsbuch mit vollständigen Daten enthält kein „FEHLT“.
### 8.4 Probedruck der Etiketten
Wie der normale Druck, plus diagonaler Aufdruck „MUSTER – nicht verkaufsfähig“ je Etikett. Zum Ausrichten am Drucker, nie zum Verkauf.
### 8.5 Prüfbericht
A4 hoch: Kopf mit Halter und Datum, Gesamtstatus, dann je Bereich die Punkte als Tabelle (Status, Schwere, Titel, Norm, bei Quittungen Notiz, Nutzer, Datum). Für den Kontrolltermin.
## 9. Oberfläche
Grundsätze: Deutsch, kurze Sätze, eine Hauptaktion je Ansicht (`DetailHeader`), seltene Felder eingeklappt (`FormSection`), Felder 44 px. WCAG 2.2 AA:
- Status nie nur über Farbe, immer Symbol plus Wort („Offen“, „Erledigt“).
- Roter Text mit mindestens 4,5:1 Kontrast (Token `destructive`, mit T5 geprüft).
- Die Prüfliste im Designer meldet Änderungen über `aria-live="polite"`.
- Ein gesperrter Druckknopf bleibt fokussierbar (`aria-disabled`) und nennt den Grund über `aria-describedby`.
- Nach einem 409 springt der Fokus auf die Lückenliste.
| Seite / Stelle | Inhalt | Hauptaktion |
|---|---|---|
| `/compliance` „Prüfbereitschaft“ (neu, Nav „Betrieb“, Symbol `ShieldCheck`, Recht `compliance:read`) | Status oben („Prüfbereit“ grün oder „3 offen“ rot). Punkte nach Bereich, jeder offene rot mit Symbol, „Offen“, Schwere-Chip „Pflicht“/„Prüfen“, Titel, eine Zeile Detail, Norm klein. Aufklappbar die betroffenen Datensätze mit „Beheben“. „Erledigt (n)“ eingeklappt. Unter „Mehr“: Bericht als PDF, Bestandsbuch, Einstellungen. | „Nächsten Punkt lösen“ (springt zum ersten offenen Pflicht-Punkt). Alles grün: „Bestandsbuch {Jahr} drucken“. |
| `/compliance/notices`, `/compliance/notices/[id]` | Liste; Detail mit drei Schritten „1. PDF öffnen · 2. ans Amt schicken · 3. hier bestätigen“ | je nach Schritt „PDF öffnen“ oder „Als gesendet markieren“ |
| `/compliance/restrictions` | aktive und beendete Sperren; Formular mit Mehrfachauswahl der Stände, Krankheit über `ChoiceGroup` (Amerikanische Faulbrut, Kleiner Beutenkäfer, Andere mit Freitext), Verfügung per `DocumentUploadCard` | „Sperrbezirk eintragen“ |
| `/compliance/settings` | Veterinäramt, Registriernummer, Auslöser, TSK-Stichtag, Frist, Mitgliedsnummer; „Erweitert“ eingeklappt: gezählte Völkerarten, Ziffernhöhen. Jede Annahme mit Vermerk „Annahme – bitte prüfen“. | „Speichern“ |
| `/medicines`, `/medicines/[id]`, `/medicines/lots/[id]` (neu; erreichbar über Lager, Behandlungsformular und Prüfbereitschaft; kein eigener Nav-Eintrag) | Katalog mit Rest und Verfall; leerer Zustand „Aus deinen Behandlungen übernehmen?“ mit Vorschlägen; Packung mit Bewegungsbuch und Beleg-Upload | Liste: „Mittel anlegen“; Mittel: „Packung erfassen“; Packung: „Inventur“ |
| Behandlung neu und Sammelerfassung | „Mittel“ als `RecordSelect` aus dem Katalog mit letzter Option „Anderes Mittel“ (dann Freitext wie heute, T3-Vorbelegung bleibt). Bei Katalog: Wirkstoff, Wartezeit, Methode, Dosis vorbelegt mit `PrefillNote` „aus Katalog“. Packung als `RecordSelect` („Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027“), älteste vorgewählt mit Notiz. Dosis im `NumberStepper` mit Einheit. Statt Lieferant, Beleg, Tierarzt eine Zeile „aus Packung: …“. Über dem Speichern: „Bucht 30 ml je Volk ab (× 6 = 180 ml).“ Temperatur: „Heute 9–17 °C. Laut Katalog 12–25 °C.“ mit Warnsymbol, wenn außerhalb. | „Speichern“ |
| Etiketten-Designer | neue Komponente `LabelCompliancePanel` (eigene Datei, der Designer hat schon 695 Zeilen): Liste der Pflichtangaben mit ✓/✗ und Warnungen, Prüfung entprellt 500 ms über `POST /label-templates/check` (nur Text-Elemente); Knopf „Pflichtangaben-Block einfügen“ setzt einen Block mit allen neuen Platzhaltern in Mindestgrößen ein (Rückgängig wie gewohnt); Feld „Mindest-x-Höhe“ mit Hinweis auf 0,9 mm bei kleinen Gläsern | „Speichern“ |
| Druckkarte der Abfüllung | prüft vorher; bei Lücken rote Liste mit Links (Stammdaten, Produkt, Designer) | „Etiketten als PDF“, bei Lücken gesperrt mit Grund; daneben „Probedruck (MUSTER)“ |
| Stammdaten & Web (bestehender Tab) | Felder Lebensmittelunternehmer, Knopf „Vom Bienenhalter übernehmen“ (füllt sichtbar, speichert erst beim Speichern) | wie bisher |
| Produkt-Formular | Feld „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“ | wie bisher |
| Umsetzen-Dialog, Volk anlegen, Vereinigen | rote Box „Stand {Name} liegt im Sperrbezirk ({Krankheit}, seit {Datum}). Umsetzen ist gesperrt.“; mit `compliance:manage` aufklappbar „Ausnahme mit Begründung“ (Textfeld, Rückfrage im `ConfirmDialog`); fester Hinweis zu BienSeuchV § 5 (2.2) | „Umsetzen“ (gesperrt, solange die Sperre gilt und keine Ausnahme begründet ist) |
| Stand-Detail | rotes Band bei aktiver Sperre mit Link | wie bisher |
| Ernte-Formular | Warnung bei gesperrtem Stand: „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“ | „Speichern“ (nicht gesperrt) |
| Dashboard „Heute“ | eine Zeile „Prüfbereitschaft: 3 offen“ mit Link, nur bei `openCount > 0` und `compliance:read` | – |
| Lager | Karte „Tierarzneimittel“ mit Link zu `/medicines` | – |
## 10. Fehlerfälle
| Fall | Verhalten |
|---|---|
| Packung reicht nicht für die Dosis | Behandlung wird gespeichert, Rest wird negativ, Prüfbereitschaft „Rest negativ“ |
| Packung abgelaufen | Behandlung wird gespeichert; Formular warnt vorher; Prüfbereitschaft „Prüfen“ |
| Zwei Inventuren gleichzeitig auf einer Packung | `FOR UPDATE` auf die Packung; die zweite rechnet mit dem neuen Rest |
| Einheit eines Mittels mit Packungen ändern | 409 „Einheit ist festgelegt, sobald Packungen erfasst sind“ |
| Nutzer mit `treatments:create`, ohne `veterinary-medicines:read` | Formular zeigt nur „Anderes Mittel“ (Freitext); kein Fehler |
| Etikett ohne Produkt an der Abfüllung | `legalName` leer; erfüllt nur, wenn ein fester Text „honig“ enthält |
| Stammdaten Lebensmittelunternehmer leer | Pflichtlücke `FOOD_BUSINESS` mit Link zu den Stammdaten |
| Druck mit Pflichtlücke | 409 `LABEL_PARTICULARS_MISSING` mit `gaps`; Probedruck bleibt möglich |
| Check-Anfrage mit Bild-Elementen | DTO nimmt nur Text-Elemente an; 400 bei anderem Typ. Das Frontend filtert vorher. |
| Umsetzen in oder aus gesperrtem Stand | 409 `RESTRICTED_ZONE` mit Stand, Krankheit, Beginn |
| Ausnahme ohne Recht / zu kurze Begründung | 403 / 400 |
| Zweiter Entwurf derselben Anzeige | 409 mit `existingId` |
| `recount` auf `SENT`, `mark-sent` auf `CANCELLED` | 409 |
| Änderungsanzeige ohne Erstanzeige | 409 „Erst die Erstanzeige erfassen“ |
| Stichtag ungültig (`02-30`, `02-29`, `1-1`) | 400; `02-29` bewusst abgelehnt, weil es nicht jedes Jahr gibt |
| Job `daily-evaluation` fällt länger als 7 Tage aus | kein Ereignis; Prüfbereitschaft zeigt `tsk-report` trotzdem; „Jetzt zählen“ legt den Entwurf an |
| Handler `compliance.tsk-reference-day` läuft doppelt | Mutex + `openKey`: ein Entwurf; `SENT` vorhanden → nichts |
| Handler scheitert | Wiederholung nach Rückgrat-Regel; nach 8 Versuchen `FAILED` in „Verwaltung > System“ |
| Quittung mit veraltetem Fingerabdruck / für einen Pflicht-Punkt | 409 / 400 |
| Dokument zu neuer Art, Objekt fehlt | 404 wie bisher |
| Veterinäramt nicht eingetragen | PDF druckt „FEHLT“; Prüfbereitschaft `vet-office` |
## 11. Sicherheit
| Thema | Maßnahme |
|---|---|
| Anschriften, Koordinaten, Belege, Aktenzeichen | nur mit `compliance:read` bzw. `veterinary-medicines:read`; nie in öffentlichen Endpunkten, nie im Shop-Feed, nie in Webhooks oder Ereignis-Payloads. Test in `public.service.spec.ts`: Herkunftsseite enthält keine Sperr- oder Anzeigedaten. |
| Ausnahme vom Sperrbezirk | eigenes Recht, Pflicht-Begründung, Audit in derselben Transaktion |
| Rechte | fail-closed `PermissionsGuard` je Endpunkt; `API_CLIENT` bekommt keine T6-Rechte |
| Eingaben | class-validator mit `MaxLength` für jeden Freitext (Namen 200, Anschriften 300, Begründungen 500, Notizen 2000); Mengen `IsPositive` bzw. endlich; `apiaryIds` höchstens 50, IDs im cuid-Zeichensatz wie in `WithdrawalStatusQueryDto` |
| Check-Endpunkt | nur Text-Elemente, Body-Limit 5 MB wie global; Standard-Rate-Limit 100/min reicht bei 500 ms Entprellung |
| Logs, `lastError` | nur `loggableError()`, nie Payload oder Freitext |
| PDFs | `no-store`; Dateinamen ohne Nutzereingaben (`anzeige-<id>.pdf`) |
| Gesetzesdaten | Wächter: Der Seed legt keine Tierarzneimittel an. |
## 12. Tests
TDD wie im Rückgrat-Plan: erst der fehlschlagende Test.
**Unit (Jest)**
- `medicine-ledger.spec.ts`: `summarizeLot rounds to thousandths`, `treatmentBookingDelta books the full dose on create`, `moves the booking when the lot changes`, `reverses the booking when dose or lot is cleared`, `books nothing when nothing changed`.
- `treatments.service.spec.ts`: `create books the dose from the lot in the same transaction`, `rejects a lot of another medicine`, `rejects supplier text together with a lot`, `takes the product name from the catalogue`, `never fills withdrawal or ingredient from the catalogue`, `bulk books one movement per colony`.
- `treatment-log.spec.ts`: `takes supplier, proof, batch and vet from the lot`, `prints the dose with unit when the amount text is empty`, `requires the vet only for prescription-only medicines`, `prints FEHLT exactly where treatmentLogGaps reports a gap`.
- `label-particulars.spec.ts`: `passes a label with all particulars`, `reports each missing particular by code`, `matches the address across line breaks and separators`, `flags a static "500 g" on a 250 g bottling`, `treats clipped text as missing`, `Helvetica 6 pt is x-height 1.11 mm (warning), 7 pt is 1.29 mm`, `needs 4 mm digits for 500 g with the default rules`, `warns on a lot without a leading L`.
- `label-templates.service.spec.ts`: `print refuses with 409 and the gap list`, `proof print adds the watermark and writes a PRINT audit entry`.
- `restricted-zone.spec.ts`: `active on the start and end day, inactive the day after`, `refuses moving into and out of a restricted apiary`, `override needs compliance:manage and a reason`, `split inside the apiary is allowed`, `merge across apiaries is refused`.
- `herd-snapshot.spec.ts`: `no deviation without changes`, `count change triggers only when enabled`, `moving between apiaries keeps the total`, `new, removed or relocated apiary triggers`, `no baseline means registration, not herd change`.
- `authority-notices.service.spec.ts`: `one open draft per kind and year`, `create with sentOn is SENT at once`, `recount only in DRAFT`, `herd change needs a baseline`.
- `daily-evaluation.rules.spec.ts`: `emits TskReferenceDayReached on the configured day`, `catches up within 7 days exactly once`, `covers the previous year's day in early January`.
- `compliance.handlers.spec.ts`: `herd-change notifies each permitted user once while unread`, `ignores a status change within living states`, `tsk handler creates one draft and skips a year already sent`.
- `rules/*.rule.spec.ts`: je Prüfpunkt offen, erledigt und quittiert.
- `compliance-acknowledgements.service.spec.ts`: `refuses a stale fingerprint`, `refuses a PFLICHT item`, `a revoked acknowledgement reopens the item`.
**e2e gegen die Test-DB**
- `test/veterinary-medicines.e2e-spec.ts`: Packung → Behandlung → Rest; Korrektur bucht gegen; Sammelerfassung; Rechte (STAFF darf Packung anlegen, READONLY nicht, API_CLIENT nichts).
- `test/treatment-log.e2e-spec.ts` (Erweiterung): vollständiger Katalog und Packung ergeben `treatmentLogGaps` leer für alle Zeilen.
- `test/label-compliance.e2e-spec.ts`: 409 → Stammdaten ergänzen → 200 und Audit `PRINT`.
- `test/restricted-zone.e2e-spec.ts`: 409 beim Umsetzen; Ausnahme mit Recht ergibt 200, Audit und Historien-Notiz.
- `test/authority-notices.e2e-spec.ts`: Volk anlegen → `drainDomainEvents` → eine Meldung; zweites Volk → keine zweite, solange ungelesen; Anzeige anlegen und senden → Prüfpunkt `herd-change` weg; PDF `application/pdf`.
- `test/daily-evaluation.e2e-spec.ts` (Erweiterung): Job zweimal am Stichtag → ein Ereignis, ein Entwurf.
- `test/compliance-permissions.e2e-spec.ts`: Vergabe der Migration je Rolle.
**Wächter**
- Backup-Wächter (bestehend) für alle neuen Modelle.
- `update-dto-null-handling.spec.ts` für alle neuen Update-DTOs und `restrictionOverrideReason`.
- `handler-contract.spec.ts` deckt `compliance.handlers.ts` ab.
- Neu `seed-no-medicines.spec.ts`: Der Seed legt keine Tierarzneimittel an.
- Platzhalter: Backend- und Frontend-Liste `PLACEHOLDER_KEYS` sind gleich (Vitest liest die Swagger-Enum-Werte aus einer Fixture).
**Frontend (Vitest + RTL)**
- Prüfbereitschaft: offene Punkte mit Symbol und Wort, Hauptaktion zeigt auf den ersten Pflicht-Punkt, Quittieren verlangt eine Notiz.
- Druckkarte: Hauptknopf `aria-disabled` mit Grund, Probedruck möglich.
- Designer-Panel: Live-Liste, `aria-live`, „Pflichtangaben-Block einfügen“.
- Behandlungsformular: Vorbelegung „aus Katalog“, älteste Packung vorgewählt, Lieferant-Felder ausgeblendet, Buchungszeile.
- Umsetzen-Dialog: Sperre sichtbar; Ausnahme nur mit Recht.
- Mittel-Liste: leerer Zustand mit Vorschlägen.
**Manuell (MP):** Desktop 1366 px und Handy 390 px: Prüfbereitschaft, Behandlung mit Packung, Etikett drucken, Umsetzen im Sperrbezirk.
## 13. Rollout
1. **Voraussetzungen:** Rückgrat-Runbook bis Schritt 3 live (Worker, CronJobs). T3 Task 4 ist gemergt.
2. **Image mit den drei Migrationen.** Alles additiv: neue Tabellen, nullbare Spalten, Enum-Werte. Der alte Pod läuft während des RollingUpdates mit dem alten Code weiter.
3. **Checkliste für den Inhaber, direkt nach dem Rollout** (ab dann sperrt der Etikettendruck bei Lücken; Probedruck bleibt):
1. Stammdaten: Lebensmittelunternehmer eintragen.
2. Produkte: Bezeichnung eintragen.
3. Etikettenvorlagen im Designer öffnen, „Pflichtangaben-Block einfügen“ oder Lücken schließen.
4. Einstellungen: Veterinäramt, Registriernummer. Stichtag und Frist der Tierseuchenkasse prüfen (Annahme).
5. Katalog aus den bisherigen Behandlungen übernehmen, aktuelle Packungen erfassen.
6. Erstanzeige: „bereits angezeigt am …“ mit dem echten Datum.
7. Prüfbereitschaft durchgehen, bis sie grün ist.
4. **Rückweg:** Ein Rollback des Codes lässt die neuen Tabellen ungenutzt. Es geht nichts verloren.
5. **Doku im selben Commit:** ARCHITECTURE bekommt vier neue Annahmen, jede mit der nächsten freien Annahme-Nummer zum Zeitpunkt des Commits (die Nummerierung teilen sich mehrere Teilprojekte): Katalog und Packungen, Etikett-Pflichtangaben, Behördenpaket und Sperrbezirk, Prüfbereitschaft. Außerdem Abschnitt 10 um die neuen Ereignisse; `docs/pruefung-2026-09.md` um einen Abschnitt „Prüfbereitschaft“; TASKS.md.
## 14. Aufwand
| Paket | PT |
|---|---|
| Datenmodell, drei Migrationen, Rechte, Backup | 0,5 |
| Katalog, Packungen, Bewegungsbuch, Vorschläge (Backend) | 1,0 |
| Behandlung: Prüfung, Buchung, Sammelerfassung, Bestandsbuch, Lücken-Helfer | 0,75 |
| Etikett: Platzhalter, Prüfregeln, Schriftmetriken, Druck-Sperre, Probedruck, Audit | 1,0 |
| Sperrbezirk: Modell, Wächter-Helfer in vier Völker-Pfaden, Ausnahme | 0,5 |
| Behördenpaket: Einstellungen, Anzeigen, zwei PDFs, Ereignisse, zwei Handler, Job-Regel | 1,25 |
| Prüfbereitschaft: 18 Regeln, Service, Quittungen, Bericht | 0,75 |
| Frontend: sechs neue Seiten, Behandlungsformular, Designer-Panel, Druckkarte, Dialoge, Banner | 2,5 |
| e2e, Wächter, Doku | 0,5 |
| **Summe** | **8,75 (≈ 9)** |
Der Fahrplan nannte etwa 6 PT. Die Wanderung (≈ 1 PT) entfällt. Dazu kommen:
- das Bewegungsbuch der Packungen (+1,5),
- die Form-Prüfung der Etiketten mit Schriftmetriken (+1),
- Quittungen und Prüfbericht (+0,5),
- die Ereignis-Erzeuger für Stände und Völker samt Sperrbezirk-Prüfung (+0,75).
Ein Lagerartikel statt Packungen hätte etwa 7,5 PT ergeben. Der Inhaber hat sich am 2026-10-13 für die Packungen entschieden.
## 15. Offene Fragen an den Inhaber
Keine. Alle fünf Fragen hat der Inhaber am 2026-10-13 entschieden. Die Entscheidungen stehen in der Tabelle in Abschnitt 1:
- Packungen mit eigenem Bewegungsbuch,
- Druck-Sperre nur bei fehlender oder abgeschnittener Pflichtangabe,
- eigene Felder „Lebensmittelunternehmer“,
- Ausnahme vom Sperrbezirk nur mit Begründung, Recht und Audit,
- Ernte im Sperrbezirk nur mit Warnung.
Offen bleiben nur die Annahmen aus Abschnitt 3. Sie sind Einstellungen, ändern also nichts am Bau. Der Inhaber prüft sie beim Rollout (Abschnitt 13, Schritt 3).