From b4746a2b174c61ed41fc6e1d7b66a006871d98f8 Mon Sep 17 00:00:00 2001
From: Nick Szaunig
Date: Thu, 15 Oct 2026 15:59:08 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20T6=20wave=205=20=E2=80=93=20readiness?=
=?UTF-8?q?=20checks=20and=20report,=20compliance=20and=20medicine=20UI,?=
=?UTF-8?q?=20label=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Readiness: all 18 checks (medicine lots, treatment log gaps, labels,
harvest withdrawal), medicine subjects masked without
veterinary-medicines:read, report PDF (counts only), summary cached 60 s,
409 codes READINESS_*.
- Frontend: /compliance page with acknowledge/revoke, dashboard line and
nav; veterinary medicine catalogue, lots, movement book with inventory
and disposal; label designer check panel, print card with gap list and
proof print, food business master data, product legal name.
- Service worker: cacheability is path-based (the backend sends no-store
on every answer); compliance, medicine, restriction, notice and label
check/print paths and restricted document kinds are never cached.
- ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
ARCHITECTURE.md | 72 ++-
.../acknowledgements.service.spec.ts | 71 ++-
.../readiness/acknowledgements.service.ts | 22 +-
.../readiness/loaders/harvests.loader.spec.ts | 92 ++++
.../readiness/loaders/harvests.loader.ts | 49 ++
.../readiness/loaders/labels.loader.spec.ts | 179 +++++++
.../readiness/loaders/labels.loader.ts | 125 +++++
.../readiness/loaders/lots.loader.spec.ts | 86 ++++
.../readiness/loaders/lots.loader.ts | 79 +++
.../loaders/treatments.loader.spec.ts | 142 +++++
.../readiness/loaders/treatments.loader.ts | 101 ++++
.../compliance/readiness/readiness-context.ts | 136 +++--
.../compliance/readiness/readiness-limits.ts | 18 +
.../readiness/readiness-report.spec.ts | 176 +++++++
.../compliance/readiness/readiness-report.ts | 154 ++++++
.../readiness/readiness.controller.ts | 25 +-
.../compliance/readiness/readiness.module.ts | 3 +-
.../readiness/readiness.service.spec.ts | 109 +++-
.../compliance/readiness/readiness.service.ts | 70 ++-
.../rules/food-business-data.rule.spec.ts | 2 +-
.../rules/harvest-withdrawal.rule.spec.ts | 140 +++++
.../rules/harvest-withdrawal.rule.ts | 51 ++
.../readiness/rules/herd-change.rule.spec.ts | 2 +-
.../readiness/rules/holder-data.rule.spec.ts | 2 +-
.../compliance/readiness/rules/index.spec.ts | 144 +++++-
.../src/compliance/readiness/rules/index.ts | 24 +-
.../readiness/rules/label-checks.ts | 47 ++
.../rules/label-template-form.rule.spec.ts | 93 ++++
.../rules/label-template-form.rule.ts | 40 ++
.../rules/label-template-gaps.rule.spec.ts | 102 ++++
.../rules/label-template-gaps.rule.ts | 39 ++
.../rules/lot-expired-stock.rule.spec.ts | 65 +++
.../readiness/rules/lot-expired-stock.rule.ts | 31 ++
.../readiness/rules/lot-gaps.rule.spec.ts | 59 +++
.../readiness/rules/lot-gaps.rule.ts | 46 ++
.../readiness/rules/lot-negative.rule.spec.ts | 56 ++
.../readiness/rules/lot-negative.rule.ts | 38 ++
.../rules/lot-prescription.rule.spec.ts | 53 ++
.../readiness/rules/lot-prescription.rule.ts | 36 ++
.../readiness/rules/medicine-rule-helpers.ts | 75 +++
.../medicine-withdrawal-missing.rule.spec.ts | 51 ++
.../rules/medicine-withdrawal-missing.rule.ts | 40 ++
.../readiness/rules/registration.rule.spec.ts | 2 +-
.../rules/restriction-active.rule.spec.ts | 2 +-
.../readiness/rules/rule-spec-helpers.ts | 122 -----
.../readiness/rules/rule.spec-helper.ts | 298 +++++++++++
.../rules/treatment-expired-lot.rule.spec.ts | 104 ++++
.../rules/treatment-expired-lot.rule.ts | 39 ++
.../treatment-location-uncertain.rule.spec.ts | 84 +++
.../treatment-location-uncertain.rule.ts | 40 ++
.../rules/treatment-log-gaps.rule.spec.ts | 227 ++++++++
.../rules/treatment-log-gaps.rule.ts | 130 +++++
.../readiness/rules/tsk-report.rule.spec.ts | 2 +-
.../readiness/rules/vet-office.rule.spec.ts | 2 +-
.../test/compliance-readiness.e2e-spec.ts | 486 +++++++++++++++++-
ApiCore-Frontend/public/sw-routes.js | 52 +-
ApiCore-Frontend/public/sw.js | 19 +-
.../src/app/(app)/apiaries/[id]/page.spec.tsx | 87 +++-
.../src/app/(app)/apiaries/[id]/page.tsx | 20 +-
.../src/app/(app)/bottling/[id]/page.tsx | 2 +-
.../bottling/[id]/print-labels-card.spec.tsx | 269 ++++++++++
.../(app)/bottling/[id]/print-labels-card.tsx | 318 +++++++++++-
.../compliance/acknowledge-dialog.spec.tsx | 147 ++++++
.../(app)/compliance/acknowledge-dialog.tsx | 225 ++++++++
.../src/app/(app)/compliance/page.spec.tsx | 179 +++++++
.../src/app/(app)/compliance/page.tsx | 196 +++++++
.../compliance/readiness-item-card.spec.tsx | 89 ++++
.../(app)/compliance/readiness-item-card.tsx | 137 +++++
.../src/app/(app)/dashboard/page.spec.tsx | 3 +
.../src/app/(app)/dashboard/page.tsx | 3 +
.../medicines/[id]/lot-form-dialog.spec.tsx | 142 +++++
.../(app)/medicines/[id]/lot-form-dialog.tsx | 294 +++++++++++
.../app/(app)/medicines/[id]/page.spec.tsx | 104 ++++
.../src/app/(app)/medicines/[id]/page.tsx | 206 ++++++++
.../lots/[id]/disposal-dialog.spec.tsx | 102 ++++
.../medicines/lots/[id]/disposal-dialog.tsx | 165 ++++++
.../lots/[id]/inventory-dialog.spec.tsx | 143 ++++++
.../medicines/lots/[id]/inventory-dialog.tsx | 156 ++++++
.../(app)/medicines/lots/[id]/page.spec.tsx | 137 +++++
.../app/(app)/medicines/lots/[id]/page.tsx | 187 +++++++
.../medicines/medicine-form-dialog.spec.tsx | 126 +++++
.../(app)/medicines/medicine-form-dialog.tsx | 342 ++++++++++++
.../(app)/medicines/medicine-form-values.ts | 100 ++++
.../(app)/medicines/medicine-suggestions.tsx | 80 +++
.../src/app/(app)/medicines/page.spec.tsx | 128 +++++
.../src/app/(app)/medicines/page.tsx | 105 ++++
.../src/app/(app)/nav-config.spec.ts | 10 +
ApiCore-Frontend/src/app/(app)/nav-config.ts | 7 +
.../src/app/(app)/products/[id]/page.spec.tsx | 3 +
.../src/app/(app)/products/[id]/page.tsx | 1 +
.../products/edit-product-dialog.spec.tsx | 13 +
.../(app)/products/edit-product-dialog.tsx | 18 +
.../src/app/(app)/products/new/page.spec.tsx | 14 +
.../src/app/(app)/products/new/page.tsx | 14 +
.../(app)/products/new/product-form-values.ts | 3 +
.../automation/social-media-tab.spec.tsx | 70 +++
.../settings/automation/social-media-tab.tsx | 69 ++-
.../settings/label-templates/[id]/page.tsx | 1 +
.../designer/label-designer.spec.tsx | 206 +++++++-
.../designer/label-designer.tsx | 132 ++++-
.../src/app/(app)/storage/page.spec.tsx | 45 ++
.../src/app/(app)/storage/page.tsx | 19 +-
.../components/document-upload-card.spec.tsx | 16 +
.../src/components/document-upload-card.tsx | 49 +-
.../labels/label-compliance-panel.spec.tsx | 149 ++++++
.../labels/label-compliance-panel.tsx | 142 +++++
.../components/medicines/no-read-access.tsx | 18 +
.../src/components/medicines/rest-text.tsx | 20 +
.../components/today/compliance-line.spec.tsx | 72 +++
.../src/components/today/compliance-line.tsx | 47 ++
.../src/lib/auth/api-client.spec.ts | 7 +
ApiCore-Frontend/src/lib/auth/api-client.ts | 10 +-
.../src/lib/compliance/api.spec.ts | 54 ++
ApiCore-Frontend/src/lib/compliance/api.ts | 32 ++
.../src/lib/compliance/errors.spec.ts | 72 +++
ApiCore-Frontend/src/lib/compliance/errors.ts | 97 ++++
.../src/lib/compliance/fix-links.spec.ts | 79 +++
.../src/lib/compliance/fix-links.ts | 96 ++++
.../src/lib/compliance/fixtures.ts | 35 ++
.../src/lib/compliance/labels.spec.ts | 20 +
ApiCore-Frontend/src/lib/compliance/labels.ts | 44 ++
ApiCore-Frontend/src/lib/compliance/types.ts | 63 +++
.../src/lib/compliance/use-readiness.ts | 22 +
.../src/lib/documents/types.spec.ts | 17 +
ApiCore-Frontend/src/lib/documents/types.ts | 13 +-
.../src/lib/label-templates/api.ts | 28 +-
.../src/lib/label-templates/check.spec.ts | 259 ++++++++++
.../src/lib/label-templates/check.ts | 237 +++++++++
.../label-templates/compliance-block.spec.ts | 81 +++
.../lib/label-templates/compliance-block.ts | 83 +++
.../label-templates/placeholder-keys.spec.ts | 23 +
.../lib/label-templates/placeholder-keys.ts | 37 ++
.../src/lib/label-templates/types.ts | 28 +-
.../src/lib/medicines/api.spec.ts | 87 ++++
ApiCore-Frontend/src/lib/medicines/api.ts | 96 ++++
.../src/lib/medicines/book.spec.ts | 50 ++
ApiCore-Frontend/src/lib/medicines/book.ts | 50 ++
.../src/lib/medicines/fixtures.spec-helper.ts | 79 +++
.../src/lib/medicines/format.spec.ts | 57 ++
ApiCore-Frontend/src/lib/medicines/format.ts | 73 +++
.../src/lib/medicines/prefill.spec.ts | 41 ++
ApiCore-Frontend/src/lib/medicines/prefill.ts | 42 ++
ApiCore-Frontend/src/lib/medicines/types.ts | 101 ++++
.../src/lib/medicines/use-attempt-key.spec.ts | 38 ++
.../src/lib/medicines/use-attempt-key.ts | 39 ++
.../src/lib/offline/sw-routes.spec.ts | 95 ++++
ApiCore-Frontend/src/lib/products/types.ts | 4 +
.../src/lib/site-settings/types.ts | 7 +
.../src/lib/system/labels.spec.ts | 9 +
149 files changed, 11869 insertions(+), 320 deletions(-)
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/readiness-limits.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/readiness-report.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/readiness-report.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/label-checks.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/medicine-rule-helpers.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.ts
delete mode 100644 ApiCore-Backend/src/compliance/readiness/rules/rule-spec-helpers.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/rule.spec-helper.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.spec.ts
create mode 100644 ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.ts
create mode 100644 ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/compliance/page.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/compliance/page.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/[id]/lot-form-dialog.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/[id]/lot-form-dialog.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/[id]/page.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/[id]/page.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/medicine-form-values.ts
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/medicine-suggestions.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/medicines/page.tsx
create mode 100644 ApiCore-Frontend/src/app/(app)/storage/page.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/labels/label-compliance-panel.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx
create mode 100644 ApiCore-Frontend/src/components/medicines/no-read-access.tsx
create mode 100644 ApiCore-Frontend/src/components/medicines/rest-text.tsx
create mode 100644 ApiCore-Frontend/src/components/today/compliance-line.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/today/compliance-line.tsx
create mode 100644 ApiCore-Frontend/src/lib/compliance/api.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/api.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/errors.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/errors.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/fix-links.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/fix-links.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/fixtures.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/labels.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/labels.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/types.ts
create mode 100644 ApiCore-Frontend/src/lib/compliance/use-readiness.ts
create mode 100644 ApiCore-Frontend/src/lib/documents/types.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/label-templates/check.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/label-templates/check.ts
create mode 100644 ApiCore-Frontend/src/lib/label-templates/compliance-block.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/label-templates/compliance-block.ts
create mode 100644 ApiCore-Frontend/src/lib/label-templates/placeholder-keys.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/api.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/api.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/book.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/book.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/fixtures.spec-helper.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/format.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/format.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/prefill.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/prefill.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/types.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/use-attempt-key.spec.ts
create mode 100644 ApiCore-Frontend/src/lib/medicines/use-attempt-key.ts
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 57a70ee..5ac5ce9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -76,7 +76,7 @@ Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-But
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/`, `/colonies/`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
-**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`).
+**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`, aktuell der Hash `0681ff4b095d`; ein Spec prüft, dass die Version zum Inhalt von `sw-routes.js` passt, jede Änderung an der Datei braucht also eine neue Version). Seit T6 kommen dazu: `/api/v1/compliance` (Prüfbereitschaft, Anzeigen-Einstellungen), `authority-notices`, `apiary-restrictions`, `apiaries/:id/restrictions`, `veterinary-medicines`, `veterinary-medicine-lots`, die Etikett-Prüfung und der Druck (`label-templates/check`, `label-templates/:id/check…`, `label-templates/:id/print…`; sie tragen die Anschrift des Lebensmittelunternehmers), das Einzeldokument `documents/:id` (die Art steht nicht im Pfad) und die Dokumentlisten der drei eingeschränkten Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` (die Art steht in der Query `/documents?entityType=…`, daher prüft `isCacheableApiUrl` die URL samt Query; Listen der Durchsicht-Fotos und `documents/:id/download` bleiben offline verfügbar, das Präfix `/api/v1/documents` wird nicht als Ganzes gesperrt). **Der globale Header `Cache-Control: no-store` des Backends ist bewusst kein Signal für den Worker:** Jede API-Antwort trägt ihn (Abschnitt 9, `noStoreMiddleware`), ein Header-Check würde das gesamte Offline-Verhalten abschalten. `isKeepableApiAnswer` prüft nur Erfolg und JSON; ob etwas behalten wird, entscheidet allein der Pfad (Annahme 87).
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
@@ -206,6 +206,11 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
83. **Der Ort im Briefkopf kommt aus der Postleitzahl-Zeile**: Aus „…, 34117 Kassel“ wird „Kassel“; ohne Treffer steht nur das Datum, der Ort bleibt leer (kein `FEHLT`). Kosten bei falscher Annahme: Der Ort fehlt neben dem Datum. Abschnitt 12.8.
84. **Die Summe im Anzeigen-PDF ist das gespeicherte `totalColonies`**: Weicht es von der Summe der Stände ab, steht „Gesamtzahl berichtigt (Summe der Stände: n)“; es wird nicht live neu gezählt. Kosten bei falscher Annahme: Ein veraltetes `totalColonies` erscheint im Ausdruck, bis `recount` im Entwurf läuft. Abschnitt 12.8.
85. **Keine der ersten sieben Prüfregeln ist quittierbar**: Weder Pflicht- noch Prüfen-Punkte dieser Stufe lassen sich wegquittieren (Spec 5.7); die Quittung ist Mechanik für spätere weiche Regeln (Task 13). Kosten bei falscher Annahme: Der Inhaber muss die Ursache beheben statt zu quittieren. Abschnitt 12.10.
+86. **Die Kurzfassung der Prüfbereitschaft kann bis zu 60 s veraltet sein**: `GET /compliance/readiness/summary` (Dashboard-Zeile) wird je Prozess 60 s aus dem Speicher bedient und nach Quittieren oder Zurücknehmen im selben Prozess sofort verworfen. Änderungen an den Daten und Quittungen eines anderen Prozesses erscheinen dort bis zu 60 s später; Liste, PDF-Bericht und Quittierpfad rechnen immer live. Kosten bei falscher Annahme: Die Dashboard-Zeile zeigt kurz einen alten Stand. Abschnitt 12.10.
+87. **Der Service Worker entscheidet über das Zwischenspeichern nach dem Pfad, nicht nach `Cache-Control`**: Das Backend sendet `Cache-Control: no-store` an jede API-Antwort (Abschnitt 9); würde der Worker den Header als Signal lesen, verwürfe er das gesamte Offline-Verhalten. Nie gespeichert wird, was `NEVER_CACHED_API_PATHS` und die Dokumentarten-Regel benennen; ein neuer schutzwürdiger Pfad muss dort eingetragen werden und hebt `SW_ROUTES_VERSION`. Kosten bei falscher Annahme: Ein vergessener Pfad kann offline gespeichert werden, obwohl der Server `no-store` sendet. Abschnitt 2 und 12.3.
+88. **„1.000“ wird als mehrdeutig abgelehnt**: Die Mengenfelder der Tierarzneimittel-Oberfläche lesen das Komma als Dezimalzeichen und lehnen einen einzelnen Punkt mit genau drei Nachkommastellen („1.000“, „12.345“) sowie jeden Text mit mehreren Trennzeichen ab, weil er Tausender oder Dezimalstelle meinen könnte. Kosten bei falscher Annahme: Wer „1.000“ als eins meint, muss „1“ oder „1,0“ schreiben. Abschnitt 12.11.
+89. **Eine neue, ungespeicherte Etikettvorlage prüft nichts, bis sie einen Wert hat**: Der Check-Endpunkt verlangt die Mindest-x-Höhe; das Frontend kennt keinen eigenen Vorgabewert (kein Rechtswert im Code). Eine neue Vorlage startet mit leerem Feld, sendet beim Anlegen keinen Wert (der Server setzt seinen Standard) und zeigt das Panel erst nach Eingabe oder nach dem ersten Speichern. Kosten bei falscher Annahme: Im Designer fehlt bei einer ganz neuen Vorlage die Prüfung, bis der Wert gesetzt ist. Abschnitt 12.11.
+90. **Das Prüfbericht-PDF druckt Zählungen, keine Namen**: Es nennt je Punkt nur die Zahl der betroffenen Objekte, nie Stände, Völker, Mittel, Packungen oder Aktenzeichen, damit der Bericht ohne Rechteprüfung der Namen weitergegeben werden kann. Kosten bei falscher Annahme: Wer die Objekte braucht, öffnet die Seite `/compliance` mit den Rechten. Abschnitt 12.10.
## 7. Kubernetes
@@ -731,7 +736,7 @@ Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von `compliance:read` bzw. `veterinary-medicines:read`; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie `COMPLIANCE`, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk.
-**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I). Task 13 bis 19 (weitere Prüfregeln, Oberfläche) folgen und ergänzen diesen Abschnitt.
+**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I), 13 (Prüfbereitschaft II), 14 (Oberfläche Prüfbereitschaft), 15 (Oberfläche Tierarzneimittel) und 17 (Oberfläche Etikett). Task 1 bis 15 und 17 sind erledigt; Task 16, 18 und 19 (Behandlungsmaske, Anzeigen- und Sperrbezirk-Oberfläche, Abschluss) folgen und ergänzen diesen Abschnitt.
### 12.1 Schema und Rechte
@@ -777,7 +782,7 @@ Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein
Für `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` gilt zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes. Die Zuordnung steht in `DOCUMENT_ENTITY_PERMISSIONS` (`documents.service.ts`): Packung lesen `veterinary-medicines:read`, schreiben `veterinary-medicines:update`; Behördenanzeige und Sperrbezirk lesen `compliance:read`, schreiben `compliance:manage`. „Schreiben“ umfasst Hochladen, Umhängen, Umbenennen und Löschen; beim Umhängen zählen die alte und die neue Art. `DocumentsService.assertEntityAccess(userId, entityType, mode)` wirft 403 ohne das Recht; Arten ohne Eintrag (Stand, Volk, Königin, Durchsicht, Honigcharge) prüfen nichts zusätzlich. Die Prüfung kommt vor jeder Datenbankabfrage (`findAll`, `create`) bzw. nach dem Laden des Dokuments und vor dem Lesen der Datei (`readFile`, `update`, `delete`), daher 403 vor 404 und kein Existenz-Orakel für Unberechtigte. Wer nur `documents:read` hat, sieht Belege, Anzeigen und Sperrbescheide weder in der Liste noch per Download-ID. `compliance:manage` schließt `compliance:read` nicht ein (Annahme 69). Speicherordner sind `medicine_lot`, `authority_notice` und `apiary_restriction`; MIME-Allowlist und Signaturprüfung gelten unverändert. Ein Fehler beim Löschen der Datei wird mit Dokument-Id und `loggableError()` protokolliert, Zeile, Audit und Antwort bleiben. Dokumente gelöschter Datensätze bleiben bestehen (Annahme 70). Das Recht wirkt über den 30-s-Cache des Resolvers verzögert, wie überall.
-**Zwischenspeicher.** Alle API-Antworten tragen `Cache-Control: no-store` (Abschnitt 9, `noStoreMiddleware`); die e2e-Tests halten das für Liste und Download fest. Der Service Worker beachtet `no-store` bisher nicht (`isKeepableApiAnswer` in `public/sw.js` behält JSON-Antworten per `cache.put`). Task 14 muss deshalb entweder `no-store` dort berücksichtigen oder Pfadregeln für Listen mit den drei Arten ergänzen; das Präfix `/api/v1/documents` wird nicht ganz gesperrt, sonst fehlten die Durchsichtsfotos offline. Nie gecacht werden die Compliance-, Arzneimittel- und Sperrbezirk-Pfade (`NEVER_CACHED_API_PATHS`).
+**Zwischenspeicher.** Alle API-Antworten tragen `Cache-Control: no-store` (Abschnitt 9, `noStoreMiddleware`); die e2e-Tests halten das für Liste und Download fest. Der Service Worker beachtet `no-store` bewusst **nicht**, weil es jede Antwort trägt (Abschnitt 2, Annahme 87). Stattdessen schließen Pfadregeln die Listen der drei Arten (`/documents?entityType=…`) und `documents/:id` aus; das Präfix `/api/v1/documents` wird nicht ganz gesperrt, sonst fehlten die Durchsichtsfotos offline. Nie gecacht werden auch die Compliance-, Arzneimittel- und Sperrbezirk-Pfade (`NEVER_CACHED_API_PATHS`, seit Task 14).
### 12.4 Etikett-Pflichtangaben
@@ -861,17 +866,70 @@ Task 11 verbindet die Anzeigen mit dem Ereignis-Rückgrat (Abschnitt 10). `Compl
### 12.10 Prüfbereitschaft I
-Task 12 liefert den Rahmen der Prüfbereitschaft mit den ersten sieben Regeln; die übrigen elf Regeln folgen mit Task 13.
+Task 12 liefert den Rahmen der Prüfbereitschaft mit den ersten sieben Regeln, Task 13 ergänzt die übrigen elf (zusammen die 18 der Spec, Ergänzungen am Ende dieses Abschnitts).
- **Modul und Worker-Schutz.** `ReadinessModule` (`src/compliance/readiness/`) ist **nur in `AppModule`**. Der Controller hängt am `PermissionResolverService` und darf nicht in den Worker-Graphen. `readiness-worker-graph.spec.ts` läuft über die `imports` des `WorkerModule` und prüft, dass weder `ReadinessModule` noch `ReadinessController` darin liegen.
- **`READINESS_RULES_TOKEN`.** Die Regelliste (`READINESS_RULES` in `rules/index.ts`) wird unter dem Token bereitgestellt; `ReadinessService` und `AcknowledgementsService` bekommen sie per Konstruktor. Unit-Specs übergeben Fake-Regeln direkt, die e2e überschreibt den Provider (`[...READINESS_RULES, softRule]`), ohne die geteilte Liste zu verändern. `rules/index.spec.ts` prüft eindeutige Schlüssel und eindeutige `subjectId` je Regel.
-- **Sieben reine Regeln.** Jede Regel ist eine pure Funktion `evaluate(ctx)`: `holder-data`, `food-business-data` (Pflicht), `vet-office` (Prüfen), `registration` (keine `SENT`-Erstanzeige), `herd-change` (Abweichung zum Bezugsstand laut Einstellung, nie ohne Bezugsstand), `tsk-report` (Stichtag erreicht ohne `SENT`-Zählung, „überfällig“ nach `tskReportDueDay`; das Vorjahr bleibt offen, solange sein Entwurf existiert) und `restriction-active` (ein Punkt mit allen gesperrten Ständen). Eine Regel ohne Befund ergibt genau einen erledigten Punkt (`DONE`, `subjectId` `global`). **Keine dieser sieben Regeln ist quittierbar** (Spec 5.7, Annahme 85); die Quittierung ist als Mechanik gebaut (`acknowledgeable`) und für weiche Regeln aus Task 13 gedacht, ein Test mit einer echten quittierbaren Regel gehört dorthin.
+- **Sieben reine Regeln.** Jede Regel ist eine pure Funktion `evaluate(ctx)`: `holder-data`, `food-business-data` (Pflicht), `vet-office` (Prüfen), `registration` (keine `SENT`-Erstanzeige), `herd-change` (Abweichung zum Bezugsstand laut Einstellung, nie ohne Bezugsstand), `tsk-report` (Stichtag erreicht ohne `SENT`-Zählung, „überfällig“ nach `tskReportDueDay`; das Vorjahr bleibt offen, solange sein Entwurf existiert) und `restriction-active` (ein Punkt mit allen gesperrten Ständen). Eine Regel ohne Befund ergibt genau einen erledigten Punkt (`DONE`, `subjectId` `global`). **Keine dieser sieben Regeln ist quittierbar** (Spec 5.7, Annahme 85); die Quittierung ist als Mechanik gebaut (`acknowledgeable`) und wird erst von den weichen Regeln aus Task 13 genutzt (vier quittierbare Regeln, siehe unten).
- **Live-Auswertung, nur Quittungen gespeichert.** `GET /compliance/readiness` lädt einen Kontext (Stammdaten, Einstellungen, Bestandsstatus, Anzeigen, aktive Sperren, jeweils begrenzt) und führt jede Regel aus; gespeichert wird allein `ComplianceAcknowledgement`. Tage (`today`, Stichtag, „überfällig“) sind Berliner Kalendertage; die Sperre zählt über `isRestrictionActive`, dieselbe Funktion wie der Wächter an den Völker-Pfaden (12.7), damit Anzeige und Sperre nie auseinanderlaufen.
- **Fingerabdruck.** sha256 über kanonisches JSON der für den Befund maßgeblichen Werte; Listen (z. B. `apiariesAdded/Removed/Relocated` bei `herd-change`) werden vorher **sortiert**, damit dieselben Daten in anderer Reihenfolge denselben Abdruck ergeben (Test).
- **Quittung, Rücknahme, veraltet.** Quittiert wird mit Notiz (10 bis 2000 Zeichen); der Fingerabdruck muss zum jetzt berechneten Punkt passen (sonst 409). Eine Quittung gilt nur, solange sie nicht zurückgenommen ist **und** ihr Fingerabdruck noch passt: Eine **zurückgenommene oder veraltete Quittung öffnet den Punkt wieder**. Quittieren und Zurücknehmen schreiben Audit (`CREATE`/`UPDATE` an `ComplianceAcknowledgement`); das Zurücknehmen sperrt die Zeile und setzt `revokedAt` in SQL.
- **Rot und Schwere.** Jeder offene Punkt ist rot; die Schwere steht als Wort („Pflicht“ oder „Prüfen“) daneben. Gesamtstatus `READY`, wenn kein Punkt offen ist, sonst `OPEN`.
- **Höchstens 50 Subjekte.** Betroffene Objekte stehen in `subjects` (höchstens 50), der Rest wird in `more` gezählt.
-- **Maskierung je `subjectPermission`.** Titel und Detailtexte enthalten nie Namen, Anschriften, Aktenzeichen oder Notizen. Hat der Aufrufer das Recht der Regel (`subjectPermission`, hier `apiaries:read`) nicht, bleiben `subjects` leer und `more` zählt sie; `summary` wertet ohne Subjekte aus. Der Stammdaten-Punkt nennt nur die Lücke („Name“, „Anschrift“), nie den Wert. **Die Zählungen bleiben sichtbar** („N Stände liegen im Sperrbezirk“), auch maskiert; die Seite verlangt ohnehin `compliance:read`, nur die Namen hängen am Recht (Annahme 81). Die Arzneimittel-Regeln aus Task 13 setzen `subjectPermission: 'veterinary-medicines:read'` und halten Mittel, Packung, Lieferant und Tierarzt aus `detail` und `title` heraus.
+- **Maskierung je `subjectPermission`.** Titel und Detailtexte enthalten nie Namen, Anschriften, Aktenzeichen oder Notizen. Hat der Aufrufer das Recht der Regel (`subjectPermission`, hier `apiaries:read`) nicht, bleiben `subjects` leer und `more` zählt sie; `summary` wertet ohne Subjekte aus. Der Stammdaten-Punkt nennt nur die Lücke („Name“, „Anschrift“), nie den Wert. **Die Zählungen bleiben sichtbar** („N Stände liegen im Sperrbezirk“), auch maskiert; die Seite verlangt ohnehin `compliance:read`, nur die Namen hängen am Recht (Annahme 81). Die Arzneimittel-Regeln aus Task 13 halten Mittel, Packung, Lieferant und Tierarzt aus `detail` und `title` heraus; welches Recht die Namen freigibt, steht je Regel in der Tabelle unten.
- **Lesepfade schreiben nicht.** `loadReadinessContext` liest `SiteSettings` per `findUnique`; fehlt die Zeile, gilt ein leerer In-Memory-Standard (alle Felder `null`, `holder-data` meldet die Lücken), kein Upsert. `ComplianceSettings` kommt aus `herdStatusIn` (12.8), das nur anlegt, wenn die von der Migration gesäte Zeile fehlt.
- **Registrierungs-Bezug separat.** Die jüngste `SENT`-Erstanzeige (nach `sentOn`, `createdAt`, `id`) und der jüngste `DRAFT` werden in **eigenen Abfragen** gelesen, nicht aus einer begrenzten Gesamtliste; nur der TSK-Teil ist auf 100 begrenzt. Der Bezugsstand für `herd-change` kommt unverändert aus `herdStatusIn` (`findBaselineIn`).
- **Endpunkte.** `GET /compliance/readiness`, `GET /compliance/readiness/summary` (`compliance:read`); `POST /compliance/acknowledgements` (`@Idempotent`) und `POST /compliance/acknowledgements/:id/revoke` (`compliance:manage`). BEEKEEPER und ADMIN alles, STAFF und READONLY nur Lesen, API_CLIENT nichts.
-- Hinweis für die Oberfläche (Task 14): Die Zuordnung „Schwere → Wort“ und „`OPEN` → rot mit Symbol und Wort“ braucht einen eigenen Test.
+- Oberfläche: Die Zuordnung „Schwere → Wort“ und „`OPEN` → rot mit Symbol und Wort“ hat seit Task 14 einen eigenen Test (`labels.spec.ts`, Abschnitt 12.11).
+
+**Task 13: die 18 Prüfpunkte.** Die Liste `READINESS_RULES` hat jetzt 18 Regeln in fünf Bereichen; `rules/index.spec.ts` prüft Bereich, Schwere, `acknowledgeable` und `subjectPermission` aller 18. Alle Regeln sind weiter reine Funktionen auf dem Kontext (`loadReadinessContext(db, now, { withdrawal })` mit den Feldern `treatments`, `moves`, `lots`, `medicines`, `templates`, `harvests`; `ReadinessModule` importiert dafür `WithdrawalModule`).
+
+| Bereich | Regel (Schlüssel) | Schwere | quittierbar | Namen frei mit |
+|---|---|---|---|---|
+| Stammdaten | `holder-data`, `food-business-data` | Pflicht | – | – |
+| Stammdaten | `vet-office` | Prüfen | – | – |
+| Behörden | `registration`, `herd-change`, `tsk-report`, `restriction-active` | Pflicht | – | `apiaries:read` bei `herd-change`, `restriction-active` |
+| Tierarzneimittel | `treatment-log-gaps` (ein Punkt je Kalenderjahr) | Pflicht | – | `treatments:read` |
+| Tierarzneimittel | `lot-gaps`, `lot-prescription` | Pflicht | – | `veterinary-medicines:read` |
+| Tierarzneimittel | `treatment-location-uncertain` | Prüfen | – | `treatments:read` |
+| Tierarzneimittel | `medicine-withdrawal-missing`, `lot-negative` | Prüfen | – | `veterinary-medicines:read` |
+| Tierarzneimittel | `lot-expired-stock` (je Packung), `treatment-expired-lot` (je Behandlung) | Prüfen | ja | `veterinary-medicines:read` |
+| Etiketten | `label-template-gaps` | Pflicht | – | – |
+| Etiketten | `label-template-form` | Prüfen | ja | – |
+| Ernte | `harvest-withdrawal` (je Ernte) | Prüfen | ja | `treatments:read` |
+
+- **Gleiche Funktionen wie das PDF.** `treatment-log-gaps` nutzt `resolveSourceValues`, `treatmentLogGaps` und `groupTreatmentLog` des Bestandsbuchs (12.2): Rückblick sind das laufende und fünf vorige Kalenderjahre (Jahr der UTC-Zeit wie im PDF; ein Eintrag um Mitternacht zum Jahreswechsel kann im Nachbarjahr stehen). Eine lückenhafte Packung steht einmal als Subjekt `LOT` (Label „Packung der Behandlungen“), Behandlungen ohne Packung als Subjekt `TREATMENT` mit dem Volk als `parentId`; Mengen- und Wartezeitlücken bleiben auf der Behandlung, die Tierarzt-Lücke gibt es nur bei verschreibungspflichtigem Mittel. `treatment-location-uncertain` meldet die `*`-Zeilen (`apiaryOnDate(...).uncertain`). `medicine-withdrawal-missing` zählt 0 Tage als erfasst. `lot-expired-stock` nimmt den Rest in den Fingerabdruck auf: Ändert sich der Rest, öffnet sich die Quittung wieder.
+- **Etiketten.** `label-template-gaps` und `label-template-form` prüfen jede aktive Vorlage mit demselben `checkLabel` wie der Druck (12.4, 12.6; `buildPlaceholderValues`, `expectedParticulars`, Ziffernregeln nur aus `ComplianceSettings`), je Vorlage einmal gemessen (`label-checks.ts`). `label-template-form` meldet nur Warnungen, sein Fingerabdruck sind die Warn-Codes. **Das Ziel der Prüfung ist die jüngste Abfüllung mit Restbestand**: Der Lader liest die Abfüllungen seitenweise (200, neueste zuerst) und hört bei der ersten mit Restbestand (`summarizeStock`) auf; gibt es keine, gilt die jüngste, sonst `SAMPLE_BOTTLING` (Beispielwerte). Ungültige `netQuantityDigitRules` ergeben in der Prüfbereitschaft „keine Regel“ (die Höhe der Füllmenge wird nicht beurteilt), der Druck lehnt sie dagegen mit 409 ab.
+- **Ernte.** `harvest-withdrawal` rechnet keine Fenster selbst: Der Lader ruft `WithdrawalCheckService.conflictsForHarvests` für die Ernten der letzten 24 Monate auf, der Text kommt aus `withdrawalRangeText` (längstes Fenster).
+- **Arzneimittel-Namen maskiert.** Regeln mit `subjectPermission: 'veterinary-medicines:read'` zeigen Packungen und Behandlungen ohne dieses Recht nur gezählt (`subjects` leer, `more` zählt). Titel, Text und Subjekt-Labels enthalten **nie Produktnamen** oder Mittelnamen; ein Label heißt allgemein („Packung der Behandlungen“), die Zuordnung zum Mittel steht erst hinter dem Beheben-Link. Die Namen der Lücken (`missing`) sind deutsche Feldnamen (Lieferant, Beleg, Menge, Wartezeit, Tierarzt) bzw. die Titel der Etikett-Befunde. Neue Subjekt-Typen: `TREATMENT`, `LOT`, `MEDICINE`, `LABEL_TEMPLATE`, `HARVEST`.
+- **Prüfbericht als PDF.** `GET /compliance/readiness/report.pdf` (`compliance:read`) liefert `pruefbericht.pdf` (A4 hoch, `no-store`, `renderReadinessReportPdf` in `readiness-report.ts`). Inhalt: Kopf mit Halter (`FEHLT`, wenn leer), Datum, Gesamtstatus und dem Satz „Jeder offene Punkt ist zu behandeln.“, je Bereich eine Tabelle mit Status (Offen, Erledigt, Quittiert), Schwere als Wort, Titel, Text, Norm (130 pt breit, „Fertigpackungsverordnung“ in einer Zeile) und bei Quittungen Notiz, Nutzer und Datum. **Gedruckt werden nur Zählungen** („n Objekte“), nie Namen von Objekten (Annahme 90); Quittungsnotizen werden nach 600 Zeichen mit „…“ gekürzt. Die Auswertung läuft mit den Rechten des Aufrufers. Ein Aufrufer mit `compliance:read` ohne `veterinary-medicines:read` bekommt 200, und das PDF enthält weder Mittelnamen noch Behandlungsprodukte (e2e).
+- **Kurzfassung gecacht.** `ReadinessService.summary(now)` hält das Ergebnis je Prozess 60 Sekunden (`SUMMARY_TTL_MS`, Zeitbasis ist das übergebene `now`). `AcknowledgementsService.create` (nach Erfolg) und `revoke` rufen `invalidateSummary()` auf; ein abgelehntes Quittieren lässt den Cache stehen. Liste (`GET /compliance/readiness`), PDF-Bericht und Quittierpfad rechnen immer live. Änderungen an den Daten und Quittungen anderer Prozesse zeigen sich in der Kurzfassung bis zu 60 Sekunden später (Annahme 86).
+- **409-Codes beim Quittieren.** Die Antwort hat den Körper `{ code, message }` (Meldungen unverändert): `READINESS_FINGERPRINT_STALE` (Fingerabdruck passt nicht mehr; „Der Punkt hat sich geändert. Bitte neu laden.“), `READINESS_ITEM_NOT_OPEN` (Punkt weg oder erledigt; gleiche Meldung) bei `POST /compliance/acknowledgements`, und `READINESS_ACK_REVOKED` („Die Quittung ist schon zurückgenommen.“) bei `…/:id/revoke`. Die Konstanten stehen in `acknowledgements.service.ts`; die Oberfläche wertet die Codes aus (12.11), der Text ist nur Rückfall.
+- **Lastgrenze.** Eine Auswertung lädt alle Packungen, sechs Jahre Behandlungen und die Ernte-Konflikte (`windowsOf` liest alle Behandlungen der beteiligten Völker). Das ist für den Betrieb unkritisch und der Grund für den 60-s-Cache der Kurzfassung (Dashboard); bei sehr vielen Daten sind Index oder weitere Zwischenspeicher zu prüfen. Einzelpunkte je Behandlung oder Ernte können lang werden, die Oberfläche klappt sie ein.
+
+### 12.11 Oberfläche (Frontend)
+
+Die Oberfläche des Compliance-Blocks entsteht in mehreren Tasks; dieser Stand umfasst Prüfbereitschaft (Task 14), Tierarzneimittel (Task 15) und Etikett (Task 17). Behandlungsmaske (Task 16) sowie Anzeigen, Sperrbezirke und Einstellungen (Task 18, 19) folgen. Alle Aktionen sind online-only: Auslöser sind offline deaktiviert und nennen den sichtbaren Grund „Nur mit Verbindung“ (`aria-describedby`). Der Service-Worker-Ausschluss steht in Abschnitt 2.
+
+**Prüfbereitschaft (Task 14).** Code in `lib/compliance/` (`types`, `api`, `fix-links`, `errors`, `labels`, `use-readiness`; Query-Schlüssel `['compliance', …]`) und `app/(app)/compliance/`.
+- **Seite `/compliance`** zeigt `GET /compliance/readiness`. Oben der Status: „Prüfbereit“ (grün) oder „n offen“ (rot), jeweils mit Symbol und Wort. **Jeder offene Punkt ist rot** (Token `destructive`) und trägt Symbol, das Wort „Offen“, die **Schwere als Wort „Pflicht“ oder „Prüfen“** und den Titel; dazu ein Satz Detail und klein die Norm. Betroffene Objekte stehen eingeklappt mit einem „Beheben“-Link je Objekt, „und n weitere“ für den Rest; fehlt das Recht auf die Namen, steht nur „n betroffen“. Erledigte Punkte stehen eingeklappt unter „Erledigt (n)“, Quittierte ruhig mit Nutzer, Datum und Notiz. Die Zuordnung Schwere → Wort und offen → rot liegt allein in `labels.ts` und hat einen eigenen Test. Hauptaktion ist „Nächsten Punkt lösen“ (erster offener Pflicht-Punkt); ist dessen Ziel noch nicht gebaut, heißt sie „Nächsten Punkt ansehen“ und springt auf die Karte (`#item--`). Bei grünem Status steht dort „Bestandsbuch {Jahr} drucken“. Hinter „Mehr“ liegen der Bericht (PDF über `downloadReadinessReport`, als Blob mit Bearer-Header, Dateiname `pruefbericht.pdf`), das Bestandsbuch, Anzeigen, Sperrbezirke und Einstellungen. Downloads sind offline aus.
+- **Beheben-Links aus einer Stelle:** `fixLinkFor(itemKey, subject)` in `fix-links.ts` bildet Subjekte auf Seiten ab (Typen ohne Beachtung der Groß-/Kleinschreibung). Die Ziele `/compliance/notices(/[id])`, `/compliance/restrictions` und `/compliance/settings` gibt es erst mit Task 18/19; bis dahin blendet die Menge `ROUTE_AVAILABLE` solche Links aus (`available: false`), und die jeweilige Task trägt ihre Route dort ein. Eine Behandlung verlinkt auf `/colonies/{parentId}`, Stammdaten auf `/settings/automation?tab=social-media` (Reiter „Stammdaten & Web“). `herd-change` führt zur Anzeige (`/compliance/notices`), nicht zur Sperrenliste; `restriction-active` ohne Subjekt zur Sperrenliste.
+- **Quittieren und Zurücknehmen** (`acknowledge-dialog.tsx`): nur bei `acknowledgeable` und offenem Punkt bzw. quittiertem Punkt, nur mit `compliance:manage`. Notiz 10 bis 2000 Zeichen, vor dem Senden geprüft; Zurücknehmen über `ConfirmDialog`. Fehler laufen über `readinessConflictText`, der die drei Codes `READINESS_FINGERPRINT_STALE`, `READINESS_ITEM_NOT_OPEN` und `READINESS_ACK_REVOKED` mit je eigenem deutschem Text auswertet (der Servertext ist nur Rückfall); bei jedem 409 laden alle `compliance`-Queries neu. Nach Erfolg und Neuladen geht der Fokus auf die Kartenüberschrift (`tabIndex=-1`), eine `role="status"`-Region sagt „Punkt quittiert“ bzw. „Quittung zurückgenommen“. `lib/compliance/errors.ts` liest außerdem `DRAFT_EXISTS` und `RESTRICTED_ZONE`, alles andere läuft über `saveErrorText`; es gibt keine zweite Fehlerlogik.
+- **Dashboard-Zeile** (`components/today/compliance-line.tsx`): „n Punkte offen in der Prüfbereitschaft“, nur mit `compliance:read` und `openCount > 0`, aus `GET /compliance/readiness/summary` (60 s gecacht, Annahme 86). Schlägt das Laden fehl (kein Netzfehler; offline bleibt es still), steht eine **Fehlerzeile** „Prüfbereitschaft nicht geladen.“ mit „Erneut versuchen“.
+- **Navigation:** `nav-config.ts` hat „Prüfbereitschaft“ (Symbol `ShieldCheck`, Gruppe „Betrieb“, Recht `compliance:read`).
+
+**Tierarzneimittel (Task 15).** Seiten unter `app/(app)/medicines/`, Daten aus `lib/medicines/api.ts`; kein eigener Nav-Eintrag, der Einstieg ist die Karte „Tierarzneimittel“ auf `/storage` (nur mit `veterinary-medicines:read`).
+- **Seiten.** `/medicines` (Katalog mit Rest und nächstem Verfall, Reiter Aktiv/Deaktiviert; bei leerem Katalog „Aus deinen Behandlungen übernehmen?“ mit `GET …/suggestions`: „Übernehmen“ öffnet nur das Formular, gespeichert wird erst beim Absenden), `/medicines/[id]` (Angaben, Packungen, Hauptknopf „Packung erfassen“) und `/medicines/lots/[id]` (Rest, Angaben, Bewegungsbuch, Beleg, Hauptknopf „Inventur“). Hauptknöpfe: „Mittel anlegen“ (`create`), „Packung erfassen“ (`create`), „Inventur“ (`update`); Entsorgung, Bearbeiten und (De-)Aktivieren sind Nebenaktionen mit `update`. Ist das Mittel inaktiv, ist „Packung erfassen“ mit sichtbarem Grund „Das Mittel ist deaktiviert.“ gesperrt (Annahme 61). Mit Packungen entfällt das Einheitsfeld (Annahme 62), `unit` wird nie gesendet; geleerte Felder gehen als `null` hinaus.
+- **Dialoge.** Mittel, Packung, Inventur (zeigt die Differenz live in einem `aria-live`-Bereich, Buchen bei Differenz 0 gesperrt) und Entsorgung (Menge, Pflichtgrund; über den Rest hinaus erlaubt, mit roter Warnung samt Symbol und Wort „Negativ“). Server-Meldungen („Das Mittel ist deaktiviert.“, „Die Einheit des Mittels passt nicht zur Packung.“) erscheinen im Dialog. Ein negativer Rest erscheint überall rot mit Warnsymbol und Wort (`RestText`).
+- **`use-attempt-key`** (`lib/medicines/use-attempt-key.ts`, Ansatz der Abfüllung): Jeder Anlege- und Buchungsaufruf trägt einen `Idempotency-Key`. Der Schlüssel bleibt nur, wenn der Body unverändert ist und der letzte Versuch am Netzwerk scheiterte; jede Serverantwort ohne Erfolg und jede Änderung des Bodys erneuert ihn (sonst würde der Server mit dem Schlüssel einen alten Body wiedergeben). Er gilt in allen vier Dialogen.
+- **`parseDecimal`: Tausenderpunkt-Regel.** Das Komma ist immer das deutsche Dezimalzeichen („1,000“ ist 1). Ein Punkt gilt nur als Dezimalpunkt, wenn er das einzige Trennzeichen ist und nicht wie ein Tausenderpunkt aussieht (`^\d*[1-9]\d*\.\d{3}$`). „1,5“, „1.5“ und „0.001“ gehen; **„1.000“**, „12.345“ und jeder Text mit mehr als einem Trenner (auch „1.234,5“) sind ungültig mit „Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5“ (Annahme 88). Gilt in allen Mengenfeldern; sie sind Textfelder mit `inputMode="decimal"`.
+- **Bewegungsbuch** (`lib/medicines/book.ts`): Eingang als erste Zeile, laufender Stand auf Tausendstel gerundet, neueste zuerst, Datum ist der Berliner Tag der Buchung. **Hinweis bei Abweichung:** Weicht der letzte rechnerische Stand von `lot.remaining` des Servers ab, steht unter der Tabelle „Stand weicht vom Server ab – bitte neu laden“.
+- **Seite ohne Leserecht.** Die drei Seiten laden ohne `veterinary-medicines:read` nichts (`enabled: false`) und zeigen „Dazu fehlt dir das Recht.“ ohne Erneut-Knopf (`components/medicines/no-read-access.tsx`, Link zurück zum Lager).
+- **Belege.** `DocumentUploadCard` (Art `MEDICINE_LOT`, Titel „Beleg“) hat die Eigenschaft `canWrite` (Vorgabe `true`); die Packungsseite übergibt `canWrite={canUpdate}`. Ohne sie entfallen Hochladen, Umbenennen und Löschen, Herunterladen bleibt. `DocumentEntityType` kommt aus `DOCUMENT_ENTITY_TYPES` und kennt `MEDICINE_LOT`, `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`.
+- **Hinweis Bestandsbuch (Annahme 73).** Auf der Standseite ist der Menüeintrag „Bestandsbuch (PDF)“ ohne `veterinary-medicines:read` deaktiviert (und nicht fokussierbar, weil die Menükomponente `aria-disabled` nicht zulässt); dauerhaft steht darunter die Zeile „Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.“. Für Task 16: `fetchSelectableLots(medicineId, includeLotId?)` liefert nur Packungen aktiver Mittel mit Rest > 0.
+
+**Etikett (Task 17).** Das Frontend entscheidet nichts über Pflichtangaben; es zeigt, was das Backend (12.4, 12.6) prüft.
+- **Prüfpanel im Designer** (`components/labels/label-compliance-panel.tsx`): Der Designer sendet 500 ms nach der letzten Änderung nur die Text-Elemente an `POST /label-templates/check` (Hook `useDebouncedLabelCheck` in `lib/label-templates/check.ts`; nur Text-Änderungen lösen aus, Eingaben außerhalb des technischen Bereichs lösen keinen Aufruf aus). Ein neuer Entwurf und das Aushängen **brechen den laufenden Aufruf ab** (`AbortSignal`); bei einem ungültigen Entwurf bleibt das letzte Ergebnis stehen, ist aber als „Veraltet: …“ markiert (`stale`). Das Panel zeigt die sechs Pflichtangaben mit Symbol und „Erfüllt“/„Fehlt“, Hinweise getrennt und mit „Hinweis:“ beginnend; die Live-Region (`aria-live="polite"`) enthält nur die Summenzeile.
+- **Keine Rechtszahlen im Frontend.** Mindest-x-Höhe und Ziffernhöhen kommen ausschließlich aus `requirements` der Prüfantwort („Die Prüfung verlangt aktuell mindestens X mm“); der Hinweistext am Feld „Mindest-x-Höhe (mm)“ nennt keine Zahl, `DEFAULT_MIN_X_HEIGHT_MM` gibt es nicht. Der Pflichtangaben-Block (`compliance-block.ts`, „Pflichtangaben-Block einfügen“, mit Rückgängig) rechnet die Schriftgrößen aus `requirements`. Die Schranken 0,5 bis 5 mm des Feldes sind der technische Bereich des DTO, kein Rechtswert. Eine neue Vorlage startet mit leerem Feld und sendet kein `minXHeightMm` (der Server setzt seinen Standard); bis ein Wert da ist, läuft keine Prüfung (Annahme 89).
+- **Druckkarte der Abfüllung** (`bottling/[id]/print-labels-card.tsx`): Vor dem Anzeigen läuft `GET /label-templates/:id/check/:bottlingBatchId` (`gcTime: 0`, keine Persistenz, `cache: "no-store"`). Bei Lücken zeigt sie die **rote Lückenliste** (Region, Symbol und „Fehlt:“) mit Links zu Stammdaten, Produkt und Designer (der Produktlink entfällt ohne Produkt an der Abfüllung); „Etiketten als PDF“ ist dann `aria-disabled` (fokussierbar, Grund per `aria-describedby`). Der **Probedruck** („Probedruck (MUSTER)“, `proof=true`) bleibt immer möglich; sein Dateiname enthält „muster“, der des echten Drucks nicht. Antwortet der Druck mit 409 `LABEL_PARTICULARS_MISSING`, zeigt die Karte die Liste `gaps` des Servers und **setzt den Fokus darauf**. 409 `COMPLIANCE_SETTINGS_INVALID` und 403 erscheinen als deutsche Meldung (`labelCheckErrorText`). Ein zweiter Klick während der Wartezeit löst keinen zweiten Aufruf aus. Ohne `label-templates:read` steht nur ein Hinweis, ohne `:print` gibt es keine Druckknöpfe, ohne Netz „Nur mit Verbindung“. Prüf- und Druckaufrufe laufen mit `cache: "no-store"` (`apiFetchBlob` reicht `init` durch).
+- **Stammdaten.** Im Reiter „Stammdaten & Web“ (Verwaltung > Automatisierung) stehen `foodBusinessName` (200) und `foodBusinessAddress` (300); **leere oder nur aus Leerzeichen bestehende Felder werden als `null` gesendet** (`value.trim() || null`), damit sie serverseitig löschen. „Vom Bienenhalter übernehmen“ füllt nur die Felder, ohne zu speichern. Der Text nennt, dass sie weder im Shop noch auf der Herkunftsseite erscheinen.
+- **Produkt `legalName`.** Das Feld „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“ steht im Typ `Product`, im Neu-Formular, im Bearbeiten-Dialog und auf der Detailseite; Platzhalter-Schlüssel und deutsche Labels liegen in `lib/label-templates/placeholder-keys.ts` (Wächter-Spec gegen `test/fixtures/placeholder-keys.json`).
diff --git a/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.spec.ts b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.spec.ts
index 93e57eb..0c3c427 100644
--- a/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.spec.ts
@@ -63,7 +63,7 @@ function dto(patch: Record = {}) {
describe('AcknowledgementsService', () => {
let service: AcknowledgementsService;
- let readiness: { evaluate: jest.Mock };
+ let readiness: { evaluate: jest.Mock; invalidateSummary: jest.Mock };
let auditLog: { record: jest.Mock };
let tx: {
complianceAcknowledgement: {
@@ -109,7 +109,7 @@ describe('AcknowledgementsService', () => {
callback(tx),
),
} as unknown as PrismaService;
- readiness = { evaluate: jest.fn() };
+ readiness = { evaluate: jest.fn(), invalidateSummary: jest.fn() };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new AcknowledgementsService(
prisma,
@@ -156,23 +156,34 @@ describe('AcknowledgementsService', () => {
});
it('refuses a stale fingerprint with 409', async () => {
- await expect(
- service.create(dto({ fingerprint: 'b'.repeat(64) }), 'u1'),
- ).rejects.toBeInstanceOf(ConflictException);
+ const error: unknown = await service
+ .create(dto({ fingerprint: 'b'.repeat(64) }), 'u1')
+ .catch((e: unknown) => e);
+
+ expect(error).toBeInstanceOf(ConflictException);
+ expect((error as ConflictException).getResponse()).toEqual({
+ code: 'READINESS_FINGERPRINT_STALE',
+ message: 'Der Punkt hat sich geändert. Bitte neu laden.',
+ });
expect(tx.complianceAcknowledgement.create).not.toHaveBeenCalled();
});
it('refuses an item that is not open (gone or done) with 409', async () => {
- reportWith(item({ state: 'DONE' }));
- await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
- ConflictException,
- );
+ for (const setup of [
+ () => reportWith(item({ state: 'DONE' })),
+ () => reportWith(),
+ ]) {
+ setup();
+ const error: unknown = await service
+ .create(dto(), 'u1')
+ .catch((e: unknown) => e);
- reportWith();
- await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
- ConflictException,
- );
+ expect(error).toBeInstanceOf(ConflictException);
+ expect((error as ConflictException).getResponse()).toMatchObject({
+ code: 'READINESS_ITEM_NOT_OPEN',
+ });
+ }
});
it('refuses a PFLICHT item and an unknown item key with 400', async () => {
@@ -236,9 +247,14 @@ describe('AcknowledgementsService', () => {
id: 'ack1',
revokedAt: new Date('2027-03-01T00:00:00.000Z'),
});
- await expect(service.revoke('ack1', 'u2')).rejects.toBeInstanceOf(
- ConflictException,
- );
+ const revoked: unknown = await service
+ .revoke('ack1', 'u2')
+ .catch((e: unknown) => e);
+ expect(revoked).toBeInstanceOf(ConflictException);
+ expect((revoked as ConflictException).getResponse()).toMatchObject({
+ code: 'READINESS_ACK_REVOKED',
+ message: 'Die Quittung ist schon zurückgenommen.',
+ });
expect(tx.$executeRaw).not.toHaveBeenCalled();
lockMock.mockResolvedValue(false);
@@ -246,4 +262,27 @@ describe('AcknowledgementsService', () => {
NotFoundException,
);
});
+
+ it('forgets the cached summary after an acknowledgement and after a revoke', async () => {
+ await service.create(dto(), 'u1');
+ expect(readiness.invalidateSummary).toHaveBeenCalledTimes(1);
+
+ tx.complianceAcknowledgement.findUnique.mockResolvedValue({
+ id: 'ack1',
+ revokedAt: null,
+ });
+ tx.complianceAcknowledgement.findUniqueOrThrow.mockResolvedValue({
+ id: 'ack1',
+ });
+ await service.revoke('ack1', 'u2');
+ expect(readiness.invalidateSummary).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the cache when the acknowledgement is refused', async () => {
+ await service
+ .create(dto({ fingerprint: 'b'.repeat(64) }), 'u1')
+ .catch(() => undefined);
+
+ expect(readiness.invalidateSummary).not.toHaveBeenCalled();
+ });
});
diff --git a/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.ts b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.ts
index 1f38b29..fec2d6c 100644
--- a/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.ts
+++ b/ApiCore-Backend/src/compliance/readiness/acknowledgements.service.ts
@@ -18,6 +18,11 @@ export const MIN_NOTE_LENGTH = 10;
export const MAX_NOTE_LENGTH = 2000;
const NO_PERMISSIONS: ReadonlySet = new Set();
+/** Stable codes of the 409 answers; the frontend switches on them. */
+export const READINESS_FINGERPRINT_STALE = 'READINESS_FINGERPRINT_STALE';
+export const READINESS_ITEM_NOT_OPEN = 'READINESS_ITEM_NOT_OPEN';
+export const READINESS_ACK_REVOKED = 'READINESS_ACK_REVOKED';
+
const STALE_MESSAGE = 'Der Punkt hat sich geändert. Bitte neu laden.';
@Injectable()
@@ -60,10 +65,16 @@ export class AcknowledgementsService {
(entry) => entry.key === dto.itemKey && entry.subjectId === dto.subjectId,
);
if (!item || item.state === 'DONE') {
- throw new ConflictException(STALE_MESSAGE);
+ throw new ConflictException({
+ code: READINESS_ITEM_NOT_OPEN,
+ message: STALE_MESSAGE,
+ });
}
if (item.fingerprint !== dto.fingerprint) {
- throw new ConflictException(STALE_MESSAGE);
+ throw new ConflictException({
+ code: READINESS_FINGERPRINT_STALE,
+ message: STALE_MESSAGE,
+ });
}
const created = await this.prisma.$transaction(async (tx) => {
const row = await tx.complianceAcknowledgement.create({
@@ -98,6 +109,7 @@ export class AcknowledgementsService {
);
return row;
});
+ this.readiness.invalidateSummary();
return {
...item,
state: 'ACKNOWLEDGED',
@@ -125,7 +137,10 @@ export class AcknowledgementsService {
throw new NotFoundException('Die Quittung gibt es nicht.');
}
if (before.revokedAt) {
- throw new ConflictException('Die Quittung ist schon zurückgenommen.');
+ throw new ConflictException({
+ code: READINESS_ACK_REVOKED,
+ message: 'Die Quittung ist schon zurückgenommen.',
+ });
}
await tx.$executeRaw`
UPDATE "ComplianceAcknowledgement"
@@ -147,5 +162,6 @@ export class AcknowledgementsService {
tx,
);
});
+ this.readiness.invalidateSummary();
}
}
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.spec.ts b/ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.spec.ts
new file mode 100644
index 0000000..9cfdd38
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.spec.ts
@@ -0,0 +1,92 @@
+import type { DbClient } from '../../../prisma/db-client';
+import type { NotificationsService } from '../../../notifications/notifications.service';
+import { withdrawalWindowOf } from '../../../treatments/withdrawal-window';
+import { WithdrawalCheckService } from '../../../withdrawal/withdrawal-check.service';
+import { loadHarvests } from './harvests.loader';
+
+const TREATMENT = {
+ id: 't1',
+ colonyId: 'c1',
+ product: 'Mittel Eins',
+ date: new Date('2027-02-01T00:00:00.000Z'),
+ startDate: null,
+ endDate: new Date('2027-02-05T00:00:00.000Z'),
+ withdrawalPeriodDays: 10,
+};
+
+function harvest(id: string, iso: string) {
+ return {
+ id,
+ date: new Date(iso),
+ colonies: [{ colonyId: 'c1' }],
+ };
+}
+
+function fakeDb(harvests: unknown[], treatments: unknown[] = [TREATMENT]) {
+ const findHarvests = jest.fn().mockResolvedValue(harvests);
+ const db = {
+ harvest: { findMany: findHarvests },
+ treatment: { findMany: jest.fn().mockResolvedValue(treatments) },
+ } as unknown as DbClient;
+ return { db, findHarvests };
+}
+
+const withdrawal = new WithdrawalCheckService({} as NotificationsService);
+
+describe('loadHarvests', () => {
+ it('reads the harvests of the last 2 years', async () => {
+ const { db, findHarvests } = fakeDb([]);
+
+ await loadHarvests(db, withdrawal, '2027-03-15');
+
+ const args = (findHarvests.mock.calls as unknown[][])[0][0] as {
+ where: { date: { gte: Date } };
+ };
+ expect(args.where.date.gte).toEqual(new Date('2025-03-15T00:00:00.000Z'));
+ });
+
+ it('reports a harvest inside the window of a participating colony and no harvest after it, exactly as the window helper says', async () => {
+ const window = withdrawalWindowOf(TREATMENT);
+ if (window === null) {
+ throw new Error('window expected');
+ }
+ // fromDay 2027-02-01, untilDay 2027-02-15 (exclusive).
+ const { db } = fakeDb([
+ harvest('inside', '2027-02-14T10:00:00.000Z'),
+ harvest('free', '2027-02-15T10:00:00.000Z'),
+ harvest('before', '2027-01-31T10:00:00.000Z'),
+ ]);
+
+ const harvests = await loadHarvests(db, withdrawal, '2027-03-15');
+
+ expect(window.untilDay).toBe('2027-02-15');
+ expect(harvests.map((entry) => [entry.id, entry.conflicts.length])).toEqual(
+ [
+ ['inside', 1],
+ ['free', 0],
+ ['before', 0],
+ ],
+ );
+ expect(harvests[0].conflicts[0]).toMatchObject({
+ harvestId: 'inside',
+ colonyId: 'c1',
+ treatmentId: 't1',
+ untilDay: '2027-02-15',
+ });
+ expect(harvests[0].day).toBe('2027-02-14');
+ });
+
+ it('asks the service once for all harvests', async () => {
+ const spy = jest.spyOn(withdrawal, 'conflictsForHarvests');
+ const { db } = fakeDb([
+ harvest('a', '2027-02-10T10:00:00.000Z'),
+ harvest('b', '2027-02-11T10:00:00.000Z'),
+ ]);
+
+ await loadHarvests(db, withdrawal, '2027-03-15');
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy.mock.calls[0][1]).toEqual(['a', 'b']);
+ spy.mockRestore();
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.ts b/ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.ts
new file mode 100644
index 0000000..b5989f5
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/harvests.loader.ts
@@ -0,0 +1,49 @@
+import {
+ addMonthsClamped,
+ berlinDayOf,
+ dayToDate,
+} from '../../../common/dates';
+import type { DbClient } from '../../../prisma/db-client';
+import type {
+ WithdrawalCheckService,
+ WithdrawalConflict,
+} from '../../../withdrawal/withdrawal-check.service';
+import { HARVEST_WITHDRAWAL_LOOKBACK_YEARS } from '../readiness-limits';
+
+export interface ReadinessHarvest {
+ id: string;
+ /** Berlin calendar day of the harvest. */
+ day: string;
+ /** One per participating colony whose withdrawal window covers the day. */
+ conflicts: WithdrawalConflict[];
+}
+
+/**
+ * The harvests of the last 2 years with their withdrawal conflicts. The
+ * windows are never calculated here: `conflictsForHarvests` decides, with
+ * `withdrawalWindowOf` and `isInWindow`.
+ */
+export async function loadHarvests(
+ db: DbClient,
+ withdrawal: Pick,
+ today: string,
+): Promise {
+ const since = addMonthsClamped(
+ today,
+ -12 * HARVEST_WITHDRAWAL_LOOKBACK_YEARS,
+ );
+ const harvests = await db.harvest.findMany({
+ where: { date: { gte: dayToDate(since) } },
+ select: { id: true, date: true },
+ orderBy: [{ date: 'asc' }, { id: 'asc' }],
+ });
+ const conflicts = await withdrawal.conflictsForHarvests(
+ db,
+ harvests.map((harvest) => harvest.id),
+ );
+ return harvests.map((harvest) => ({
+ id: harvest.id,
+ day: berlinDayOf(harvest.date),
+ conflicts: conflicts.filter((entry) => entry.harvestId === harvest.id),
+ }));
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.spec.ts b/ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.spec.ts
new file mode 100644
index 0000000..d96f63e
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.spec.ts
@@ -0,0 +1,179 @@
+import { SAMPLE_BOTTLING } from '../../../label-templates/label-particulars';
+import type { DbClient } from '../../../prisma/db-client';
+import { loadLabelTemplates } from './labels.loader';
+
+const TEXT = {
+ id: 'e1',
+ type: 'text',
+ x: 0,
+ y: 0,
+ width: 50,
+ height: 10,
+ text: 'Honig',
+ placeholderKey: null,
+ fontSize: 12,
+ fontFamily: 'sans',
+ bold: false,
+ italic: false,
+ color: '#000000',
+ align: 'left',
+};
+
+function template(patch: Record = {}) {
+ return {
+ id: 'tpl1',
+ name: 'Vorlage',
+ minXHeightMm: 1.2,
+ elements: [
+ TEXT,
+ { id: 'q', type: 'qrCode', x: 0, y: 0, width: 10, height: 10 },
+ { id: 'l', type: 'line', x: 0, y: 0, width: 10, height: 1 },
+ ],
+ ...patch,
+ };
+}
+
+function bottling(id: string, jarCount: number, day: string) {
+ return {
+ id,
+ jarSize: 500,
+ jarCount,
+ lotNumber: `L-${id}`,
+ date: new Date(`${day}T00:00:00.000Z`),
+ bestBeforeDate: new Date('2029-01-01T00:00:00.000Z'),
+ honeyBatch: { batchNumber: 'HB', honeyType: null },
+ product: { name: 'Honig', legalName: null },
+ };
+}
+
+function fakeDb(
+ templates: unknown[],
+ bottlings: unknown[] = [],
+ movements: unknown[] = [],
+) {
+ const findBottlings = jest.fn().mockResolvedValue(bottlings);
+ const groupBy = jest.fn().mockResolvedValue(movements);
+ const db = {
+ labelTemplate: { findMany: jest.fn().mockResolvedValue(templates) },
+ bottlingBatch: { findMany: findBottlings },
+ batchMovement: { groupBy },
+ } as unknown as DbClient;
+ return { db, findBottlings, groupBy };
+}
+
+describe('loadLabelTemplates', () => {
+ it('reads only active templates and keeps their text elements', async () => {
+ const { db } = fakeDb([template()]);
+
+ const templates = await loadLabelTemplates(db);
+
+ expect(
+ (
+ (db as unknown as { labelTemplate: { findMany: jest.Mock } })
+ .labelTemplate.findMany.mock.calls as unknown[][]
+ )[0][0],
+ ).toMatchObject({ where: { active: true } });
+ expect(templates).toHaveLength(1);
+ expect(templates[0].elements.map((element) => element.id)).toEqual(['e1']);
+ });
+
+ it('asks for no bottling without an active template', async () => {
+ const { db, findBottlings } = fakeDb([]);
+
+ expect(await loadLabelTemplates(db)).toEqual([]);
+ expect(findBottlings).not.toHaveBeenCalled();
+ });
+
+ it('targets the newest bottling that still has stock', async () => {
+ // Newest first, as asked: b3 is sold out (60 of 60), b2 has 10 left.
+ const { db } = fakeDb(
+ [template()],
+ [
+ bottling('b3', 60, '2027-03-01'),
+ bottling('b2', 40, '2027-02-01'),
+ bottling('b1', 20, '2027-01-01'),
+ ],
+ [
+ { bottlingBatchId: 'b3', type: 'SHOP_SALE', _sum: { quantity: -60 } },
+ { bottlingBatchId: 'b2', type: 'WITHDRAWAL', _sum: { quantity: -30 } },
+ ],
+ );
+
+ const [loaded] = await loadLabelTemplates(db);
+
+ expect(loaded.target.sample).toBe(false);
+ expect(loaded.target.bottling.lotNumber).toBe('L-b2');
+ });
+
+ it('falls back to the newest bottling when none has stock', async () => {
+ const { db } = fakeDb(
+ [template()],
+ [bottling('b2', 40, '2027-02-01'), bottling('b1', 20, '2027-01-01')],
+ [
+ { bottlingBatchId: 'b2', type: 'SHOP_SALE', _sum: { quantity: -40 } },
+ { bottlingBatchId: 'b1', type: 'SHOP_SALE', _sum: { quantity: -20 } },
+ ],
+ );
+
+ const [loaded] = await loadLabelTemplates(db);
+
+ expect(loaded.target.sample).toBe(false);
+ expect(loaded.target.bottling.lotNumber).toBe('L-b2');
+ });
+
+ it('falls back to the sample values when there is no bottling at all', async () => {
+ const { db } = fakeDb([template()], []);
+
+ const [loaded] = await loadLabelTemplates(db);
+
+ expect(loaded.target).toEqual({ sample: true, bottling: SAMPLE_BOTTLING });
+ });
+
+ it('shares one target between all templates', async () => {
+ const { db, findBottlings } = fakeDb(
+ [template(), template({ id: 'tpl2' })],
+ [bottling('b1', 20, '2027-01-01')],
+ );
+
+ const templates = await loadLabelTemplates(db);
+
+ expect(templates[0].target).toBe(templates[1].target);
+ expect(findBottlings).toHaveBeenCalledTimes(1);
+ });
+
+ it('finds an older bottling with stock behind 200 sold-out ones', async () => {
+ const soldOut = Array.from({ length: 200 }, (_, index) =>
+ bottling(`s${index}`, 10, '2027-03-01'),
+ );
+ const older = bottling('old', 10, '2020-01-01');
+ const findBottlings = jest
+ .fn()
+ .mockImplementation(({ skip }: { skip: number }) =>
+ Promise.resolve(skip === 0 ? soldOut : [older]),
+ );
+ const groupBy = jest
+ .fn()
+ .mockImplementation(
+ ({ where }: { where: { bottlingBatchId: { in: string[] } } }) =>
+ Promise.resolve(
+ where.bottlingBatchId.in
+ .filter((id) => id.startsWith('s'))
+ .map((id) => ({
+ bottlingBatchId: id,
+ type: 'SHOP_SALE',
+ _sum: { quantity: -10 },
+ })),
+ ),
+ );
+ const db = {
+ labelTemplate: { findMany: jest.fn().mockResolvedValue([template()]) },
+ bottlingBatch: { findMany: findBottlings },
+ batchMovement: { groupBy },
+ } as unknown as DbClient;
+
+ const [loaded] = await loadLabelTemplates(db);
+
+ expect(loaded.target.bottling.lotNumber).toBe('L-old');
+ expect(findBottlings).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.ts b/ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.ts
new file mode 100644
index 0000000..1629fae
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/labels.loader.ts
@@ -0,0 +1,125 @@
+import type { LabelTextElement } from '../../../label-templates/label-element.types';
+import { SAMPLE_BOTTLING } from '../../../label-templates/label-particulars';
+import type { buildPlaceholderValues } from '../../../label-templates/label-templates.service';
+import type { DbClient } from '../../../prisma/db-client';
+import { summarizeStock } from '../../../stock/stock-ledger';
+
+/** Bottlings read per page while looking for one with stock. */
+const BOTTLING_PAGE = 200;
+
+export type LabelBottling = Parameters[0];
+
+/** What a template is checked against. */
+export interface ReadinessLabelTarget {
+ /** True when no bottling exists and the sample values stand in. */
+ sample: boolean;
+ bottling: LabelBottling;
+}
+
+export interface ReadinessLabelTemplate {
+ id: string;
+ name: string;
+ minXHeightMm: number;
+ /** Text elements only: images, lines and QR codes carry no particular. */
+ elements: LabelTextElement[];
+ target: ReadinessLabelTarget;
+}
+
+function textElementsOf(elements: unknown): LabelTextElement[] {
+ return Array.isArray(elements)
+ ? (elements as { type?: unknown }[]).filter(
+ (element): element is LabelTextElement =>
+ typeof element === 'object' &&
+ element !== null &&
+ element.type === 'text',
+ )
+ : [];
+}
+
+const BOTTLING_SELECT = {
+ id: true,
+ jarSize: true,
+ jarCount: true,
+ lotNumber: true,
+ date: true,
+ bestBeforeDate: true,
+ honeyBatch: { select: { batchNumber: true, honeyType: true } },
+ product: { select: { name: true, legalName: true } },
+} as const;
+
+/**
+ * The bottling a template is checked against: the newest with stock left
+ * (the one being sold), else the newest, else the sample values. The
+ * bottlings are read newest first in pages until one has stock, so a long
+ * run of sold-out bottlings hides nothing; a movement sum per page uses the
+ * stock arithmetic of the shop (`summarizeStock`).
+ */
+async function loadTarget(db: DbClient): Promise {
+ let newest: LabelBottling | null = null;
+ for (let skip = 0; ; skip += BOTTLING_PAGE) {
+ const page = await db.bottlingBatch.findMany({
+ orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
+ skip,
+ take: BOTTLING_PAGE,
+ select: BOTTLING_SELECT,
+ });
+ newest ??= page[0] ?? null;
+ const withStock = await firstWithStock(db, page);
+ if (withStock) {
+ return { sample: false, bottling: withStock };
+ }
+ if (page.length < BOTTLING_PAGE) {
+ break;
+ }
+ }
+ return newest
+ ? { sample: false, bottling: newest }
+ : { sample: true, bottling: SAMPLE_BOTTLING };
+}
+
+async function firstWithStock(
+ db: DbClient,
+ bottlings: readonly T[],
+): Promise {
+ if (bottlings.length === 0) {
+ return undefined;
+ }
+ const sums = await db.batchMovement.groupBy({
+ by: ['bottlingBatchId', 'type'],
+ where: {
+ bottlingBatchId: { in: bottlings.map((bottling) => bottling.id) },
+ },
+ _sum: { quantity: true },
+ });
+ return bottlings.find(
+ (bottling) =>
+ summarizeStock(
+ bottling.jarCount,
+ sums
+ .filter((sum) => sum.bottlingBatchId === bottling.id)
+ .map((sum) => ({ type: sum.type, quantity: sum._sum.quantity ?? 0 })),
+ ).remaining > 0,
+ );
+}
+
+/** The active templates, each with the bottling it is checked against. */
+export async function loadLabelTemplates(
+ db: DbClient,
+): Promise {
+ const templates = await db.labelTemplate.findMany({
+ where: { active: true },
+ select: { id: true, name: true, minXHeightMm: true, elements: true },
+ orderBy: [{ name: 'asc' }, { id: 'asc' }],
+ });
+ if (templates.length === 0) {
+ return [];
+ }
+ const target = await loadTarget(db);
+ return templates.map((template) => ({
+ id: template.id,
+ name: template.name,
+ minXHeightMm: template.minXHeightMm,
+ elements: textElementsOf(template.elements),
+ target,
+ }));
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.spec.ts b/ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.spec.ts
new file mode 100644
index 0000000..4088886
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.spec.ts
@@ -0,0 +1,86 @@
+import type { DbClient } from '../../../prisma/db-client';
+import { loadLots } from './lots.loader';
+
+function lotRow(patch: Record = {}) {
+ return {
+ id: 'l1',
+ medicineId: 'm1',
+ purchasedOn: new Date('2027-01-05T00:00:00.000Z'),
+ quantity: 10,
+ expiresOn: new Date('2028-01-05T00:00:00.000Z'),
+ supplier: 'Lieferant',
+ purchaseProof: 'Beleg',
+ prescribingVet: null,
+ medicine: { name: 'Mittel Eins', prescriptionOnly: true },
+ ...patch,
+ };
+}
+
+function fakeDb(lots: unknown[], medicines: unknown[], sums: unknown[] = []) {
+ const groupBy = jest.fn().mockResolvedValue(sums);
+ const db = {
+ veterinaryMedicineLot: { findMany: jest.fn().mockResolvedValue(lots) },
+ veterinaryMedicine: { findMany: jest.fn().mockResolvedValue(medicines) },
+ veterinaryMedicineMovement: { groupBy },
+ } as unknown as DbClient;
+ return { db, groupBy };
+}
+
+describe('loadLots', () => {
+ it('computes the rest of each lot from one grouped sum query', async () => {
+ const { db, groupBy } = fakeDb(
+ [lotRow({ id: 'l1' }), lotRow({ id: 'l2', quantity: 5 })],
+ [],
+ [
+ { lotId: 'l1', _sum: { quantity: -3.5 }, _count: { _all: 2 } },
+ { lotId: 'l2', _sum: { quantity: -7 }, _count: { _all: 1 } },
+ ],
+ );
+
+ const { lots } = await loadLots(db);
+
+ expect(lots.map((lot) => [lot.id, lot.remaining])).toEqual([
+ ['l1', 6.5],
+ ['l2', -2],
+ ]);
+ expect(groupBy).toHaveBeenCalledTimes(1);
+ });
+
+ it('maps a lot with its medicine and days as day texts', async () => {
+ const { db } = fakeDb([lotRow()], []);
+
+ const { lots } = await loadLots(db);
+
+ expect(lots[0]).toEqual({
+ id: 'l1',
+ medicineId: 'm1',
+ medicineName: 'Mittel Eins',
+ prescriptionOnly: true,
+ purchasedOn: '2027-01-05',
+ expiresOn: '2028-01-05',
+ supplier: 'Lieferant',
+ purchaseProof: 'Beleg',
+ prescribingVet: null,
+ remaining: 10,
+ });
+ });
+
+ it('reads only active medicines', async () => {
+ const { db } = fakeDb(
+ [],
+ [{ id: 'm1', name: 'A', withdrawalPeriodDays: null }],
+ );
+
+ const { medicines } = await loadLots(db);
+
+ expect(medicines).toEqual([
+ { id: 'm1', name: 'A', withdrawalPeriodDays: null },
+ ]);
+ expect(
+ (
+ (db as unknown as { veterinaryMedicine: { findMany: jest.Mock } })
+ .veterinaryMedicine.findMany.mock.calls as unknown[][]
+ )[0][0],
+ ).toMatchObject({ where: { active: true } });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.ts b/ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.ts
new file mode 100644
index 0000000..659386f
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/lots.loader.ts
@@ -0,0 +1,79 @@
+import type { DbClient } from '../../../prisma/db-client';
+import {
+ dayText,
+ lotTotals,
+ remainingOf,
+} from '../../../veterinary-medicines/medicine-views';
+
+export interface ReadinessLot {
+ id: string;
+ medicineId: string;
+ /** Only for subject labels, which need veterinary-medicines:read. */
+ medicineName: string;
+ prescriptionOnly: boolean;
+ /** 'YYYY-MM-DD' */
+ purchasedOn: string;
+ /** 'YYYY-MM-DD' */
+ expiresOn: string | null;
+ supplier: string | null;
+ purchaseProof: string | null;
+ prescribingVet: string | null;
+ /** quantity + Σ movements, the same number the lot pages show. */
+ remaining: number;
+}
+
+export interface ReadinessMedicine {
+ id: string;
+ name: string;
+ withdrawalPeriodDays: number | null;
+}
+
+export interface ReadinessLots {
+ lots: ReadinessLot[];
+ /** The active catalogue entries. */
+ medicines: ReadinessMedicine[];
+}
+
+/** All lots with their rest (one grouped sum query) and the active medicines. */
+export async function loadLots(db: DbClient): Promise {
+ const [rows, medicines] = await Promise.all([
+ db.veterinaryMedicineLot.findMany({
+ select: {
+ id: true,
+ medicineId: true,
+ purchasedOn: true,
+ quantity: true,
+ expiresOn: true,
+ supplier: true,
+ purchaseProof: true,
+ prescribingVet: true,
+ medicine: { select: { name: true, prescriptionOnly: true } },
+ },
+ orderBy: [{ purchasedOn: 'asc' }, { id: 'asc' }],
+ }),
+ db.veterinaryMedicine.findMany({
+ where: { active: true },
+ select: { id: true, name: true, withdrawalPeriodDays: true },
+ orderBy: [{ name: 'asc' }, { id: 'asc' }],
+ }),
+ ]);
+ const totals = await lotTotals(
+ db,
+ rows.map((row) => row.id),
+ );
+ return {
+ lots: rows.map((row): ReadinessLot => ({
+ id: row.id,
+ medicineId: row.medicineId,
+ medicineName: row.medicine.name,
+ prescriptionOnly: row.medicine.prescriptionOnly,
+ purchasedOn: dayText(row.purchasedOn),
+ expiresOn: row.expiresOn === null ? null : dayText(row.expiresOn),
+ supplier: row.supplier,
+ purchaseProof: row.purchaseProof,
+ prescribingVet: row.prescribingVet,
+ remaining: remainingOf(row, totals.get(row.id)),
+ })),
+ medicines,
+ };
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.spec.ts b/ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.spec.ts
new file mode 100644
index 0000000..03082c6
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.spec.ts
@@ -0,0 +1,142 @@
+import type { DbClient } from '../../../prisma/db-client';
+import { loadTreatments } from './treatments.loader';
+
+const MOVE = {
+ colonyId: 'c1',
+ apiaryId: 'a2',
+ movedAt: new Date('2027-03-01T00:00:00.000Z'),
+};
+
+function treatmentRow(patch: Record = {}) {
+ return {
+ id: 't1',
+ colonyId: 'c1',
+ date: new Date('2027-02-10T00:00:00.000Z'),
+ startDate: null,
+ endDate: null,
+ product: 'Mittel Eins',
+ amount: '30 ml',
+ batchNumber: null,
+ supplier: null,
+ purchaseProof: null,
+ prescribingVet: null,
+ withdrawalPeriodDays: 0,
+ apiaryId: null,
+ doseQuantity: 2.5,
+ medicineId: 'm1',
+ colony: { code: 'V-1', apiaryId: 'a1' },
+ user: { name: 'Imker' },
+ medicine: { prescriptionOnly: true, unit: 'ML' },
+ medicineLot: {
+ id: 'l1',
+ batchNumber: 'CH-1',
+ supplier: 'Lieferant',
+ purchaseProof: 'Beleg',
+ prescribingVet: 'Dr. Vet',
+ },
+ ...patch,
+ };
+}
+
+function fakeDb(rows: unknown[], moves: unknown[] = [MOVE]) {
+ const findTreatments = jest.fn().mockResolvedValue(rows);
+ const findMoves = jest.fn().mockResolvedValue(moves);
+ const db = {
+ treatment: { findMany: findTreatments },
+ colonyApiaryHistory: { findMany: findMoves },
+ } as unknown as DbClient;
+ return { db, findTreatments, findMoves };
+}
+
+describe('loadTreatments', () => {
+ it('reads the last 6 calendar years with a column list, no whole rows', async () => {
+ const { db, findTreatments } = fakeDb([]);
+
+ await loadTreatments(db, '2027-03-15');
+
+ const args = (findTreatments.mock.calls as unknown[][])[0][0] as {
+ where: { date: { gte: Date } };
+ select?: unknown;
+ include?: unknown;
+ };
+ expect(args.where.date.gte).toEqual(new Date('2022-01-01T00:00:00.000Z'));
+ expect(args.select).toBeDefined();
+ expect(args.include).toBeUndefined();
+ });
+
+ it('maps rows to the sources of the Bestandsbuch', async () => {
+ const { db } = fakeDb([treatmentRow()]);
+
+ const { treatments } = await loadTreatments(db, '2027-03-15');
+
+ expect(treatments).toEqual([
+ {
+ id: 't1',
+ colonyId: 'c1',
+ colonyCode: 'V-1',
+ colonyApiaryId: 'a1',
+ apiaryId: null,
+ date: new Date('2027-02-10T00:00:00.000Z'),
+ startDate: null,
+ endDate: null,
+ product: 'Mittel Eins',
+ amount: '30 ml',
+ batchNumber: null,
+ supplier: null,
+ purchaseProof: null,
+ prescribingVet: null,
+ withdrawalPeriodDays: 0,
+ administeredBy: 'Imker',
+ medicineId: 'm1',
+ prescriptionOnly: true,
+ doseQuantity: 2.5,
+ medicineUnit: 'ML',
+ lot: {
+ id: 'l1',
+ batchNumber: 'CH-1',
+ supplier: 'Lieferant',
+ purchaseProof: 'Beleg',
+ prescribingVet: 'Dr. Vet',
+ },
+ },
+ ]);
+ });
+
+ it('loads the moves of the colonies whose treatment has no stored apiary, and only those', async () => {
+ const { db, findMoves } = fakeDb([
+ treatmentRow({ id: 't1', colonyId: 'c1', apiaryId: null }),
+ treatmentRow({ id: 't2', colonyId: 'c2', apiaryId: 'a1' }),
+ treatmentRow({ id: 't3', colonyId: 'c1', apiaryId: null }),
+ ]);
+
+ const { moves } = await loadTreatments(db, '2027-03-15');
+
+ expect(moves).toEqual([MOVE]);
+ expect((findMoves.mock.calls as unknown[][])[0][0]).toMatchObject({
+ where: { colonyId: { in: ['c1'] } },
+ });
+ });
+
+ it('asks for no moves when every treatment stores its apiary', async () => {
+ const { db, findMoves } = fakeDb([treatmentRow({ apiaryId: 'a1' })]);
+
+ const { moves } = await loadTreatments(db, '2027-03-15');
+
+ expect(moves).toEqual([]);
+ expect(findMoves).not.toHaveBeenCalled();
+ });
+
+ it('treats a treatment without a catalogue entry as over the counter', async () => {
+ const { db } = fakeDb([
+ treatmentRow({ medicineId: null, medicine: null, medicineLot: null }),
+ ]);
+
+ const { treatments } = await loadTreatments(db, '2027-03-15');
+
+ expect(treatments[0]).toMatchObject({
+ prescriptionOnly: false,
+ medicineUnit: null,
+ lot: null,
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.ts b/ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.ts
new file mode 100644
index 0000000..efbbcb9
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/loaders/treatments.loader.ts
@@ -0,0 +1,101 @@
+import type {
+ ColonyMove,
+ TreatmentLogSource,
+} from '../../../export/treatment-log';
+import type { DbClient } from '../../../prisma/db-client';
+import { firstTreatmentYear } from '../readiness-limits';
+
+export interface ReadinessTreatments {
+ treatments: TreatmentLogSource[];
+ /** The moves of the colonies whose treatment stores no apiary. */
+ moves: ColonyMove[];
+}
+
+/**
+ * The treatments of the lookback as the sources of the Bestandsbuch (the
+ * same shape the PDF is built from), with only the columns the rules read.
+ */
+export async function loadTreatments(
+ db: DbClient,
+ today: string,
+): Promise {
+ const rows = await db.treatment.findMany({
+ where: {
+ date: { gte: new Date(Date.UTC(firstTreatmentYear(today), 0, 1)) },
+ },
+ select: {
+ id: true,
+ colonyId: true,
+ date: true,
+ startDate: true,
+ endDate: true,
+ product: true,
+ amount: true,
+ batchNumber: true,
+ supplier: true,
+ purchaseProof: true,
+ prescribingVet: true,
+ withdrawalPeriodDays: true,
+ apiaryId: true,
+ doseQuantity: true,
+ medicineId: true,
+ colony: { select: { code: true, apiaryId: true } },
+ user: { select: { name: true } },
+ medicine: { select: { prescriptionOnly: true, unit: true } },
+ medicineLot: {
+ select: {
+ id: true,
+ batchNumber: true,
+ supplier: true,
+ purchaseProof: true,
+ prescribingVet: true,
+ },
+ },
+ },
+ orderBy: [{ date: 'asc' }, { id: 'asc' }],
+ });
+ const treatments = rows.map((row): TreatmentLogSource => ({
+ id: row.id,
+ colonyId: row.colonyId,
+ colonyCode: row.colony.code,
+ colonyApiaryId: row.colony.apiaryId,
+ apiaryId: row.apiaryId,
+ date: row.date,
+ startDate: row.startDate,
+ endDate: row.endDate,
+ product: row.product,
+ amount: row.amount,
+ batchNumber: row.batchNumber,
+ supplier: row.supplier,
+ purchaseProof: row.purchaseProof,
+ prescribingVet: row.prescribingVet,
+ withdrawalPeriodDays: row.withdrawalPeriodDays,
+ administeredBy: row.user.name,
+ medicineId: row.medicineId,
+ prescriptionOnly: row.medicine?.prescriptionOnly ?? false,
+ doseQuantity: row.doseQuantity,
+ medicineUnit: row.medicine?.unit ?? null,
+ lot: row.medicineLot,
+ }));
+ return { treatments, moves: await loadMoves(db, treatments) };
+}
+
+async function loadMoves(
+ db: DbClient,
+ treatments: readonly TreatmentLogSource[],
+): Promise {
+ const colonyIds = [
+ ...new Set(
+ treatments
+ .filter((treatment) => treatment.apiaryId === null)
+ .map((treatment) => treatment.colonyId),
+ ),
+ ].sort();
+ if (colonyIds.length === 0) {
+ return [];
+ }
+ return db.colonyApiaryHistory.findMany({
+ where: { colonyId: { in: colonyIds } },
+ select: { colonyId: true, apiaryId: true, movedAt: true },
+ });
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness-context.ts b/ApiCore-Backend/src/compliance/readiness/readiness-context.ts
index 63ba1d9..8f79d42 100644
--- a/ApiCore-Backend/src/compliance/readiness/readiness-context.ts
+++ b/ApiCore-Backend/src/compliance/readiness/readiness-context.ts
@@ -1,3 +1,7 @@
+import type {
+ ColonyMove,
+ TreatmentLogSource,
+} from '../../export/treatment-log';
import type {
ApiaryRestriction,
AuthorityNotice,
@@ -6,7 +10,24 @@ import type {
} from '../../../generated/prisma';
import { berlinToday, dayToDate } from '../../common/dates';
import type { DbClient } from '../../prisma/db-client';
+import type { WithdrawalCheckService } from '../../withdrawal/withdrawal-check.service';
import { herdStatusIn, type HerdStatus } from '../herd-status';
+import { loadHarvests, type ReadinessHarvest } from './loaders/harvests.loader';
+import {
+ loadLabelTemplates,
+ type ReadinessLabelTemplate,
+} from './loaders/labels.loader';
+import {
+ loadLots,
+ type ReadinessLot,
+ type ReadinessMedicine,
+} from './loaders/lots.loader';
+import { loadTreatments } from './loaders/treatments.loader';
+
+export {
+ HARVEST_WITHDRAWAL_LOOKBACK_YEARS,
+ TREATMENT_LOOKBACK_CALENDAR_YEARS,
+} from './readiness-limits';
const SITE_SETTINGS_ID = 'singleton';
/** TSK reports of this year and the one before matter for the readiness. */
@@ -21,10 +42,12 @@ export type ReadinessRestriction = ApiaryRestriction & {
apiary: { id: string; name: string };
};
-/**
- * Everything the rules read, loaded once per evaluation. Task 13 adds the
- * fields of the other rule groups.
- */
+/** What the context needs besides the database. */
+export interface ReadinessContextDeps {
+ withdrawal: Pick;
+}
+
+/** Everything the rules read, loaded once per evaluation. */
export interface ReadinessContext {
now: Date;
/** The Berlin calendar day of `now` (YYYY-MM-DD). */
@@ -35,6 +58,18 @@ export interface ReadinessContext {
notices: ReadinessNotice[];
/** The restrictions active on `today`. */
restrictions: ReadinessRestriction[];
+ /** The last 6 calendar years, as the sources of the Bestandsbuch. */
+ treatments: TreatmentLogSource[];
+ /** The moves of the colonies whose treatment stores no apiary. */
+ moves: ColonyMove[];
+ /** All lots with their rest. */
+ lots: ReadinessLot[];
+ /** The active catalogue entries. */
+ medicines: ReadinessMedicine[];
+ /** The active label templates, each with the bottling it is checked on. */
+ templates: ReadinessLabelTemplate[];
+ /** The harvests of the last 2 years with their withdrawal conflicts. */
+ harvests: ReadinessHarvest[];
}
/** Before the first save there is no row: every field is empty. */
@@ -74,46 +109,61 @@ const NOTICE_COLUMNS = {
export async function loadReadinessContext(
db: DbClient,
now: Date,
+ deps: ReadinessContextDeps,
): Promise {
const today = berlinToday(now);
const day = dayToDate(today);
const firstTskYear = Number(today.slice(0, 4)) - TSK_LOOKBACK_YEARS;
- const [site, herd, sentRegistration, draftRegistration, tskReports, active] =
- await Promise.all([
- db.siteSettings.findUnique({ where: { id: SITE_SETTINGS_ID } }),
- herdStatusIn(db, now),
- // The registrations have their own queries, so an old registration can
- // never fall out of the window meant for the TSK reports.
- db.authorityNotice.findFirst({
- where: { kind: 'REGISTRATION', status: 'SENT' },
- orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
- select: NOTICE_COLUMNS,
- }),
- db.authorityNotice.findFirst({
- where: { kind: 'REGISTRATION', status: 'DRAFT' },
- orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
- select: NOTICE_COLUMNS,
- }),
- db.authorityNotice.findMany({
- where: {
- kind: 'TSK_REPORT',
- status: { not: 'CANCELLED' },
- year: { gte: firstTskYear },
- },
- select: NOTICE_COLUMNS,
- orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
- take: MAX_TSK_NOTICES,
- }),
- db.apiaryRestriction.findMany({
- where: {
- startsOn: { lte: day },
- OR: [{ endsOn: null }, { endsOn: { gte: day } }],
- },
- include: { apiary: { select: { id: true, name: true } } },
- orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
- take: MAX_RESTRICTIONS,
- }),
- ]);
+ const [
+ site,
+ herd,
+ sentRegistration,
+ draftRegistration,
+ tskReports,
+ active,
+ treatmentData,
+ lotData,
+ templates,
+ harvests,
+ ] = await Promise.all([
+ db.siteSettings.findUnique({ where: { id: SITE_SETTINGS_ID } }),
+ herdStatusIn(db, now),
+ // The registrations have their own queries, so an old registration can
+ // never fall out of the window meant for the TSK reports.
+ db.authorityNotice.findFirst({
+ where: { kind: 'REGISTRATION', status: 'SENT' },
+ orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
+ select: NOTICE_COLUMNS,
+ }),
+ db.authorityNotice.findFirst({
+ where: { kind: 'REGISTRATION', status: 'DRAFT' },
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
+ select: NOTICE_COLUMNS,
+ }),
+ db.authorityNotice.findMany({
+ where: {
+ kind: 'TSK_REPORT',
+ status: { not: 'CANCELLED' },
+ year: { gte: firstTskYear },
+ },
+ select: NOTICE_COLUMNS,
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
+ take: MAX_TSK_NOTICES,
+ }),
+ db.apiaryRestriction.findMany({
+ where: {
+ startsOn: { lte: day },
+ OR: [{ endsOn: null }, { endsOn: { gte: day } }],
+ },
+ include: { apiary: { select: { id: true, name: true } } },
+ orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
+ take: MAX_RESTRICTIONS,
+ }),
+ loadTreatments(db, today),
+ loadLots(db),
+ loadLabelTemplates(db),
+ loadHarvests(db, deps.withdrawal, today),
+ ]);
const registrations = [sentRegistration, draftRegistration].filter(
(entry): entry is ReadinessNotice => entry !== null,
);
@@ -125,5 +175,11 @@ export async function loadReadinessContext(
herd,
notices: [...registrations, ...tskReports],
restrictions: active,
+ treatments: treatmentData.treatments,
+ moves: treatmentData.moves,
+ lots: lotData.lots,
+ medicines: lotData.medicines,
+ templates,
+ harvests,
};
}
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness-limits.ts b/ApiCore-Backend/src/compliance/readiness/readiness-limits.ts
new file mode 100644
index 0000000..1e97f6c
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/readiness-limits.ts
@@ -0,0 +1,18 @@
+/** Honey is sold within about 2 years: older harvests are not checked. */
+export const HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2;
+/** The current calendar year and the 5 before it (the book is kept 5 years). */
+export const TREATMENT_LOOKBACK_CALENDAR_YEARS = 6;
+
+/** The first year of the treatment lookback, for a Berlin day 'YYYY-MM-DD'. */
+export function firstTreatmentYear(today: string): number {
+ return Number(today.slice(0, 4)) - (TREATMENT_LOOKBACK_CALENDAR_YEARS - 1);
+}
+
+/**
+ * The calendar year a treatment belongs to in the Bestandsbuch: the PDF
+ * selects its year on the UTC date, the readiness uses the same year so that
+ * item and PDF cover the same rows.
+ */
+export function treatmentYearOf(date: Date): number {
+ return date.getUTCFullYear();
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness-report.spec.ts b/ApiCore-Backend/src/compliance/readiness/readiness-report.spec.ts
new file mode 100644
index 0000000..548d48d
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/readiness-report.spec.ts
@@ -0,0 +1,176 @@
+import { pdfText } from '../../../test/utils/pdf-text';
+import { renderReadinessReportPdf } from './readiness-report';
+import type { ReadinessItem, ReadinessReport } from './readiness.types';
+
+function item(patch: Partial = {}): ReadinessItem {
+ return {
+ key: 'holder-data',
+ area: 'STAMMDATEN',
+ severity: 'PFLICHT',
+ subjectId: 'global',
+ title: 'Daten fehlen',
+ detail: 'Name fehlt.',
+ legalBasis: 'Art. 108 VO',
+ acknowledgeable: false,
+ fingerprint: 'f'.repeat(64),
+ subjects: [],
+ more: 0,
+ state: 'OPEN',
+ acknowledgement: null,
+ ...patch,
+ };
+}
+
+function report(items: ReadinessItem[]): ReadinessReport {
+ const openCount = items.filter((entry) => entry.state === 'OPEN').length;
+ return {
+ status: openCount === 0 ? 'READY' : 'OPEN',
+ generatedAt: '2027-03-15T10:00:00.000Z',
+ openCount,
+ items,
+ };
+}
+
+const HOLDER = { name: 'Imker Eins', address: 'Weg 1, 12345 Ort' };
+
+describe('renderReadinessReportPdf', () => {
+ it('lists status, areas, items with severity word, legal basis and acknowledgement note, user and date', async () => {
+ const pdf = await renderReadinessReportPdf(
+ report([
+ item(),
+ item({
+ key: 'harvest-withdrawal',
+ subjectId: 'h1',
+ area: 'ERNTE',
+ severity: 'PRUEFEN',
+ title: 'Ernte in Wartezeit',
+ legalBasis: 'Wartezeit laut Behandlung',
+ acknowledgeable: true,
+ state: 'ACKNOWLEDGED',
+ acknowledgement: {
+ id: 'a1',
+ note: 'Alles in Ordnung',
+ userName: 'Imker Zwei',
+ createdAt: '2027-03-01T08:00:00.000Z',
+ },
+ }),
+ item({
+ key: 'vet-office',
+ area: 'STAMMDATEN',
+ severity: 'PRUEFEN',
+ title: 'Amt ist eingetragen',
+ legalBasis: null,
+ state: 'DONE',
+ detail: 'Nichts offen.',
+ }),
+ ]),
+ HOLDER,
+ );
+
+ const text = pdfText(pdf);
+
+ expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
+ expect(text).toContain('Prüfbericht');
+ expect(text).toContain('Imker Eins');
+ expect(text).toContain('15.03.2027');
+ expect(text).toContain('Offen');
+ for (const heading of ['Stammdaten', 'Ernte']) {
+ expect(text).toContain(heading);
+ }
+ expect(text).toContain('Pflicht');
+ expect(text).toContain('Prüfen');
+ expect(text).toContain('Daten fehlen');
+ expect(text).toContain('Art. 108 VO');
+ expect(text).toContain('Erledigt');
+ expect(text).toContain('Quittiert');
+ expect(text).toContain('Alles in Ordnung');
+ expect(text).toContain('Imker Zwei');
+ expect(text).toContain('01.03.2027');
+ });
+
+ it('shows the overall status Bereit when nothing is open', async () => {
+ const pdf = await renderReadinessReportPdf(
+ report([item({ state: 'DONE', detail: 'Nichts offen.' })]),
+ HOLDER,
+ );
+
+ const text = pdfText(pdf);
+
+ expect(text).toContain('Bereit');
+ expect(text).not.toContain('FEHLT');
+ });
+
+ it('prints FEHLT for an empty holder', async () => {
+ const pdf = await renderReadinessReportPdf(report([item()]), {
+ name: null,
+ address: ' ',
+ });
+
+ expect(pdfText(pdf)).toContain('FEHLT');
+ });
+
+ it('prints no subject labels, only how many objects an item names', async () => {
+ const pdf = await renderReadinessReportPdf(
+ report([
+ item({
+ subjects: [{ type: 'LOT', id: 'l1', label: 'Geheimmittel, Packung' }],
+ more: 2,
+ }),
+ ]),
+ HOLDER,
+ );
+
+ const text = pdfText(pdf);
+
+ expect(text).not.toContain('Geheimmittel');
+ expect(text).toContain('3 Objekte');
+ });
+
+ it('words the header without telling to treat items red and keeps a long norm in one piece', async () => {
+ const pdf = await renderReadinessReportPdf(
+ report([
+ item({
+ key: 'label-template-form',
+ area: 'ETIKETTEN',
+ legalBasis: 'LMIV Art. 13; Fertigpackungsverordnung (Annahme)',
+ }),
+ ]),
+ HOLDER,
+ );
+
+ const text = pdfText(pdf).replaceAll('', '');
+
+ expect(text).toContain('Jeder offene Punkt ist zu behandeln.');
+ expect(text).not.toContain('rot');
+ expect(
+ text
+ .split(String.fromCharCode(10))
+ .some((line) => line.includes('Fertigpackungsverordnung')),
+ ).toBe(true);
+ });
+
+ it('cuts an acknowledgement note at 600 characters with an ellipsis', async () => {
+ const pdf = await renderReadinessReportPdf(
+ report([
+ item({
+ state: 'ACKNOWLEDGED',
+ acknowledgeable: true,
+ acknowledgement: {
+ id: 'a1',
+ note: `${'x'.repeat(600)}HINTEN`,
+ userName: 'Imker',
+ createdAt: '2027-03-01T08:00:00.000Z',
+ },
+ }),
+ ]),
+ HOLDER,
+ );
+
+ const text = pdfText(pdf)
+ .replaceAll('', '')
+ .replaceAll(String.fromCharCode(10), '');
+
+ expect(text).not.toContain('HINTEN');
+ expect(text).toContain(`${'x'.repeat(600)}${String.fromCharCode(0x85)}`);
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness-report.ts b/ApiCore-Backend/src/compliance/readiness/readiness-report.ts
new file mode 100644
index 0000000..1d3708f
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/readiness-report.ts
@@ -0,0 +1,154 @@
+import PDFDocument from 'pdfkit';
+import {
+ PDF_MARGIN,
+ drawSection,
+ drawTable,
+ formatDay,
+ orDash,
+} from '../../export/pdf-table';
+import type {
+ ReadinessArea,
+ ReadinessItem,
+ ReadinessReport,
+ ReadinessSeverity,
+} from './readiness.types';
+
+const MISSING = 'FEHLT';
+
+/** A longer note is cut here and ends with an ellipsis. */
+export const MAX_NOTE_CHARS = 600;
+
+const AREA_TITLES: Readonly> = {
+ STAMMDATEN: 'Stammdaten',
+ BEHOERDEN: 'Behörden',
+ TIERARZNEIMITTEL: 'Tierarzneimittel',
+ ETIKETTEN: 'Etiketten',
+ ERNTE: 'Ernte',
+};
+
+const AREA_ORDER: readonly ReadinessArea[] = [
+ 'STAMMDATEN',
+ 'BEHOERDEN',
+ 'TIERARZNEIMITTEL',
+ 'ETIKETTEN',
+ 'ERNTE',
+];
+
+const SEVERITY_WORDS: Readonly> = {
+ PFLICHT: 'Pflicht',
+ PRUEFEN: 'Prüfen',
+};
+
+const STATE_WORDS: Readonly> = {
+ OPEN: 'Offen',
+ DONE: 'Erledigt',
+ ACKNOWLEDGED: 'Quittiert',
+};
+
+const COLUMN_WIDTHS = { state: 52, severity: 46, norm: 130, note: 120 };
+
+export interface ReadinessReportHolder {
+ name: string | null;
+ address: string | null;
+}
+
+function hasText(value: string | null | undefined): value is string {
+ return value !== null && value !== undefined && value.trim() !== '';
+}
+
+function holderLine(holder: ReadinessReportHolder): string {
+ const parts = [holder.name, holder.address].filter(hasText);
+ return parts.length > 0 ? parts.join(', ') : MISSING;
+}
+
+/** Only how many objects an item names, never their labels. */
+function objectsText(item: ReadinessItem): string {
+ const count = item.subjects.length + item.more;
+ return count > 0 ? ` (${count} ${count === 1 ? 'Objekt' : 'Objekte'})` : '';
+}
+
+function shortNote(note: string): string {
+ return note.length > MAX_NOTE_CHARS
+ ? `${note.slice(0, MAX_NOTE_CHARS)}…`
+ : note;
+}
+
+function itemRow(item: ReadinessItem): string[] {
+ const ack = item.acknowledgement;
+ return [
+ STATE_WORDS[item.state],
+ SEVERITY_WORDS[item.severity],
+ `${item.title}${objectsText(item)}\n${item.detail}`,
+ orDash(item.legalBasis),
+ ack
+ ? `${shortNote(ack.note)}\n${ack.userName}, ${formatDay(new Date(ack.createdAt))}`
+ : '–',
+ ];
+}
+
+function drawHeader(
+ doc: PDFKit.PDFDocument,
+ report: ReadinessReport,
+ holder: ReadinessReportHolder,
+): void {
+ doc
+ .font('Helvetica-Bold')
+ .fontSize(14)
+ .text('Prüfbericht zur Prüfbereitschaft', PDF_MARGIN, PDF_MARGIN);
+ doc.font('Helvetica').fontSize(9);
+ doc.text(
+ `Erstellt am ${formatDay(new Date(report.generatedAt))} · Bienenhalter: ${holderLine(holder)}`,
+ );
+ doc.moveDown(0.4);
+ const status =
+ report.status === 'READY'
+ ? 'Bereit: kein Punkt ist offen.'
+ : `Offen: ${report.openCount} ${report.openCount === 1 ? 'Punkt ist' : 'Punkte sind'} offen.`;
+ doc.font('Helvetica-Bold').fontSize(11).text(`Gesamtstatus: ${status}`);
+ doc.font('Helvetica').fontSize(8);
+ doc.text(
+ 'Jeder offene Punkt ist zu behandeln. „Pflicht“ muss behoben werden, „Prüfen“ kann nach Prüfung mit Notiz quittiert werden.',
+ );
+}
+
+/** The inspection report: status, then one table per area. */
+export function renderReadinessReportPdf(
+ report: ReadinessReport,
+ holder: ReadinessReportHolder,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const doc = new PDFDocument({ size: 'A4', margin: PDF_MARGIN });
+ const chunks: Buffer[] = [];
+ doc.on('data', (chunk: Buffer) => chunks.push(chunk));
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
+ doc.on('error', reject);
+
+ drawHeader(doc, report, holder);
+ const itemWidth =
+ doc.page.width -
+ 2 * PDF_MARGIN -
+ COLUMN_WIDTHS.state -
+ COLUMN_WIDTHS.severity -
+ COLUMN_WIDTHS.norm -
+ COLUMN_WIDTHS.note;
+ for (const area of AREA_ORDER) {
+ const items = report.items.filter((item) => item.area === area);
+ if (items.length === 0) {
+ continue;
+ }
+ drawSection(doc, AREA_TITLES[area]);
+ drawTable(
+ doc,
+ [
+ { header: 'Status', width: COLUMN_WIDTHS.state },
+ { header: 'Schwere', width: COLUMN_WIDTHS.severity },
+ { header: 'Prüfpunkt', width: itemWidth },
+ { header: 'Norm', width: COLUMN_WIDTHS.norm },
+ { header: 'Quittung', width: COLUMN_WIDTHS.note },
+ ],
+ items.map(itemRow),
+ );
+ }
+ doc.end();
+ });
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.controller.ts b/ApiCore-Backend/src/compliance/readiness/readiness.controller.ts
index 1e1e9a5..8400491 100644
--- a/ApiCore-Backend/src/compliance/readiness/readiness.controller.ts
+++ b/ApiCore-Backend/src/compliance/readiness/readiness.controller.ts
@@ -1,5 +1,6 @@
-import { Body, Controller, Get, Param, Post } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import type { Response } from 'express';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
import { PermissionResolverService } from '../../auth/permissions/permission-resolver.service';
@@ -7,6 +8,7 @@ import type { AuthenticatedUser } from '../../auth/types/authenticated-user';
import { Idempotent } from '../../common/idempotency/idempotent.decorator';
import { AcknowledgementsService } from './acknowledgements.service';
import { CreateAcknowledgementDto } from './dto/create-acknowledgement.dto';
+import { renderReadinessReportPdf } from './readiness-report';
import { ReadinessService } from './readiness.service';
@ApiTags('compliance')
@@ -28,6 +30,27 @@ export class ReadinessController {
);
}
+ /** The inspection report; the subjects follow the caller's rights. */
+ @RequirePermission('compliance:read')
+ @Get('readiness/report.pdf')
+ async reportPdf(
+ @CurrentUser() actor: AuthenticatedUser,
+ @Res() res: Response,
+ ): Promise {
+ const { report, holder } = await this.readiness.evaluateWithHolder(
+ new Date(),
+ await this.permissions.getPermissions(actor.id),
+ );
+ const pdf = await renderReadinessReportPdf(report, holder);
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader(
+ 'Content-Disposition',
+ 'attachment; filename="pruefbericht.pdf"',
+ );
+ res.send(pdf);
+ }
+
@RequirePermission('compliance:read')
@Get('readiness/summary')
summary() {
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.module.ts b/ApiCore-Backend/src/compliance/readiness/readiness.module.ts
index b320cee..35b35fc 100644
--- a/ApiCore-Backend/src/compliance/readiness/readiness.module.ts
+++ b/ApiCore-Backend/src/compliance/readiness/readiness.module.ts
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
+import { WithdrawalModule } from '../../withdrawal/withdrawal.module';
import { ComplianceModule } from '../compliance.module';
import { READINESS_RULES, READINESS_RULES_TOKEN } from './rules';
import { AcknowledgementsService } from './acknowledgements.service';
@@ -8,7 +9,7 @@ import { ReadinessService } from './readiness.service';
// Not for WorkerModule: the controller injects the permission resolver (to
// hide the subjects a caller may not see). The services themselves do not.
@Module({
- imports: [ComplianceModule],
+ imports: [ComplianceModule, WithdrawalModule],
controllers: [ReadinessController],
providers: [
ReadinessService,
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.service.spec.ts b/ApiCore-Backend/src/compliance/readiness/readiness.service.spec.ts
index d646133..701af46 100644
--- a/ApiCore-Backend/src/compliance/readiness/readiness.service.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/readiness.service.spec.ts
@@ -1,4 +1,5 @@
import { PrismaService } from '../../prisma/prisma.service';
+import type { WithdrawalCheckService } from '../../withdrawal/withdrawal-check.service';
import { fingerprintOf } from './fingerprint';
import { loadReadinessContext } from './readiness-context';
import { ReadinessService } from './readiness.service';
@@ -7,7 +8,7 @@ import type {
ReadinessRule,
ReadinessSubject,
} from './readiness.types';
-import { readinessContext } from './rules/rule-spec-helpers';
+import { readinessContext, siteSettings } from './rules/rule.spec-helper';
jest.mock('./readiness-context', () => ({
loadReadinessContext: jest.fn(),
@@ -72,6 +73,7 @@ function ack(patch: Record = {}) {
describe('ReadinessService', () => {
let service: ReadinessService;
let findAcknowledgements: jest.Mock;
+ const withdrawal = {} as WithdrawalCheckService;
beforeEach(() => {
rules.splice(0, rules.length);
@@ -80,7 +82,7 @@ describe('ReadinessService', () => {
const prisma = {
complianceAcknowledgement: { findMany: findAcknowledgements },
} as unknown as PrismaService;
- service = new ReadinessService(prisma, rules);
+ service = new ReadinessService(prisma, rules, withdrawal);
});
it('is READY when no rule has an open finding', async () => {
@@ -190,6 +192,7 @@ describe('ReadinessService', () => {
});
rules.splice(2, 1);
+ service.invalidateSummary();
expect(await service.summary(NOW)).toEqual({
status: 'OPEN',
@@ -198,6 +201,7 @@ describe('ReadinessService', () => {
});
rules.splice(1, 1);
+ service.invalidateSummary();
expect(await service.summary(NOW)).toEqual({
status: 'READY',
@@ -292,4 +296,105 @@ describe('ReadinessService', () => {
['2027', 'Regel'],
]);
});
+ it('hides subject labels of the medicine area without veterinary-medicines:read and keeps the count', async () => {
+ rules.push(
+ fakeRule({
+ key: 'lot-gaps',
+ area: 'TIERARZNEIMITTEL',
+ subjectPermission: 'veterinary-medicines:read',
+ findings: [
+ finding({
+ subjects: [
+ { type: 'LOT', id: 'l1', label: 'Geheimmittel, Packung' },
+ { type: 'LOT', id: 'l2', label: 'Geheimmittel, Packung' },
+ ],
+ }),
+ ],
+ }),
+ );
+
+ const hidden = (
+ await service.evaluate(
+ NOW,
+ new Set(['compliance:read', 'treatments:read']),
+ )
+ ).items[0];
+ const shown = (
+ await service.evaluate(NOW, new Set(['veterinary-medicines:read']))
+ ).items[0];
+
+ expect(hidden.subjects).toEqual([]);
+ expect(hidden.more).toBe(2);
+ expect(JSON.stringify(hidden)).not.toContain('Geheimmittel');
+ expect(shown.subjects).toHaveLength(2);
+ });
+
+ it('hands the loader the withdrawal service and gives the holder with the report', async () => {
+ rules.push(fakeRule({ key: 'a' }));
+ loadContextMock.mockResolvedValue(
+ readinessContext({
+ site: siteSettings({ holderName: 'Imker', holderAddress: null }),
+ }),
+ );
+
+ const result = await service.evaluateWithHolder(NOW, new Set());
+
+ expect(loadContextMock).toHaveBeenCalledWith(expect.anything(), NOW, {
+ withdrawal,
+ });
+ expect(result.holder).toEqual({ name: 'Imker', address: null });
+ expect(result.report.status).toBe('READY');
+ });
+
+ it('serves the summary from memory within 60 s and loads the context once', async () => {
+ rules.push(fakeRule({ key: 'a', findings: [finding()] }));
+
+ const first = await service.summary(NOW);
+ const second = await service.summary(new Date(NOW.getTime() + 59_000));
+
+ expect(second).toBe(first);
+ expect(loadContextMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('recomputes the summary after the TTL and after invalidateSummary', async () => {
+ rules.push(fakeRule({ key: 'a', findings: [finding()] }));
+ await service.summary(NOW);
+
+ await service.summary(new Date(NOW.getTime() + 61_000));
+ expect(loadContextMock).toHaveBeenCalledTimes(2);
+
+ service.invalidateSummary();
+ await service.summary(new Date(NOW.getTime() + 62_000));
+ expect(loadContextMock).toHaveBeenCalledTimes(3);
+ });
+
+ it('never caches evaluate', async () => {
+ rules.push(fakeRule({ key: 'a' }));
+ await service.evaluate(NOW, new Set());
+ await service.evaluate(NOW, new Set());
+
+ expect(loadContextMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('asks for the acknowledgements by item key and matches the fingerprints in memory', async () => {
+ rules.push(
+ fakeRule({ key: 'fake', acknowledgeable: true, findings: [finding()] }),
+ );
+ findAcknowledgements.mockResolvedValue([
+ ack(),
+ ack({ id: 'old', fingerprint: 'x'.repeat(64) }),
+ ]);
+
+ const report = await service.evaluate(NOW, new Set());
+
+ expect(
+ (findAcknowledgements.mock.calls as unknown[][])[0][0],
+ ).toMatchObject({
+ where: { revokedAt: null, itemKey: { in: ['fake'] } },
+ });
+ expect(report.items[0]).toMatchObject({
+ state: 'ACKNOWLEDGED',
+ acknowledgement: { id: 'ack1' },
+ });
+ });
});
diff --git a/ApiCore-Backend/src/compliance/readiness/readiness.service.ts b/ApiCore-Backend/src/compliance/readiness/readiness.service.ts
index fbc2851..8d34edb 100644
--- a/ApiCore-Backend/src/compliance/readiness/readiness.service.ts
+++ b/ApiCore-Backend/src/compliance/readiness/readiness.service.ts
@@ -1,7 +1,11 @@
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
+import { WithdrawalCheckService } from '../../withdrawal/withdrawal-check.service';
import { fingerprintOf } from './fingerprint';
-import { loadReadinessContext } from './readiness-context';
+import {
+ loadReadinessContext,
+ type ReadinessContext,
+} from './readiness-context';
import {
GLOBAL_SUBJECT_ID,
MAX_SUBJECTS,
@@ -17,6 +21,15 @@ import { READINESS_RULES_TOKEN } from './rules';
const DONE_DETAIL = 'Nichts offen.';
+/** How long `summary` may be served from memory. */
+export const SUMMARY_TTL_MS = 60_000;
+
+/** Name and address of the beekeeper as stored (null when empty). */
+export interface ReadinessHolder {
+ name: string | null;
+ address: string | null;
+}
+
interface AcknowledgementRow {
id: string;
itemKey: string;
@@ -125,13 +138,45 @@ export class ReadinessService {
private readonly prisma: PrismaService,
@Inject(READINESS_RULES_TOKEN)
private readonly rules: readonly ReadinessRule[],
+ private readonly withdrawal: WithdrawalCheckService,
) {}
+ // The dashboard asks often: the summary is kept per process for a minute.
+ // Changes made by another process show up after at most SUMMARY_TTL_MS.
+ private summaryCache: { at: number; value: ReadinessSummary } | null = null;
+
async evaluate(
now: Date,
permissions: ReadonlySet,
): Promise {
- const ctx = await loadReadinessContext(this.prisma, now);
+ return this.evaluateIn(await this.loadContext(now), permissions);
+ }
+
+ /** The report and the holder it is printed for, from one loaded context. */
+ async evaluateWithHolder(
+ now: Date,
+ permissions: ReadonlySet,
+ ): Promise<{ report: ReadinessReport; holder: ReadinessHolder }> {
+ const ctx = await this.loadContext(now);
+ return {
+ report: await this.evaluateIn(ctx, permissions),
+ holder: {
+ name: ctx.site.holderName,
+ address: ctx.site.holderAddress,
+ },
+ };
+ }
+
+ private loadContext(now: Date): Promise {
+ return loadReadinessContext(this.prisma, now, {
+ withdrawal: this.withdrawal,
+ });
+ }
+
+ private async evaluateIn(
+ ctx: ReadinessContext,
+ permissions: ReadonlySet,
+ ): Promise {
const drafts = this.rules.flatMap((rule) => {
const findings = rule.evaluate(ctx);
return findings.length === 0
@@ -148,7 +193,7 @@ export class ReadinessService {
const openCount = items.filter(isOpen).length;
return {
status: openCount === 0 ? 'READY' : 'OPEN',
- generatedAt: now.toISOString(),
+ generatedAt: ctx.now.toISOString(),
openCount,
items,
};
@@ -156,14 +201,25 @@ export class ReadinessService {
/** No names in it: the subjects stay hidden. */
async summary(now: Date): Promise {
+ const cached = this.summaryCache;
+ if (cached && now.getTime() - cached.at < SUMMARY_TTL_MS) {
+ return cached.value;
+ }
const report = await this.evaluate(now, new Set());
const open = report.items.filter(isOpen);
const first = open.find((item) => item.severity === 'PFLICHT') ?? open[0];
- return {
+ const value: ReadinessSummary = {
status: report.status,
openCount: report.openCount,
firstItemKey: first?.key ?? null,
};
+ this.summaryCache = { at: now.getTime(), value };
+ return value;
+ }
+
+ /** Forgets the cached summary (an acknowledgement changed in this process). */
+ invalidateSummary(): void {
+ this.summaryCache = null;
}
// The valid acknowledgement of each open item that can be acknowledged:
@@ -182,11 +238,7 @@ export class ReadinessService {
await this.prisma.complianceAcknowledgement.findMany({
where: {
revokedAt: null,
- OR: candidates.map((item) => ({
- itemKey: item.key,
- subjectId: item.subjectId,
- fingerprint: item.fingerprint,
- })),
+ itemKey: { in: [...new Set(candidates.map((item) => item.key))] },
},
select: {
id: true,
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.spec.ts
index 2e33e94..ba2dd8e 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/food-business-data.rule.spec.ts
@@ -1,5 +1,5 @@
import { foodBusinessDataRule } from './food-business-data.rule';
-import { readinessContext, siteSettings } from './rule-spec-helpers';
+import { readinessContext, siteSettings } from './rule.spec-helper';
describe('food-business-data rule', () => {
it('is open when the name is empty', () => {
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.spec.ts
new file mode 100644
index 0000000..a333bb5
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.spec.ts
@@ -0,0 +1,140 @@
+import { berlinDayOf } from '../../../common/dates';
+import {
+ isInWindow,
+ withdrawalWindowOf,
+} from '../../../treatments/withdrawal-window';
+import { withdrawalRangeText } from '../../../withdrawal/withdrawal-text';
+import { harvestWithdrawalRule } from './harvest-withdrawal.rule';
+import {
+ conflict,
+ readinessContext,
+ readinessHarvest,
+} from './rule.spec-helper';
+
+describe('harvest-withdrawal rule', () => {
+ it('opens one item per harvest with a conflict, subjectId is the harvest', () => {
+ const findings = harvestWithdrawalRule.evaluate(
+ readinessContext({
+ harvests: [
+ readinessHarvest({ id: 'h1', conflicts: [conflict()] }),
+ readinessHarvest({ id: 'h2' }),
+ readinessHarvest({
+ id: 'h3',
+ day: '2027-03-02',
+ conflicts: [
+ conflict({ harvestId: 'h3', harvestedOn: '2027-03-02' }),
+ ],
+ }),
+ ],
+ }),
+ );
+
+ expect(findings.map((finding) => finding.subjectId)).toEqual(['h1', 'h3']);
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({
+ type: 'HARVEST',
+ id: 'h1',
+ label: expect.stringContaining('01.03.2027') as unknown,
+ }),
+ ]);
+ });
+
+ it('is done for a harvest without a conflict', () => {
+ expect(
+ harvestWithdrawalRule.evaluate(
+ readinessContext({ harvests: [readinessHarvest()] }),
+ ),
+ ).toEqual([]);
+ });
+
+ it('words the window end with the shared helper and uses the longest window of the harvest', () => {
+ const [finding] = harvestWithdrawalRule.evaluate(
+ readinessContext({
+ today: '2027-03-15',
+ harvests: [
+ readinessHarvest({
+ conflicts: [
+ conflict({ colonyId: 'c1', untilDay: '2027-03-10' }),
+ conflict({ colonyId: 'c2', untilDay: '2027-03-20' }),
+ ],
+ }),
+ ],
+ }),
+ );
+
+ expect(finding.detail).toContain(
+ withdrawalRangeText('2027-03-20', '2027-03-15'),
+ );
+ });
+
+ it('agrees with the window helper: inside the window is open, the first free day is clean', () => {
+ const treatment = {
+ id: 't1',
+ colonyId: 'c1',
+ product: 'Mittel Eins',
+ date: new Date('2027-02-01T00:00:00.000Z'),
+ startDate: null,
+ endDate: new Date('2027-02-05T00:00:00.000Z'),
+ withdrawalPeriodDays: 10,
+ };
+ const window = withdrawalWindowOf(treatment);
+ if (window === null) {
+ throw new Error('window expected');
+ }
+ // What the service reports for a harvest on this day in this window.
+ const harvestWith = (day: string) =>
+ readinessHarvest({
+ id: `h-${day}`,
+ day,
+ conflicts: isInWindow(window, berlinDayOf(new Date(`${day}T00:00:00Z`)))
+ ? [
+ conflict({
+ harvestId: `h-${day}`,
+ harvestedOn: day,
+ treatmentId: window.treatmentId,
+ untilDay: window.untilDay,
+ }),
+ ]
+ : [],
+ });
+ const lastBlocked = '2027-02-14';
+ const firstFree = window.untilDay;
+
+ const findings = harvestWithdrawalRule.evaluate(
+ readinessContext({
+ harvests: [harvestWith(lastBlocked), harvestWith(firstFree)],
+ }),
+ );
+
+ expect(firstFree).toBe('2027-02-15');
+ expect(findings.map((finding) => finding.subjectId)).toEqual([
+ `h-${lastBlocked}`,
+ ]);
+ });
+
+ it('keeps product names out of the texts, is acknowledgeable and the fingerprint follows the windows', () => {
+ const open = (untilDay: string) =>
+ harvestWithdrawalRule.evaluate(
+ readinessContext({
+ harvests: [readinessHarvest({ conflicts: [conflict({ untilDay })] })],
+ }),
+ )[0];
+
+ expect(
+ JSON.stringify([open('2027-03-10').title, open('2027-03-10').detail]),
+ ).not.toContain('Mittel Eins');
+ expect(open('2027-03-10').fingerprintParts).toEqual(
+ open('2027-03-10').fingerprintParts,
+ );
+ expect(open('2027-03-10').fingerprintParts).not.toEqual(
+ open('2027-03-12').fingerprintParts,
+ );
+ expect(harvestWithdrawalRule).toMatchObject({
+ key: 'harvest-withdrawal',
+ area: 'ERNTE',
+ severity: 'PRUEFEN',
+ acknowledgeable: true,
+ subjectPermission: 'treatments:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.ts
new file mode 100644
index 0000000..c1ce29f
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/harvest-withdrawal.rule.ts
@@ -0,0 +1,51 @@
+import { formatDayDe } from '../../../common/format-day';
+import { withdrawalRangeText } from '../../../withdrawal/withdrawal-text';
+import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
+
+export const harvestWithdrawalRule: ReadinessRule = {
+ key: 'harvest-withdrawal',
+ area: 'ERNTE',
+ severity: 'PRUEFEN',
+ title: 'Eine Ernte fiel in die Wartezeit eines Volks.',
+ legalBasis: 'Wartezeit laut Behandlung (Annahme 10)',
+ acknowledgeable: true,
+ subjectPermission: 'treatments:read',
+ evaluate: ({ harvests, today }): ReadinessFinding[] =>
+ harvests
+ .filter((harvest) => harvest.conflicts.length > 0)
+ .map((harvest) => {
+ // The window that blocks the longest, worded like everywhere else.
+ const untilDay = harvest.conflicts
+ .map((conflict) => conflict.untilDay)
+ .sort()
+ .at(-1) as string;
+ const colonies = new Set(
+ harvest.conflicts.map((conflict) => conflict.colonyId),
+ ).size;
+ return {
+ subjectId: harvest.id,
+ detail: `${colonies === 1 ? 'Ein Volk' : `${colonies} Völker`} in der Wartezeit: ${withdrawalRangeText(untilDay, today)}.`,
+ fingerprintParts: {
+ harvestId: harvest.id,
+ conflicts: harvest.conflicts
+ .map((conflict) => ({
+ colonyId: conflict.colonyId,
+ treatmentId: conflict.treatmentId,
+ untilDay: conflict.untilDay,
+ }))
+ .sort((a, b) =>
+ `${a.colonyId}:${a.treatmentId}`.localeCompare(
+ `${b.colonyId}:${b.treatmentId}`,
+ ),
+ ),
+ },
+ subjects: [
+ {
+ type: 'HARVEST',
+ id: harvest.id,
+ label: `Ernte vom ${formatDayDe(harvest.day, true)}`,
+ },
+ ],
+ };
+ }),
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.spec.ts
index 2469f87..57ff4c6 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/herd-change.rule.spec.ts
@@ -6,7 +6,7 @@ import {
herdStatus,
notice,
readinessContext,
-} from './rule-spec-helpers';
+} from './rule.spec-helper';
function deviation(patch: Partial = {}): HerdDeviation {
return {
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.spec.ts
index c0d8283..2b6f891 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/holder-data.rule.spec.ts
@@ -1,5 +1,5 @@
import { holderDataRule } from './holder-data.rule';
-import { readinessContext, siteSettings } from './rule-spec-helpers';
+import { readinessContext, siteSettings } from './rule.spec-helper';
describe('holder-data rule', () => {
it('is open when the holder name is empty', () => {
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/index.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/index.spec.ts
index ba16db5..612be50 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/index.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/index.spec.ts
@@ -1,12 +1,20 @@
import { READINESS_RULES } from './index';
import {
complianceSettings,
+ conflict,
+ completeTemplate,
herdStatus,
+ readinessHarvest,
+ readinessLot,
+ readinessMedicine,
+ treatmentLotRef,
+ treatmentSource,
notice,
readinessContext,
restriction,
siteSettings,
-} from './rule-spec-helpers';
+ colonyMove,
+} from './rule.spec-helper';
describe('READINESS_RULES', () => {
it('has unique keys', () => {
@@ -27,6 +35,38 @@ describe('READINESS_RULES', () => {
restriction({ id: 'r1' }),
restriction({ id: 'r2', apiary: { id: 'a2', name: 'Zwei' } }),
],
+ treatments: [
+ treatmentSource({ id: 't1', supplier: null, apiaryId: null }),
+ treatmentSource({
+ id: 't2',
+ date: new Date('2026-02-01T00:00:00.000Z'),
+ amount: null,
+ lot: treatmentLotRef({ id: 'l1', supplier: null }),
+ }),
+ ],
+ moves: [colonyMove()],
+ lots: [
+ readinessLot({
+ id: 'l1',
+ supplier: null,
+ prescriptionOnly: true,
+ expiresOn: '2020-01-01',
+ remaining: -1,
+ }),
+ readinessLot({ id: 'l2', expiresOn: '2020-01-01', remaining: 2 }),
+ ],
+ medicines: [readinessMedicine({ withdrawalPeriodDays: null })],
+ templates: [
+ completeTemplate({ elements: [] }),
+ completeTemplate({ id: 'tpl2' }),
+ ],
+ harvests: [
+ readinessHarvest({ id: 'h1', conflicts: [conflict()] }),
+ readinessHarvest({
+ id: 'h2',
+ conflicts: [conflict({ harvestId: 'h2' })],
+ }),
+ ],
});
for (const rule of READINESS_RULES) {
@@ -38,4 +78,106 @@ describe('READINESS_RULES', () => {
}).toEqual({ key: rule.key, unique: true });
}
});
+
+ it('lists the 18 checks of the spec table with area, severity, acknowledgement and subject permission', () => {
+ expect(
+ READINESS_RULES.map((rule) => [
+ rule.key,
+ rule.area,
+ rule.severity,
+ rule.acknowledgeable,
+ rule.subjectPermission ?? null,
+ ]),
+ ).toEqual([
+ ['holder-data', 'STAMMDATEN', 'PFLICHT', false, null],
+ ['food-business-data', 'STAMMDATEN', 'PFLICHT', false, null],
+ ['vet-office', 'STAMMDATEN', 'PRUEFEN', false, null],
+ ['registration', 'BEHOERDEN', 'PFLICHT', false, null],
+ ['herd-change', 'BEHOERDEN', 'PFLICHT', false, 'apiaries:read'],
+ ['tsk-report', 'BEHOERDEN', 'PFLICHT', false, null],
+ ['restriction-active', 'BEHOERDEN', 'PFLICHT', false, 'apiaries:read'],
+ [
+ 'treatment-log-gaps',
+ 'TIERARZNEIMITTEL',
+ 'PFLICHT',
+ false,
+ 'treatments:read',
+ ],
+ [
+ 'treatment-location-uncertain',
+ 'TIERARZNEIMITTEL',
+ 'PRUEFEN',
+ false,
+ 'treatments:read',
+ ],
+ [
+ 'lot-gaps',
+ 'TIERARZNEIMITTEL',
+ 'PFLICHT',
+ false,
+ 'veterinary-medicines:read',
+ ],
+ [
+ 'lot-prescription',
+ 'TIERARZNEIMITTEL',
+ 'PFLICHT',
+ false,
+ 'veterinary-medicines:read',
+ ],
+ [
+ 'medicine-withdrawal-missing',
+ 'TIERARZNEIMITTEL',
+ 'PRUEFEN',
+ false,
+ 'veterinary-medicines:read',
+ ],
+ [
+ 'lot-expired-stock',
+ 'TIERARZNEIMITTEL',
+ 'PRUEFEN',
+ true,
+ 'veterinary-medicines:read',
+ ],
+ [
+ 'lot-negative',
+ 'TIERARZNEIMITTEL',
+ 'PRUEFEN',
+ false,
+ 'veterinary-medicines:read',
+ ],
+ [
+ 'treatment-expired-lot',
+ 'TIERARZNEIMITTEL',
+ 'PRUEFEN',
+ true,
+ 'veterinary-medicines:read',
+ ],
+ ['label-template-gaps', 'ETIKETTEN', 'PFLICHT', false, null],
+ ['label-template-form', 'ETIKETTEN', 'PRUEFEN', true, null],
+ ['harvest-withdrawal', 'ERNTE', 'PRUEFEN', true, 'treatments:read'],
+ ]);
+ });
+
+ it('keeps medicine and product names out of every title and detail of the medicine rules', () => {
+ const ctx = readinessContext({
+ treatments: [treatmentSource({ id: 't1', supplier: null })],
+ lots: [
+ readinessLot({
+ supplier: null,
+ remaining: -1,
+ expiresOn: '2020-01-01',
+ }),
+ ],
+ medicines: [readinessMedicine({ withdrawalPeriodDays: null })],
+ harvests: [readinessHarvest({ conflicts: [conflict()] })],
+ });
+
+ for (const rule of READINESS_RULES) {
+ for (const finding of rule.evaluate(ctx)) {
+ expect(
+ JSON.stringify([rule.title, finding.title, finding.detail]),
+ ).not.toContain('Mittel Eins');
+ }
+ }
+ });
});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/index.ts b/ApiCore-Backend/src/compliance/readiness/rules/index.ts
index 76aa040..4dfc1eb 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/index.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/index.ts
@@ -1,13 +1,24 @@
import type { ReadinessRule } from '../readiness.types';
import { foodBusinessDataRule } from './food-business-data.rule';
import { herdChangeRule } from './herd-change.rule';
+import { harvestWithdrawalRule } from './harvest-withdrawal.rule';
import { holderDataRule } from './holder-data.rule';
+import { labelTemplateFormRule } from './label-template-form.rule';
+import { labelTemplateGapsRule } from './label-template-gaps.rule';
+import { lotExpiredStockRule } from './lot-expired-stock.rule';
+import { lotGapsRule } from './lot-gaps.rule';
+import { lotNegativeRule } from './lot-negative.rule';
+import { lotPrescriptionRule } from './lot-prescription.rule';
+import { medicineWithdrawalMissingRule } from './medicine-withdrawal-missing.rule';
import { registrationRule } from './registration.rule';
import { restrictionActiveRule } from './restriction-active.rule';
+import { treatmentExpiredLotRule } from './treatment-expired-lot.rule';
+import { treatmentLocationUncertainRule } from './treatment-location-uncertain.rule';
+import { treatmentLogGapsRule } from './treatment-log-gaps.rule';
import { tskReportRule } from './tsk-report.rule';
import { vetOfficeRule } from './vet-office.rule';
-/** In the order of the report; Task 13 appends the other rules. */
+/** The 18 checks, in the order of the report. */
export const READINESS_RULES: readonly ReadinessRule[] = [
holderDataRule,
foodBusinessDataRule,
@@ -16,6 +27,17 @@ export const READINESS_RULES: readonly ReadinessRule[] = [
herdChangeRule,
tskReportRule,
restrictionActiveRule,
+ treatmentLogGapsRule,
+ treatmentLocationUncertainRule,
+ lotGapsRule,
+ lotPrescriptionRule,
+ medicineWithdrawalMissingRule,
+ lotExpiredStockRule,
+ lotNegativeRule,
+ treatmentExpiredLotRule,
+ labelTemplateGapsRule,
+ labelTemplateFormRule,
+ harvestWithdrawalRule,
];
/** Injection token: the module provides the list, tests may override it. */
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/label-checks.ts b/ApiCore-Backend/src/compliance/readiness/rules/label-checks.ts
new file mode 100644
index 0000000..4e9c467
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/label-checks.ts
@@ -0,0 +1,47 @@
+import {
+ parseDigitRules,
+ expectedParticulars,
+} from '../../../label-templates/label-check.loader';
+import { pdfTextMeasurer } from '../../../label-templates/font-metrics';
+import {
+ checkLabel,
+ type LabelCheckResult,
+} from '../../../label-templates/label-particulars';
+import { buildPlaceholderValues } from '../../../label-templates/label-templates.service';
+import type { ReadinessLabelTemplate } from '../loaders/labels.loader';
+import type { ReadinessContext } from '../readiness-context';
+
+const cache = new WeakMap>();
+
+/**
+ * The print check of one template against its target bottling: the very
+ * `checkLabel` the print runs, fed with the master data of the context and the
+ * digit rules of the compliance settings. Both label rules read it; the
+ * result is kept per context so a template is measured once.
+ *
+ * Digit rules that cannot be read give no rules: the height of the net
+ * quantity is then not judged (the print refuses such settings with 409).
+ */
+export function labelCheckOf(
+ ctx: ReadinessContext,
+ template: ReadinessLabelTemplate,
+): LabelCheckResult {
+ const known = cache.get(ctx) ?? new Map();
+ cache.set(ctx, known);
+ const found = known.get(template.id);
+ if (found) {
+ return found;
+ }
+ const { bottling, sample } = template.target;
+ const result = checkLabel({
+ elements: template.elements,
+ minXHeightMm: template.minXHeightMm,
+ digitRules: parseDigitRules(ctx.settings.netQuantityDigitRules) ?? [],
+ values: buildPlaceholderValues(bottling, ctx.site),
+ expected: expectedParticulars(bottling, ctx.site),
+ sample,
+ measurer: pdfTextMeasurer(),
+ });
+ known.set(template.id, result);
+ return result;
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.spec.ts
new file mode 100644
index 0000000..8f44637
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.spec.ts
@@ -0,0 +1,93 @@
+import { labelTemplateFormRule } from './label-template-form.rule';
+import {
+ complianceSettings,
+ completeTemplate,
+ readinessContext,
+ SPEC_DIGIT_RULES,
+} from './rule.spec-helper';
+
+function run(
+ template = completeTemplate(),
+ digitRules: unknown = SPEC_DIGIT_RULES,
+) {
+ return labelTemplateFormRule.evaluate(
+ readinessContext({
+ templates: [template],
+ settings: complianceSettings({
+ netQuantityDigitRules: digitRules as never,
+ }),
+ }),
+ );
+}
+
+function smallNetQuantity(fontSize: number) {
+ const base = completeTemplate();
+ return {
+ ...base,
+ elements: base.elements.map((element) =>
+ element.id === 'e2' ? { ...element, fontSize } : element,
+ ),
+ };
+}
+
+describe('label-template-form rule', () => {
+ it('is done for a template without warnings', () => {
+ expect(run()).toEqual([]);
+ });
+
+ it('lists a template whose net quantity is set too small for the digit rules of the settings', () => {
+ const findings = run(smallNetQuantity(10));
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjectId).toBe('tpl1');
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({
+ type: 'LABEL_TEMPLATE',
+ id: 'tpl1',
+ missing: [expect.stringContaining('Füllmenge')],
+ }),
+ ]);
+ });
+
+ it('takes the digit rules only from the compliance settings', () => {
+ expect(run(smallNetQuantity(10), [{ maxGrams: null, minMm: 1 }])).toEqual(
+ [],
+ );
+ expect(run(smallNetQuantity(10), 'broken')).toEqual([]);
+ });
+
+ it('reports warnings only, a gap alone gives no finding', () => {
+ const base = completeTemplate();
+ const noOrigin = {
+ ...base,
+ elements: base.elements.filter((element) => element.id !== 'e5'),
+ };
+
+ expect(run(noOrigin)).toEqual([]);
+ });
+
+ it('is acknowledgeable and has a new fingerprint when the warning codes change', () => {
+ const small = run(smallNetQuantity(10))[0];
+ const again = run(smallNetQuantity(10))[0];
+ const smallLot = completeTemplate();
+ const withTwoWarnings = run({
+ ...smallLot,
+ elements: smallLot.elements.map((element) =>
+ element.id === 'e2' || element.id === 'e4'
+ ? { ...element, fontSize: 4 }
+ : element,
+ ),
+ })[0];
+
+ expect(labelTemplateFormRule).toMatchObject({
+ key: 'label-template-form',
+ area: 'ETIKETTEN',
+ severity: 'PRUEFEN',
+ acknowledgeable: true,
+ });
+ expect(again.fingerprintParts).toEqual(small.fingerprintParts);
+ expect(withTwoWarnings.fingerprintParts).not.toEqual(
+ small.fingerprintParts,
+ );
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.ts
new file mode 100644
index 0000000..4a6eec0
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/label-template-form.rule.ts
@@ -0,0 +1,40 @@
+import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
+import { labelCheckOf } from './label-checks';
+
+export const labelTemplateFormRule: ReadinessRule = {
+ key: 'label-template-form',
+ area: 'ETIKETTEN',
+ severity: 'PRUEFEN',
+ title: 'Ein Etikett hat Hinweise zur Gestaltung.',
+ legalBasis: 'LMIV Art. 13; Fertigpackungsverordnung (Annahme)',
+ acknowledgeable: true,
+ evaluate: (ctx): ReadinessFinding[] =>
+ ctx.templates.flatMap((template): ReadinessFinding[] => {
+ const { warnings } = labelCheckOf(ctx, template);
+ if (warnings.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: template.id,
+ detail:
+ warnings.length === 1
+ ? 'Das Etikett hat einen Hinweis zur Gestaltung. Im Designer ansehen.'
+ : `Das Etikett hat ${warnings.length} Hinweise zur Gestaltung. Im Designer ansehen.`,
+ // The codes: another warning (or none of the old) reopens it.
+ fingerprintParts: {
+ templateId: template.id,
+ warningCodes: warnings.map((warning) => warning.code).sort(),
+ },
+ subjects: [
+ {
+ type: 'LABEL_TEMPLATE',
+ id: template.id,
+ label: template.name,
+ missing: warnings.map((warning) => warning.title),
+ },
+ ],
+ },
+ ];
+ }),
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.spec.ts
new file mode 100644
index 0000000..501d525
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.spec.ts
@@ -0,0 +1,102 @@
+import { labelTemplateGapsRule } from './label-template-gaps.rule';
+import {
+ completeTemplate,
+ readinessContext,
+ textElement,
+} from './rule.spec-helper';
+
+function run(template = completeTemplate()) {
+ return labelTemplateGapsRule.evaluate(
+ readinessContext({ templates: [template] }),
+ );
+}
+
+describe('label-template-gaps rule', () => {
+ it('is done for a template that carries every mandatory particular', () => {
+ expect(run()).toEqual([]);
+ expect(labelTemplateGapsRule.evaluate(readinessContext())).toEqual([]);
+ });
+
+ it('opens one item per template with a gap, subjectId is the template', () => {
+ const base = completeTemplate();
+ const withoutOrigin = {
+ ...base,
+ elements: base.elements.filter((element) => element.id !== 'e5'),
+ };
+
+ const findings = run(withoutOrigin);
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjectId).toBe('tpl1');
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({
+ type: 'LABEL_TEMPLATE',
+ id: 'tpl1',
+ label: 'Vorlage Eins',
+ missing: [expect.stringContaining('Herkunft')],
+ }),
+ ]);
+ });
+
+ it('uses the target bottling of the template (the newest with stock, else the newest, else the sample)', () => {
+ const base = completeTemplate();
+ const fixedBestBefore = base.elements.map((element) =>
+ element.id === 'e3'
+ ? textElement({
+ ...element,
+ placeholderKey: null,
+ text: 'Mindestens haltbar bis 10.01.2029',
+ })
+ : element,
+ );
+ const matching = { ...base, elements: fixedBestBefore };
+ const otherBottling = {
+ ...matching,
+ target: {
+ ...matching.target,
+ bottling: {
+ ...matching.target.bottling,
+ bestBeforeDate: new Date('2030-06-01T00:00:00.000Z'),
+ },
+ },
+ };
+
+ expect(run(matching)).toEqual([]);
+ const findings = run(otherBottling);
+ expect(findings[0].subjects[0].missing).toEqual([
+ expect.stringContaining('Mindesthaltbarkeit'),
+ ]);
+ });
+
+ it('names no template or master data value in the texts, the fingerprint follows the gap codes', () => {
+ const base = completeTemplate();
+ const noOrigin = {
+ ...base,
+ elements: base.elements.filter((element) => element.id !== 'e5'),
+ };
+ const noOriginNoLot = {
+ ...base,
+ elements: base.elements.filter(
+ (element) => element.id !== 'e5' && element.id !== 'e4',
+ ),
+ };
+ const [one] = run(noOrigin);
+ const [same] = run(noOrigin);
+ const [two] = run(noOriginNoLot);
+
+ expect(JSON.stringify([one.title, one.detail])).not.toContain(
+ 'Vorlage Eins',
+ );
+ expect(same.fingerprintParts).toEqual(one.fingerprintParts);
+ expect(two.fingerprintParts).not.toEqual(one.fingerprintParts);
+ });
+
+ it('is a Pflicht rule of the label area that cannot be acknowledged', () => {
+ expect(labelTemplateGapsRule).toMatchObject({
+ key: 'label-template-gaps',
+ area: 'ETIKETTEN',
+ severity: 'PFLICHT',
+ acknowledgeable: false,
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.ts
new file mode 100644
index 0000000..501513b
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/label-template-gaps.rule.ts
@@ -0,0 +1,39 @@
+import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
+import { labelCheckOf } from './label-checks';
+
+export const labelTemplateGapsRule: ReadinessRule = {
+ key: 'label-template-gaps',
+ area: 'ETIKETTEN',
+ severity: 'PFLICHT',
+ title: 'Auf einem Etikett fehlen Pflichtangaben.',
+ legalBasis: 'LMIV, HonigV, LKV',
+ acknowledgeable: false,
+ evaluate: (ctx): ReadinessFinding[] =>
+ ctx.templates.flatMap((template): ReadinessFinding[] => {
+ const { gaps } = labelCheckOf(ctx, template);
+ if (gaps.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: template.id,
+ detail:
+ gaps.length === 1
+ ? 'Dem Etikett fehlt eine Pflichtangabe. Im Designer ergänzen.'
+ : `Dem Etikett fehlen ${gaps.length} Pflichtangaben. Im Designer ergänzen.`,
+ fingerprintParts: {
+ templateId: template.id,
+ gapCodes: gaps.map((gap) => gap.code).sort(),
+ },
+ subjects: [
+ {
+ type: 'LABEL_TEMPLATE',
+ id: template.id,
+ label: template.name,
+ missing: gaps.map((gap) => gap.title),
+ },
+ ],
+ },
+ ];
+ }),
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.spec.ts
new file mode 100644
index 0000000..e70b1a5
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.spec.ts
@@ -0,0 +1,65 @@
+import { lotExpiredStockRule } from './lot-expired-stock.rule';
+import { readinessContext, readinessLot } from './rule.spec-helper';
+
+const TODAY = '2027-03-15';
+
+function run(lots: ReturnType[]) {
+ return lotExpiredStockRule.evaluate(readinessContext({ today: TODAY, lots }));
+}
+
+describe('lot-expired-stock rule', () => {
+ it('opens one item per expired lot with a rest, subjectId is the lot', () => {
+ const findings = run([
+ readinessLot({ id: 'l1', expiresOn: '2027-03-14', remaining: 5 }),
+ readinessLot({ id: 'l2', expiresOn: '2026-01-01', remaining: 0.5 }),
+ ]);
+
+ expect(findings.map((finding) => finding.subjectId)).toEqual(['l1', 'l2']);
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({ type: 'LOT', id: 'l1' }),
+ ]);
+ });
+
+ it('is done for a lot that expires today or later, one without a rest, one without expiry', () => {
+ expect(
+ run([
+ readinessLot({ id: 'a', expiresOn: TODAY, remaining: 5 }),
+ readinessLot({ id: 'b', expiresOn: '2020-01-01', remaining: 0 }),
+ readinessLot({ id: 'c', expiresOn: '2020-01-01', remaining: -2 }),
+ readinessLot({ id: 'd', expiresOn: null, remaining: 5 }),
+ ]),
+ ).toEqual([]);
+ });
+
+ it('is acknowledgeable per lot and gets a new fingerprint when the rest changes', () => {
+ const [before] = run([
+ readinessLot({ id: 'l1', expiresOn: '2027-01-01', remaining: 5 }),
+ ]);
+ const [same] = run([
+ readinessLot({ id: 'l1', expiresOn: '2027-01-01', remaining: 5 }),
+ ]);
+ const [changed] = run([
+ readinessLot({ id: 'l1', expiresOn: '2027-01-01', remaining: 3 }),
+ ]);
+
+ expect(lotExpiredStockRule.acknowledgeable).toBe(true);
+ expect(same.fingerprintParts).toEqual(before.fingerprintParts);
+ expect(changed.fingerprintParts).not.toEqual(before.fingerprintParts);
+ });
+
+ it('keeps names out of the texts', () => {
+ const [finding] = run([
+ readinessLot({ expiresOn: '2027-01-01', remaining: 5 }),
+ ]);
+
+ expect(JSON.stringify([finding.title, finding.detail])).not.toContain(
+ 'Mittel Eins',
+ );
+ expect(lotExpiredStockRule).toMatchObject({
+ key: 'lot-expired-stock',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ subjectPermission: 'veterinary-medicines:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.ts
new file mode 100644
index 0000000..92e5215
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-expired-stock.rule.ts
@@ -0,0 +1,31 @@
+import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
+import { lotSubject } from './medicine-rule-helpers';
+
+export const lotExpiredStockRule: ReadinessRule = {
+ key: 'lot-expired-stock',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ title: 'Eine abgelaufene Packung hat noch Bestand.',
+ legalBasis: null,
+ acknowledgeable: true,
+ subjectPermission: 'veterinary-medicines:read',
+ evaluate: ({ lots, today }): ReadinessFinding[] =>
+ lots
+ // Expired from the day after `expiresOn`; a negative rest is another item.
+ .filter(
+ (lot) =>
+ lot.expiresOn !== null && lot.expiresOn < today && lot.remaining > 0,
+ )
+ .map((lot) => ({
+ subjectId: lot.id,
+ detail:
+ 'Die Packung ist abgelaufen und hat noch Bestand. Die Entsorgung buchen.',
+ // The rest is part of it: a disposal or a count changes it.
+ fingerprintParts: {
+ lotId: lot.id,
+ expiresOn: lot.expiresOn,
+ remaining: lot.remaining,
+ },
+ subjects: [lotSubject(lot)],
+ })),
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.spec.ts
new file mode 100644
index 0000000..5448f8a
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.spec.ts
@@ -0,0 +1,59 @@
+import { lotGapsRule } from './lot-gaps.rule';
+import { readinessContext, readinessLot } from './rule.spec-helper';
+
+describe('lot-gaps rule', () => {
+ it('lists a lot without supplier or proof with the missing fields', () => {
+ const findings = lotGapsRule.evaluate(
+ readinessContext({
+ lots: [
+ readinessLot({ id: 'l1', supplier: null }),
+ readinessLot({ id: 'l2', purchaseProof: ' ' }),
+ readinessLot({ id: 'l3', supplier: null, purchaseProof: null }),
+ readinessLot({ id: 'l4' }),
+ ],
+ }),
+ );
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjectId).toBe('global');
+ expect(findings[0].subjects.map((s) => [s.id, s.missing, s.type])).toEqual([
+ ['l1', ['Lieferant'], 'LOT'],
+ ['l2', ['Beleg'], 'LOT'],
+ ['l3', ['Lieferant', 'Beleg'], 'LOT'],
+ ]);
+ });
+
+ it('is done when every lot has supplier and proof', () => {
+ expect(
+ lotGapsRule.evaluate(readinessContext({ lots: [readinessLot()] })),
+ ).toEqual([]);
+ expect(lotGapsRule.evaluate(readinessContext())).toEqual([]);
+ });
+
+ it('keeps the medicine name in the gated subject label only and the fingerprint follows the gaps', () => {
+ const first = lotGapsRule.evaluate(
+ readinessContext({ lots: [readinessLot({ supplier: null })] }),
+ )[0];
+ const second = lotGapsRule.evaluate(
+ readinessContext({
+ lots: [readinessLot({ supplier: null, purchaseProof: null })],
+ }),
+ )[0];
+
+ expect(JSON.stringify([first.title, first.detail])).not.toContain(
+ 'Mittel Eins',
+ );
+ expect(first.subjects[0].label).toContain('Mittel Eins');
+ expect(first.fingerprintParts).not.toEqual(second.fingerprintParts);
+ });
+
+ it('is a Pflicht rule of the medicine area that cannot be acknowledged', () => {
+ expect(lotGapsRule).toMatchObject({
+ key: 'lot-gaps',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PFLICHT',
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.ts
new file mode 100644
index 0000000..b480d6c
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-gaps.rule.ts
@@ -0,0 +1,46 @@
+import type { ReadinessLot } from '../loaders/lots.loader';
+import { isBlank } from './rule-helpers';
+import {
+ GLOBAL_SUBJECT_ID,
+ type ReadinessFinding,
+ type ReadinessRule,
+} from '../readiness.types';
+import { lotSubject } from './medicine-rule-helpers';
+
+function missingOf(lot: ReadinessLot): string[] {
+ return [
+ ...(isBlank(lot.supplier) ? ['Lieferant'] : []),
+ ...(isBlank(lot.purchaseProof) ? ['Beleg'] : []),
+ ];
+}
+
+export const lotGapsRule: ReadinessRule = {
+ key: 'lot-gaps',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PFLICHT',
+ title: 'Bei Packungen fehlen Lieferant oder Beleg.',
+ legalBasis: 'Art. 108 VO (EU) 2019/6',
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ evaluate: ({ lots }): ReadinessFinding[] => {
+ const gaps = lots
+ .map((lot) => ({ lot, missing: missingOf(lot) }))
+ .filter(({ missing }) => missing.length > 0);
+ if (gaps.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: GLOBAL_SUBJECT_ID,
+ detail:
+ gaps.length === 1
+ ? 'Eine Packung hat keinen Lieferanten oder keinen Beleg.'
+ : `${gaps.length} Packungen haben keinen Lieferanten oder keinen Beleg.`,
+ fingerprintParts: {
+ lots: gaps.map(({ lot, missing }) => ({ id: lot.id, missing })),
+ },
+ subjects: gaps.map(({ lot, missing }) => lotSubject(lot, missing)),
+ },
+ ];
+ },
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.spec.ts
new file mode 100644
index 0000000..49dfc8e
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.spec.ts
@@ -0,0 +1,56 @@
+import { lotNegativeRule } from './lot-negative.rule';
+import { readinessContext, readinessLot } from './rule.spec-helper';
+
+describe('lot-negative rule', () => {
+ it('lists the lots with a rest below zero', () => {
+ const findings = lotNegativeRule.evaluate(
+ readinessContext({
+ lots: [
+ readinessLot({ id: 'l1', remaining: -0.5 }),
+ readinessLot({ id: 'l2', remaining: 0 }),
+ readinessLot({ id: 'l3', remaining: 4 }),
+ readinessLot({ id: 'l4', remaining: -10 }),
+ ],
+ }),
+ );
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjectId).toBe('global');
+ expect(findings[0].subjects.map((subject) => subject.id)).toEqual([
+ 'l1',
+ 'l4',
+ ]);
+ });
+
+ it('is done when no lot is negative and a changed rest changes the fingerprint', () => {
+ expect(
+ lotNegativeRule.evaluate(
+ readinessContext({ lots: [readinessLot({ remaining: 0 })] }),
+ ),
+ ).toEqual([]);
+ const a = lotNegativeRule.evaluate(
+ readinessContext({ lots: [readinessLot({ remaining: -1 })] }),
+ )[0];
+ const b = lotNegativeRule.evaluate(
+ readinessContext({ lots: [readinessLot({ remaining: -2 })] }),
+ )[0];
+ expect(a.fingerprintParts).not.toEqual(b.fingerprintParts);
+ });
+
+ it('cannot be acknowledged and keeps names out of the texts', () => {
+ const [finding] = lotNegativeRule.evaluate(
+ readinessContext({ lots: [readinessLot({ remaining: -1 })] }),
+ );
+
+ expect(JSON.stringify([finding.title, finding.detail])).not.toContain(
+ 'Mittel Eins',
+ );
+ expect(lotNegativeRule).toMatchObject({
+ key: 'lot-negative',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.ts
new file mode 100644
index 0000000..315183c
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-negative.rule.ts
@@ -0,0 +1,38 @@
+import {
+ GLOBAL_SUBJECT_ID,
+ type ReadinessFinding,
+ type ReadinessRule,
+} from '../readiness.types';
+import { lotSubject } from './medicine-rule-helpers';
+
+export const lotNegativeRule: ReadinessRule = {
+ key: 'lot-negative',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ title: 'Eine Packung hat einen negativen Bestand.',
+ legalBasis: null,
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ evaluate: ({ lots }): ReadinessFinding[] => {
+ const negative = lots.filter((lot) => lot.remaining < 0);
+ if (negative.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: GLOBAL_SUBJECT_ID,
+ detail:
+ negative.length === 1
+ ? 'Aus einer Packung wurde mehr entnommen als erfasst. Inventur oder fehlenden Erwerb erfassen.'
+ : `Aus ${negative.length} Packungen wurde mehr entnommen als erfasst. Inventur oder fehlenden Erwerb erfassen.`,
+ fingerprintParts: {
+ lots: negative.map((lot) => ({
+ id: lot.id,
+ remaining: lot.remaining,
+ })),
+ },
+ subjects: negative.map((lot) => lotSubject(lot)),
+ },
+ ];
+ },
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.spec.ts
new file mode 100644
index 0000000..1440b73
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.spec.ts
@@ -0,0 +1,53 @@
+import { lotPrescriptionRule } from './lot-prescription.rule';
+import { readinessContext, readinessLot } from './rule.spec-helper';
+
+describe('lot-prescription rule', () => {
+ it('lists a lot of a prescription-only medicine without a vet', () => {
+ const findings = lotPrescriptionRule.evaluate(
+ readinessContext({
+ lots: [
+ readinessLot({ id: 'l1', prescriptionOnly: true }),
+ readinessLot({
+ id: 'l2',
+ prescriptionOnly: true,
+ prescribingVet: ' ',
+ }),
+ ],
+ }),
+ );
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjects.map((s) => [s.id, s.missing])).toEqual([
+ ['l1', ['Tierarzt']],
+ ['l2', ['Tierarzt']],
+ ]);
+ });
+
+ it('is done for a lot with a vet and for an over-the-counter medicine', () => {
+ expect(
+ lotPrescriptionRule.evaluate(
+ readinessContext({
+ lots: [
+ readinessLot({ prescriptionOnly: true, prescribingVet: 'Dr. Vet' }),
+ readinessLot({ id: 'l2', prescriptionOnly: false }),
+ ],
+ }),
+ ),
+ ).toEqual([]);
+ });
+
+ it('keeps names out of the texts', () => {
+ const [finding] = lotPrescriptionRule.evaluate(
+ readinessContext({ lots: [readinessLot({ prescriptionOnly: true })] }),
+ );
+
+ expect(JSON.stringify([finding.detail])).not.toContain('Mittel Eins');
+ expect(lotPrescriptionRule).toMatchObject({
+ key: 'lot-prescription',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PFLICHT',
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.ts
new file mode 100644
index 0000000..b314797
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/lot-prescription.rule.ts
@@ -0,0 +1,36 @@
+import {
+ GLOBAL_SUBJECT_ID,
+ type ReadinessFinding,
+ type ReadinessRule,
+} from '../readiness.types';
+import { lotSubject } from './medicine-rule-helpers';
+import { isBlank } from './rule-helpers';
+
+export const lotPrescriptionRule: ReadinessRule = {
+ key: 'lot-prescription',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PFLICHT',
+ title: 'Bei verschreibungspflichtigen Packungen fehlt der Tierarzt.',
+ legalBasis: 'Art. 108 VO (EU) 2019/6',
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ evaluate: ({ lots }): ReadinessFinding[] => {
+ const open = lots.filter(
+ (lot) => lot.prescriptionOnly && isBlank(lot.prescribingVet),
+ );
+ if (open.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: GLOBAL_SUBJECT_ID,
+ detail:
+ open.length === 1
+ ? 'Bei einer verschreibungspflichtigen Packung fehlt der Tierarzt.'
+ : `Bei ${open.length} verschreibungspflichtigen Packungen fehlt der Tierarzt.`,
+ fingerprintParts: { lotIds: open.map((lot) => lot.id).sort() },
+ subjects: open.map((lot) => lotSubject(lot, ['Tierarzt'])),
+ },
+ ];
+ },
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/medicine-rule-helpers.ts b/ApiCore-Backend/src/compliance/readiness/rules/medicine-rule-helpers.ts
new file mode 100644
index 0000000..08d94f9
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/medicine-rule-helpers.ts
@@ -0,0 +1,75 @@
+import { berlinDayOf } from '../../../common/dates';
+import { formatDayDe } from '../../../common/format-day';
+import type {
+ TreatmentLogGap,
+ TreatmentLogSource,
+} from '../../../export/treatment-log';
+import type { ReadinessLot } from '../loaders/lots.loader';
+import type { ReadinessContext } from '../readiness-context';
+import { firstTreatmentYear, treatmentYearOf } from '../readiness-limits';
+import type { ReadinessSubject } from '../readiness.types';
+
+/** The German name of a gap of the Bestandsbuch, as the form names it. */
+export const GAP_NAMES: Readonly> = {
+ SUPPLIER: 'Lieferant',
+ PROOF: 'Beleg',
+ AMOUNT: 'Menge',
+ WITHDRAWAL: 'Wartezeit',
+ VET: 'Tierarzt',
+};
+
+/** The order `treatmentLogGaps` reports the gaps in. */
+export const GAP_ORDER: readonly TreatmentLogGap[] = [
+ 'SUPPLIER',
+ 'PROOF',
+ 'AMOUNT',
+ 'WITHDRAWAL',
+ 'VET',
+];
+
+/** The gaps that live on the lot (supplier, proof and vet come from there). */
+export const LOT_GAPS: ReadonlySet = new Set([
+ 'SUPPLIER',
+ 'PROOF',
+ 'VET',
+]);
+
+/** The treatments of the last 6 calendar years, the book's lookback. */
+export function treatmentsInLookback(
+ ctx: Pick,
+): TreatmentLogSource[] {
+ const first = firstTreatmentYear(ctx.today);
+ return ctx.treatments.filter(
+ (treatment) => treatmentYearOf(treatment.date) >= first,
+ );
+}
+
+/**
+ * A treatment as a subject. The label names the day and the colony but
+ * never the product: medicine names need veterinary-medicines:read.
+ */
+export function treatmentSubject(
+ source: Pick,
+ missing?: string[],
+): ReadinessSubject {
+ return {
+ type: 'TREATMENT',
+ id: source.id,
+ label: `Behandlung vom ${formatDayDe(berlinDayOf(source.date), true)}, Volk ${source.colonyCode}`,
+ parentId: source.colonyId,
+ ...(missing ? { missing } : {}),
+ };
+}
+
+/** A lot as a subject; its medicine name is gated by the rule's permission. */
+export function lotSubject(
+ lot: Pick,
+ missing?: string[],
+): ReadinessSubject {
+ return {
+ type: 'LOT',
+ id: lot.id,
+ label: `${lot.medicineName}, Packung vom ${formatDayDe(lot.purchasedOn, true)}`,
+ ...(missing ? { missing } : {}),
+ };
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.spec.ts
new file mode 100644
index 0000000..4cd56d0
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.spec.ts
@@ -0,0 +1,51 @@
+import { medicineWithdrawalMissingRule } from './medicine-withdrawal-missing.rule';
+import { readinessContext, readinessMedicine } from './rule.spec-helper';
+
+describe('medicine-withdrawal-missing rule', () => {
+ it('lists active medicines without a withdrawal period, 0 days counts as given', () => {
+ const findings = medicineWithdrawalMissingRule.evaluate(
+ readinessContext({
+ medicines: [
+ readinessMedicine({ id: 'm1', withdrawalPeriodDays: null }),
+ readinessMedicine({ id: 'm2', withdrawalPeriodDays: 0 }),
+ readinessMedicine({ id: 'm3', withdrawalPeriodDays: 21 }),
+ ],
+ }),
+ );
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({
+ type: 'MEDICINE',
+ id: 'm1',
+ label: 'Mittel Eins',
+ missing: ['Wartezeit'],
+ }),
+ ]);
+ });
+
+ it('is done without a medicine or when all have a period', () => {
+ expect(medicineWithdrawalMissingRule.evaluate(readinessContext())).toEqual(
+ [],
+ );
+ });
+
+ it('keeps the name out of the texts and is a Prüfen rule that cannot be acknowledged', () => {
+ const [finding] = medicineWithdrawalMissingRule.evaluate(
+ readinessContext({
+ medicines: [readinessMedicine({ withdrawalPeriodDays: null })],
+ }),
+ );
+
+ expect(JSON.stringify([finding.title, finding.detail])).not.toContain(
+ 'Mittel Eins',
+ );
+ expect(medicineWithdrawalMissingRule).toMatchObject({
+ key: 'medicine-withdrawal-missing',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.ts
new file mode 100644
index 0000000..7a7ce66
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/medicine-withdrawal-missing.rule.ts
@@ -0,0 +1,40 @@
+import {
+ GLOBAL_SUBJECT_ID,
+ type ReadinessFinding,
+ type ReadinessRule,
+} from '../readiness.types';
+
+export const medicineWithdrawalMissingRule: ReadinessRule = {
+ key: 'medicine-withdrawal-missing',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ title: 'Bei Mitteln im Katalog fehlt die Wartezeit.',
+ legalBasis: null,
+ acknowledgeable: false,
+ subjectPermission: 'veterinary-medicines:read',
+ evaluate: ({ medicines }): ReadinessFinding[] => {
+ // 0 days is a value: only a missing period counts.
+ const open = medicines.filter(
+ (medicine) => medicine.withdrawalPeriodDays === null,
+ );
+ if (open.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: GLOBAL_SUBJECT_ID,
+ detail:
+ open.length === 1
+ ? 'Bei einem aktiven Mittel ist keine Wartezeit eingetragen.'
+ : `Bei ${open.length} aktiven Mitteln ist keine Wartezeit eingetragen.`,
+ fingerprintParts: { medicineIds: open.map((m) => m.id).sort() },
+ subjects: open.map((medicine) => ({
+ type: 'MEDICINE',
+ id: medicine.id,
+ label: medicine.name,
+ missing: ['Wartezeit'],
+ })),
+ },
+ ];
+ },
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.spec.ts
index baf2ae7..9f023b9 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/registration.rule.spec.ts
@@ -1,5 +1,5 @@
import { registrationRule } from './registration.rule';
-import { notice, readinessContext } from './rule-spec-helpers';
+import { notice, readinessContext } from './rule.spec-helper';
describe('registration rule', () => {
it('is open without any registration', () => {
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.spec.ts
index 36f1a27..0885931 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/restriction-active.rule.spec.ts
@@ -1,5 +1,5 @@
import { restrictionActiveRule } from './restriction-active.rule';
-import { readinessContext, restriction } from './rule-spec-helpers';
+import { readinessContext, restriction } from './rule.spec-helper';
describe('restriction-active rule', () => {
it('is open with one finding listing each apiary with an active restriction once', () => {
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/rule-spec-helpers.ts b/ApiCore-Backend/src/compliance/readiness/rules/rule-spec-helpers.ts
deleted file mode 100644
index 6e63b7c..0000000
--- a/ApiCore-Backend/src/compliance/readiness/rules/rule-spec-helpers.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import type {
- ApiaryRestriction,
- ComplianceSettings,
- SiteSettings,
-} from '../../../../generated/prisma';
-import type { HerdSnapshot } from '../../herd-snapshot';
-import type { HerdStatus } from '../../herd-status';
-import type { ReadinessContext, ReadinessNotice } from '../readiness-context';
-
-/** Builders for the pure rule specs (and for the rules of later tasks). */
-export function siteSettings(patch: Partial = {}): SiteSettings {
- return {
- id: 'singleton',
- youtubeUrl: null,
- instagramUrl: null,
- tiktokUrl: null,
- websiteUrl: null,
- holderName: 'Halter',
- holderAddress: 'Weg 1, 12345 Ort',
- foodBusinessName: 'Imkerei',
- foodBusinessAddress: 'Weg 2, 12345 Ort',
- originCountry: 'Deutschland',
- updatedAt: new Date(0),
- ...patch,
- };
-}
-
-export function complianceSettings(
- patch: Partial = {},
-): ComplianceSettings {
- return {
- id: 'singleton',
- veterinaryOfficeName: 'Veterinäramt',
- veterinaryOfficeAddress: null,
- registrationNumber: null,
- noticeOnColonyCountChange: true,
- noticeOnApiaryChange: true,
- tskReferenceDay: '01-01',
- tskReportDueDay: null,
- tskMemberNumber: null,
- tskExcludedColonyTypes: [],
- netQuantityDigitRules: [],
- updatedAt: new Date(0),
- ...patch,
- };
-}
-
-export function emptySnapshot(): HerdSnapshot {
- return {
- version: 1,
- takenAt: '2027-03-15T10:00:00.000Z',
- apiaries: [],
- totalLiving: 0,
- };
-}
-
-export function notice(patch: Partial = {}): ReadinessNotice {
- return {
- id: 'n1',
- kind: 'REGISTRATION',
- status: 'SENT',
- year: null,
- referenceDate: new Date('2026-01-01T00:00:00.000Z'),
- countedAt: new Date('2026-01-01T10:00:00.000Z'),
- totalColonies: 3,
- sentOn: new Date('2026-01-02T00:00:00.000Z'),
- sentVia: null,
- reference: null,
- notes: null,
- openKey: null,
- createdByUserId: null,
- createdAt: new Date(0),
- updatedAt: new Date(0),
- ...patch,
- };
-}
-
-export function restriction(
- patch: Partial & { apiary?: { id: string; name: string } },
-): ApiaryRestriction & { apiary: { id: string; name: string } } {
- const apiary = patch.apiary ?? { id: 'a1', name: 'Stand Eins' };
- return {
- id: 'r1',
- apiaryId: apiary.id,
- disease: 'Faulbrut',
- authority: null,
- reference: null,
- startsOn: new Date('2027-03-01T00:00:00.000Z'),
- endsOn: null,
- notes: null,
- createdByUserId: null,
- createdAt: new Date(0),
- updatedAt: new Date(0),
- ...patch,
- apiary,
- };
-}
-
-export function herdStatus(patch: Partial = {}): HerdStatus {
- return {
- settings: complianceSettings(),
- baseline: null,
- current: emptySnapshot(),
- deviation: null,
- ...patch,
- };
-}
-
-export function readinessContext(
- patch: Partial = {},
-): ReadinessContext {
- return {
- now: new Date('2027-03-15T10:00:00.000Z'),
- today: '2027-03-15',
- site: siteSettings(),
- settings: complianceSettings(),
- herd: herdStatus(),
- notices: [],
- restrictions: [],
- ...patch,
- };
-}
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/rule.spec-helper.ts b/ApiCore-Backend/src/compliance/readiness/rules/rule.spec-helper.ts
new file mode 100644
index 0000000..6324c28
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/rule.spec-helper.ts
@@ -0,0 +1,298 @@
+import type {
+ ApiaryRestriction,
+ ComplianceSettings,
+ SiteSettings,
+} from '../../../../generated/prisma';
+import type { LabelTextElement } from '../../../label-templates/label-element.types';
+import type {
+ ColonyMove,
+ TreatmentLogSource,
+} from '../../../export/treatment-log';
+import type { WithdrawalConflict } from '../../../withdrawal/withdrawal-check.service';
+import type { HerdSnapshot } from '../../herd-snapshot';
+import type { HerdStatus } from '../../herd-status';
+import type { ReadinessHarvest } from '../loaders/harvests.loader';
+import type { ReadinessLabelTemplate } from '../loaders/labels.loader';
+import type { ReadinessLot, ReadinessMedicine } from '../loaders/lots.loader';
+import type { ReadinessContext, ReadinessNotice } from '../readiness-context';
+
+/** Builders for the pure rule specs (and for the rules of later tasks). */
+export function siteSettings(patch: Partial = {}): SiteSettings {
+ return {
+ id: 'singleton',
+ youtubeUrl: null,
+ instagramUrl: null,
+ tiktokUrl: null,
+ websiteUrl: null,
+ holderName: 'Halter',
+ holderAddress: 'Weg 1, 12345 Ort',
+ foodBusinessName: 'Imkerei',
+ foodBusinessAddress: 'Weg 2, 12345 Ort',
+ originCountry: 'Deutschland',
+ updatedAt: new Date(0),
+ ...patch,
+ };
+}
+
+export function complianceSettings(
+ patch: Partial = {},
+): ComplianceSettings {
+ return {
+ id: 'singleton',
+ veterinaryOfficeName: 'Veterinäramt',
+ veterinaryOfficeAddress: null,
+ registrationNumber: null,
+ noticeOnColonyCountChange: true,
+ noticeOnApiaryChange: true,
+ tskReferenceDay: '01-01',
+ tskReportDueDay: null,
+ tskMemberNumber: null,
+ tskExcludedColonyTypes: [],
+ netQuantityDigitRules: [],
+ updatedAt: new Date(0),
+ ...patch,
+ };
+}
+
+export function emptySnapshot(): HerdSnapshot {
+ return {
+ version: 1,
+ takenAt: '2027-03-15T10:00:00.000Z',
+ apiaries: [],
+ totalLiving: 0,
+ };
+}
+
+export function notice(patch: Partial = {}): ReadinessNotice {
+ return {
+ id: 'n1',
+ kind: 'REGISTRATION',
+ status: 'SENT',
+ year: null,
+ referenceDate: new Date('2026-01-01T00:00:00.000Z'),
+ countedAt: new Date('2026-01-01T10:00:00.000Z'),
+ totalColonies: 3,
+ sentOn: new Date('2026-01-02T00:00:00.000Z'),
+ sentVia: null,
+ reference: null,
+ notes: null,
+ openKey: null,
+ createdByUserId: null,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ ...patch,
+ };
+}
+
+export function restriction(
+ patch: Partial & { apiary?: { id: string; name: string } },
+): ApiaryRestriction & { apiary: { id: string; name: string } } {
+ const apiary = patch.apiary ?? { id: 'a1', name: 'Stand Eins' };
+ return {
+ id: 'r1',
+ apiaryId: apiary.id,
+ disease: 'Faulbrut',
+ authority: null,
+ reference: null,
+ startsOn: new Date('2027-03-01T00:00:00.000Z'),
+ endsOn: null,
+ notes: null,
+ createdByUserId: null,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ ...patch,
+ apiary,
+ };
+}
+
+export function herdStatus(patch: Partial = {}): HerdStatus {
+ return {
+ settings: complianceSettings(),
+ baseline: null,
+ current: emptySnapshot(),
+ deviation: null,
+ ...patch,
+ };
+}
+
+export function readinessContext(
+ patch: Partial = {},
+): ReadinessContext {
+ return {
+ now: new Date('2027-03-15T10:00:00.000Z'),
+ today: '2027-03-15',
+ site: siteSettings(),
+ settings: complianceSettings(),
+ herd: herdStatus(),
+ notices: [],
+ restrictions: [],
+ treatments: [],
+ moves: [],
+ lots: [],
+ medicines: [],
+ templates: [],
+ harvests: [],
+ ...patch,
+ };
+}
+
+/** A complete treatment without a pack: nothing missing, apiary known. */
+export function treatmentSource(
+ patch: Partial = {},
+): TreatmentLogSource {
+ return {
+ id: 't1',
+ colonyId: 'c1',
+ colonyCode: 'V-1',
+ colonyApiaryId: 'a1',
+ apiaryId: 'a1',
+ date: new Date('2027-02-10T00:00:00.000Z'),
+ startDate: null,
+ endDate: null,
+ product: 'Mittel Eins',
+ amount: '30 ml',
+ batchNumber: 'CH-1',
+ supplier: 'Lieferant',
+ purchaseProof: 'Beleg 1',
+ prescribingVet: null,
+ withdrawalPeriodDays: 0,
+ administeredBy: 'Imker',
+ medicineId: null,
+ prescriptionOnly: false,
+ doseQuantity: null,
+ medicineUnit: null,
+ lot: null,
+ ...patch,
+ };
+}
+
+export function treatmentLotRef(
+ patch: Partial> = {},
+): NonNullable {
+ return {
+ id: 'l1',
+ batchNumber: 'CH-1',
+ supplier: 'Lieferant',
+ purchaseProof: 'Beleg 1',
+ prescribingVet: null,
+ ...patch,
+ };
+}
+
+export function colonyMove(patch: Partial = {}): ColonyMove {
+ return {
+ colonyId: 'c1',
+ apiaryId: 'a2',
+ movedAt: new Date('2027-03-01T00:00:00.000Z'),
+ ...patch,
+ };
+}
+
+/** A complete pack with a rest. */
+export function readinessLot(patch: Partial = {}): ReadinessLot {
+ return {
+ id: 'l1',
+ medicineId: 'm1',
+ medicineName: 'Mittel Eins',
+ prescriptionOnly: false,
+ purchasedOn: '2027-01-05',
+ expiresOn: '2028-01-05',
+ supplier: 'Lieferant',
+ purchaseProof: 'Beleg 1',
+ prescribingVet: null,
+ remaining: 10,
+ ...patch,
+ };
+}
+
+export function readinessMedicine(
+ patch: Partial = {},
+): ReadinessMedicine {
+ return { id: 'm1', name: 'Mittel Eins', withdrawalPeriodDays: 0, ...patch };
+}
+
+export function textElement(
+ patch: Partial = {},
+): LabelTextElement {
+ return {
+ id: 'e1',
+ type: 'text',
+ x: 2,
+ y: 2,
+ width: 150,
+ height: 10,
+ text: '',
+ placeholderKey: null,
+ fontSize: 12,
+ fontFamily: 'sans',
+ bold: false,
+ italic: false,
+ color: '#000000',
+ align: 'left',
+ ...patch,
+ };
+}
+
+export const SPEC_DIGIT_RULES = [
+ { maxGrams: 50, minMm: 2 },
+ { maxGrams: 200, minMm: 3 },
+ { maxGrams: 1000, minMm: 4 },
+ { maxGrams: null, minMm: 6 },
+];
+
+/** A template that carries every mandatory particular in large type. */
+export function completeTemplate(
+ patch: Partial = {},
+): ReadinessLabelTemplate {
+ const line = (
+ id: string,
+ y: number,
+ key: LabelTextElement['placeholderKey'],
+ ) => textElement({ id, y, placeholderKey: key, fontSize: 18, height: 10 });
+ return {
+ id: 'tpl1',
+ name: 'Vorlage Eins',
+ minXHeightMm: 1.2,
+ elements: [
+ line('e1', 0, 'legalName'),
+ line('e2', 12, 'jarSize'),
+ line('e3', 24, 'bestBeforeLine'),
+ line('e4', 36, 'lotLine'),
+ line('e5', 48, 'originCountry'),
+ line('e6', 60, 'foodBusinessName'),
+ line('e7', 72, 'foodBusinessAddress'),
+ ],
+ target: {
+ sample: false,
+ bottling: {
+ jarSize: 500,
+ lotNumber: '2027-0001',
+ date: new Date('2027-01-10T00:00:00.000Z'),
+ bestBeforeDate: new Date('2029-01-10T00:00:00.000Z'),
+ honeyBatch: { batchNumber: 'HB-1', honeyType: 'Sommerhonig' },
+ product: { name: 'Sommerhonig', legalName: 'Honig' },
+ },
+ },
+ ...patch,
+ };
+}
+
+export function readinessHarvest(
+ patch: Partial = {},
+): ReadinessHarvest {
+ return { id: 'h1', day: '2027-03-01', conflicts: [], ...patch };
+}
+
+export function conflict(
+ patch: Partial = {},
+): WithdrawalConflict {
+ return {
+ harvestId: 'h1',
+ harvestedOn: '2027-03-01',
+ colonyId: 'c1',
+ treatmentId: 't1',
+ product: 'Mittel Eins',
+ untilDay: '2027-03-10',
+ ...patch,
+ };
+}
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.spec.ts
new file mode 100644
index 0000000..1d407c5
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.spec.ts
@@ -0,0 +1,104 @@
+import { treatmentExpiredLotRule } from './treatment-expired-lot.rule';
+import {
+ readinessContext,
+ readinessLot,
+ treatmentLotRef,
+ treatmentSource,
+} from './rule.spec-helper';
+
+function run(treatments: ReturnType[]) {
+ return treatmentExpiredLotRule.evaluate(
+ readinessContext({
+ lots: [readinessLot({ id: 'l1', expiresOn: '2027-02-10' })],
+ treatments,
+ }),
+ );
+}
+
+describe('treatment-expired-lot rule', () => {
+ it('opens one item per treatment dated after the expiry of its lot', () => {
+ const findings = run([
+ treatmentSource({
+ id: 't1',
+ date: new Date('2027-02-11T00:00:00.000Z'),
+ lot: treatmentLotRef({ id: 'l1' }),
+ }),
+ treatmentSource({
+ id: 't2',
+ date: new Date('2027-03-01T00:00:00.000Z'),
+ colonyId: 'c2',
+ lot: treatmentLotRef({ id: 'l1' }),
+ }),
+ ]);
+
+ expect(findings.map((finding) => finding.subjectId)).toEqual(['t1', 't2']);
+ expect(findings[1].subjects).toEqual([
+ expect.objectContaining({ type: 'TREATMENT', id: 't2', parentId: 'c2' }),
+ ]);
+ });
+
+ it('is done on the day of expiry, before it, without a lot and for a lot without expiry', () => {
+ expect(
+ run([
+ treatmentSource({
+ id: 'a',
+ date: new Date('2027-02-10T00:00:00.000Z'),
+ lot: treatmentLotRef({ id: 'l1' }),
+ }),
+ treatmentSource({
+ id: 'b',
+ date: new Date('2027-01-01T00:00:00.000Z'),
+ lot: treatmentLotRef({ id: 'l1' }),
+ }),
+ treatmentSource({
+ id: 'c',
+ date: new Date('2027-03-01T00:00:00.000Z'),
+ }),
+ treatmentSource({
+ id: 'd',
+ date: new Date('2027-03-01T00:00:00.000Z'),
+ lot: treatmentLotRef({ id: 'unknown' }),
+ }),
+ ]),
+ ).toEqual([]);
+ });
+
+ it('is acknowledgeable per treatment and keeps names out of the texts', () => {
+ const [finding] = run([
+ treatmentSource({
+ id: 't1',
+ date: new Date('2027-03-01T00:00:00.000Z'),
+ lot: treatmentLotRef({ id: 'l1' }),
+ }),
+ ]);
+
+ expect(
+ JSON.stringify([finding.title, finding.detail, finding.subjects]),
+ ).not.toContain('Mittel Eins');
+ expect(treatmentExpiredLotRule).toMatchObject({
+ key: 'treatment-expired-lot',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ acknowledgeable: true,
+ subjectPermission: 'veterinary-medicines:read',
+ });
+ });
+
+ it('only looks at the last 6 calendar years', () => {
+ const findings = treatmentExpiredLotRule.evaluate(
+ readinessContext({
+ today: '2027-03-15',
+ lots: [readinessLot({ id: 'l1', expiresOn: '2020-01-01' })],
+ treatments: [
+ treatmentSource({
+ id: 'old',
+ date: new Date('2021-05-01T00:00:00.000Z'),
+ lot: treatmentLotRef({ id: 'l1' }),
+ }),
+ ],
+ }),
+ );
+
+ expect(findings).toEqual([]);
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.ts
new file mode 100644
index 0000000..8060272
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/treatment-expired-lot.rule.ts
@@ -0,0 +1,39 @@
+import { berlinDayOf } from '../../../common/dates';
+import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
+import {
+ treatmentSubject,
+ treatmentsInLookback,
+} from './medicine-rule-helpers';
+
+export const treatmentExpiredLotRule: ReadinessRule = {
+ key: 'treatment-expired-lot',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ title: 'Eine Behandlung liegt nach dem Verfall ihrer Packung.',
+ legalBasis: null,
+ acknowledgeable: true,
+ subjectPermission: 'veterinary-medicines:read',
+ evaluate: (ctx): ReadinessFinding[] => {
+ const expiry = new Map(ctx.lots.map((lot) => [lot.id, lot.expiresOn]));
+ return treatmentsInLookback(ctx).flatMap((source): ReadinessFinding[] => {
+ const expiresOn = source.lot ? (expiry.get(source.lot.id) ?? null) : null;
+ if (expiresOn === null || berlinDayOf(source.date) <= expiresOn) {
+ return [];
+ }
+ return [
+ {
+ subjectId: source.id,
+ detail:
+ 'Die Behandlung liegt nach dem Verfall der Packung. Bitte prüfen.',
+ fingerprintParts: {
+ treatmentId: source.id,
+ lotId: source.lot?.id,
+ expiresOn,
+ day: berlinDayOf(source.date),
+ },
+ subjects: [treatmentSubject(source)],
+ },
+ ];
+ });
+ },
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.spec.ts
new file mode 100644
index 0000000..5ddab3e
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.spec.ts
@@ -0,0 +1,84 @@
+import { treatmentLocationUncertainRule } from './treatment-location-uncertain.rule';
+import {
+ colonyMove,
+ readinessContext,
+ treatmentSource,
+} from './rule.spec-helper';
+
+type Source = ReturnType;
+
+describe('treatment-location-uncertain rule', () => {
+ // No apiary stored with the treatment, and the colony moved later.
+ const uncertain = (patch: Partial = {}) =>
+ treatmentSource({ id: 't1', apiaryId: null, colonyId: 'c1', ...patch });
+ const laterMove = colonyMove({
+ movedAt: new Date('2027-03-01T00:00:00.000Z'),
+ });
+
+ it('flags a treatment whose apiary on the day is not known (the * row)', () => {
+ const findings = treatmentLocationUncertainRule.evaluate(
+ readinessContext({ treatments: [uncertain()], moves: [laterMove] }),
+ );
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjectId).toBe('global');
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({ type: 'TREATMENT', id: 't1', parentId: 'c1' }),
+ ]);
+ });
+
+ it('is done when the apiary is stored, known from a move, or the colony never moved', () => {
+ const run = (
+ treatments: Source[],
+ moves: ReturnType[] = [],
+ ) =>
+ treatmentLocationUncertainRule.evaluate(
+ readinessContext({ treatments, moves }),
+ );
+
+ expect(run([treatmentSource({ apiaryId: 'a1' })], [laterMove])).toEqual([]);
+ expect(run([uncertain()])).toEqual([]);
+ expect(
+ run(
+ [uncertain()],
+ [colonyMove({ movedAt: new Date('2027-01-01T00:00:00.000Z') })],
+ ),
+ ).toEqual([]);
+ });
+
+ it('only looks at the last 6 calendar years', () => {
+ const findings = treatmentLocationUncertainRule.evaluate(
+ readinessContext({
+ today: '2027-03-15',
+ treatments: [uncertain({ date: new Date('2021-05-01T00:00:00.000Z') })],
+ moves: [laterMove],
+ }),
+ );
+
+ expect(findings).toEqual([]);
+ });
+
+ it('keeps the product out of the texts and follows the set of treatments in the fingerprint', () => {
+ const one = treatmentLocationUncertainRule.evaluate(
+ readinessContext({ treatments: [uncertain()], moves: [laterMove] }),
+ )[0];
+ const two = treatmentLocationUncertainRule.evaluate(
+ readinessContext({
+ treatments: [uncertain(), uncertain({ id: 't2' })],
+ moves: [laterMove],
+ }),
+ )[0];
+
+ expect(JSON.stringify([one.title, one.detail, one.subjects])).not.toContain(
+ 'Mittel Eins',
+ );
+ expect(one.fingerprintParts).not.toEqual(two.fingerprintParts);
+ expect(treatmentLocationUncertainRule).toMatchObject({
+ key: 'treatment-location-uncertain',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ acknowledgeable: false,
+ subjectPermission: 'treatments:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.ts
new file mode 100644
index 0000000..59211e1
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/treatment-location-uncertain.rule.ts
@@ -0,0 +1,40 @@
+import { apiaryOnDate } from '../../../export/treatment-log';
+import {
+ GLOBAL_SUBJECT_ID,
+ type ReadinessFinding,
+ type ReadinessRule,
+} from '../readiness.types';
+import {
+ treatmentSubject,
+ treatmentsInLookback,
+} from './medicine-rule-helpers';
+
+export const treatmentLocationUncertainRule: ReadinessRule = {
+ key: 'treatment-location-uncertain',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PRUEFEN',
+ title: 'Bei Behandlungen ist der Stand am Behandlungstag unsicher.',
+ legalBasis: 'Art. 108 VO (EU) 2019/6 (Identifizierung)',
+ acknowledgeable: false,
+ subjectPermission: 'treatments:read',
+ evaluate: (ctx): ReadinessFinding[] => {
+ // The rows the PDF marks with *: `apiaryOnDate` decides, as it does there.
+ const uncertain = treatmentsInLookback(ctx).filter(
+ (source) => apiaryOnDate(source, ctx.moves).uncertain,
+ );
+ if (uncertain.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: GLOBAL_SUBJECT_ID,
+ detail:
+ uncertain.length === 1
+ ? 'Bei einer Behandlung ist der Stand nicht sicher. Den Stand bestätigen.'
+ : `Bei ${uncertain.length} Behandlungen ist der Stand nicht sicher. Den Stand bestätigen.`,
+ fingerprintParts: { treatmentIds: uncertain.map((s) => s.id).sort() },
+ subjects: uncertain.map((source) => treatmentSubject(source)),
+ },
+ ];
+ },
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.spec.ts
new file mode 100644
index 0000000..dfc3408
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.spec.ts
@@ -0,0 +1,227 @@
+import {
+ groupTreatmentLog,
+ treatmentLogGaps,
+} from '../../../export/treatment-log';
+import { treatmentLogGapsRule } from './treatment-log-gaps.rule';
+import {
+ readinessContext,
+ treatmentLotRef,
+ treatmentSource,
+} from './rule.spec-helper';
+
+const GERMAN = {
+ SUPPLIER: 'Lieferant',
+ PROOF: 'Beleg',
+ AMOUNT: 'Menge',
+ WITHDRAWAL: 'Wartezeit',
+ VET: 'Tierarzt',
+} as const;
+
+describe('treatment-log-gaps rule', () => {
+ it('opens one item per year with gap rows', () => {
+ const findings = treatmentLogGapsRule.evaluate(
+ readinessContext({
+ treatments: [
+ treatmentSource({ id: 't1', supplier: null }),
+ treatmentSource({
+ id: 't2',
+ date: new Date('2026-06-01T00:00:00.000Z'),
+ purchaseProof: null,
+ }),
+ treatmentSource({ id: 't3' }),
+ ],
+ }),
+ );
+
+ expect(findings.map((finding) => finding.subjectId)).toEqual([
+ '2027',
+ '2026',
+ ]);
+ expect(findings[0].subjects.map((subject) => subject.id)).toEqual(['t1']);
+ expect(findings[1].subjects.map((subject) => subject.id)).toEqual(['t2']);
+ });
+
+ it('is done when every row of the Bestandsbuch is complete', () => {
+ expect(
+ treatmentLogGapsRule.evaluate(
+ readinessContext({ treatments: [treatmentSource()] }),
+ ),
+ ).toEqual([]);
+ });
+
+ it('reports a lot with missing supplier once as a lot subject, not once per treatment', () => {
+ const lot = treatmentLotRef({ id: 'l1', supplier: null });
+ const findings = treatmentLogGapsRule.evaluate(
+ readinessContext({
+ treatments: [
+ treatmentSource({ id: 't1', lot }),
+ treatmentSource({ id: 't2', colonyId: 'c2', colonyCode: 'V-2', lot }),
+ treatmentSource({ id: 't3', lot }),
+ ],
+ }),
+ );
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({
+ type: 'LOT',
+ id: 'l1',
+ missing: ['Lieferant'],
+ }),
+ ]);
+ });
+
+ it('reports treatments without a lot as treatment subjects with the missing fields and the colony as parentId', () => {
+ const findings = treatmentLogGapsRule.evaluate(
+ readinessContext({
+ treatments: [
+ treatmentSource({
+ id: 't1',
+ colonyId: 'c9',
+ supplier: null,
+ amount: null,
+ withdrawalPeriodDays: null,
+ }),
+ ],
+ }),
+ );
+
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({
+ type: 'TREATMENT',
+ id: 't1',
+ parentId: 'c9',
+ missing: ['Lieferant', 'Menge', 'Wartezeit'],
+ }),
+ ]);
+ });
+
+ it('keeps the fields of the treatment itself on a treatment subject even when the lot causes other gaps', () => {
+ const findings = treatmentLogGapsRule.evaluate(
+ readinessContext({
+ treatments: [
+ treatmentSource({
+ id: 't1',
+ amount: null,
+ lot: treatmentLotRef({ id: 'l1', purchaseProof: null }),
+ }),
+ ],
+ }),
+ );
+
+ expect(findings[0].subjects).toEqual([
+ expect.objectContaining({
+ type: 'TREATMENT',
+ id: 't1',
+ missing: ['Menge'],
+ }),
+ expect.objectContaining({ type: 'LOT', id: 'l1', missing: ['Beleg'] }),
+ ]);
+ });
+
+ it('the gap codes equal the codes treatmentLogGaps reports for the same row', () => {
+ const source = treatmentSource({
+ id: 't1',
+ supplier: null,
+ purchaseProof: null,
+ amount: null,
+ withdrawalPeriodDays: null,
+ prescriptionOnly: true,
+ prescribingVet: null,
+ });
+ const [sheet] = groupTreatmentLog([source], []);
+ const expected = treatmentLogGaps(sheet.rows[0]).map(
+ (code) => GERMAN[code],
+ );
+
+ const [finding] = treatmentLogGapsRule.evaluate(
+ readinessContext({ treatments: [source] }),
+ );
+
+ expect(finding.subjects[0].missing).toEqual(expected);
+ expect(expected).toHaveLength(5);
+ });
+
+ it('counts the rows of the book with a gap in the text, as the PDF groups them', () => {
+ const [finding] = treatmentLogGapsRule.evaluate(
+ readinessContext({
+ treatments: [
+ treatmentSource({ id: 't1', supplier: null }),
+ treatmentSource({
+ id: 't2',
+ colonyId: 'c2',
+ colonyCode: 'V-2',
+ supplier: null,
+ }),
+ ],
+ }),
+ );
+
+ expect(finding.subjects).toHaveLength(2);
+ expect(finding.detail).toContain('1 Zeile');
+ });
+
+ it('requires the vet only for prescription-only medicines', () => {
+ const run = (prescriptionOnly: boolean) =>
+ treatmentLogGapsRule.evaluate(
+ readinessContext({
+ treatments: [treatmentSource({ prescriptionOnly })],
+ }),
+ );
+
+ expect(run(false)).toEqual([]);
+ expect(run(true)[0].subjects[0].missing).toEqual(['Tierarzt']);
+ });
+
+ it('looks back over 6 calendar years and not further', () => {
+ const findings = treatmentLogGapsRule.evaluate(
+ readinessContext({
+ today: '2027-03-15',
+ treatments: [
+ treatmentSource({
+ id: 'old',
+ date: new Date('2021-12-31T00:00:00.000Z'),
+ supplier: null,
+ }),
+ treatmentSource({
+ id: 'edge',
+ date: new Date('2022-01-01T00:00:00.000Z'),
+ supplier: null,
+ }),
+ ],
+ }),
+ );
+
+ expect(findings.map((finding) => finding.subjectId)).toEqual(['2022']);
+ });
+
+ it('keeps product names out of titles, details and labels, the fingerprint follows the gaps', () => {
+ const build = (patch: Parameters[0]) =>
+ treatmentLogGapsRule.evaluate(
+ readinessContext({
+ treatments: [treatmentSource({ id: 't1', ...patch })],
+ }),
+ );
+ const [open] = build({ supplier: null });
+
+ expect(
+ JSON.stringify([open.title, open.detail, open.subjects]),
+ ).not.toContain('Mittel Eins');
+ expect(build({ supplier: null })[0].fingerprintParts).toEqual(
+ open.fingerprintParts,
+ );
+ expect(
+ build({ supplier: null, amount: null })[0].fingerprintParts,
+ ).not.toEqual(open.fingerprintParts);
+ });
+
+ it('is a Pflicht rule of the medicine area that cannot be acknowledged', () => {
+ expect(treatmentLogGapsRule).toMatchObject({
+ key: 'treatment-log-gaps',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PFLICHT',
+ acknowledgeable: false,
+ subjectPermission: 'treatments:read',
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.ts b/ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.ts
new file mode 100644
index 0000000..39ba51f
--- /dev/null
+++ b/ApiCore-Backend/src/compliance/readiness/rules/treatment-log-gaps.rule.ts
@@ -0,0 +1,130 @@
+import {
+ groupTreatmentLog,
+ resolveSourceValues,
+ treatmentLogGaps,
+ type TreatmentLogGap,
+ type TreatmentLogSource,
+} from '../../../export/treatment-log';
+import { treatmentYearOf } from '../readiness-limits';
+import type {
+ ReadinessFinding,
+ ReadinessRule,
+ ReadinessSubject,
+} from '../readiness.types';
+import {
+ GAP_NAMES,
+ GAP_ORDER,
+ LOT_GAPS,
+ treatmentSubject,
+ treatmentsInLookback,
+} from './medicine-rule-helpers';
+
+/**
+ * The gaps of one treatment, from the very functions that print the PDF:
+ * the columns come from `resolveSourceValues`, the list from
+ * `treatmentLogGaps`. Grouped rows share these values, so a row has exactly
+ * the gaps of each of its treatments.
+ */
+function gapsOf(source: TreatmentLogSource): TreatmentLogGap[] {
+ const values = resolveSourceValues(source);
+ return treatmentLogGaps({
+ supplier: values.supplier,
+ purchaseProof: values.purchaseProof,
+ amount: values.amount,
+ prescribingVet: values.prescribingVet,
+ withdrawalPeriodDays: source.withdrawalPeriodDays,
+ prescriptionOnly: source.prescriptionOnly,
+ });
+}
+
+interface YearSubjects {
+ subjects: ReadinessSubject[];
+ parts: { type: string; id: string; missing: string[] }[];
+}
+
+/** A lot with a gap appears once, however many treatments came from it. */
+function subjectsOfYear(sources: readonly TreatmentLogSource[]): YearSubjects {
+ const treatments: ReadinessSubject[] = [];
+ const lots = new Map>();
+ for (const source of sources) {
+ const gaps = gapsOf(source);
+ const own = source.lot ? gaps.filter((gap) => !LOT_GAPS.has(gap)) : gaps;
+ if (own.length > 0) {
+ treatments.push(
+ treatmentSubject(
+ source,
+ own.map((gap) => GAP_NAMES[gap]),
+ ),
+ );
+ }
+ if (source.lot) {
+ const known = lots.get(source.lot.id) ?? new Set();
+ gaps.filter((gap) => LOT_GAPS.has(gap)).forEach((gap) => known.add(gap));
+ lots.set(source.lot.id, known);
+ }
+ }
+ const lotSubjects = [...lots]
+ .filter(([, gaps]) => gaps.size > 0)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([id, gaps]): ReadinessSubject => ({
+ type: 'LOT',
+ id,
+ // Not the medicine: this item is seen with treatments:read only.
+ label: 'Packung der Behandlungen',
+ missing: GAP_ORDER.filter((gap) => gaps.has(gap)).map(
+ (gap) => GAP_NAMES[gap],
+ ),
+ }));
+ const subjects = [...treatments, ...lotSubjects];
+ return {
+ subjects,
+ parts: subjects.map(({ type, id, missing }) => ({
+ type,
+ id,
+ missing: missing ?? [],
+ })),
+ };
+}
+
+function rowCountText(rows: number, year: number): string {
+ return `Im Bestandsbuch ${year} fehlen Angaben in ${rows} ${rows === 1 ? 'Zeile' : 'Zeilen'}.`;
+}
+
+export const treatmentLogGapsRule: ReadinessRule = {
+ key: 'treatment-log-gaps',
+ area: 'TIERARZNEIMITTEL',
+ severity: 'PFLICHT',
+ title: 'Im Bestandsbuch fehlen Angaben.',
+ legalBasis: 'Art. 108 VO (EU) 2019/6',
+ acknowledgeable: false,
+ subjectPermission: 'treatments:read',
+ evaluate: (ctx): ReadinessFinding[] => {
+ const byYear = new Map();
+ for (const source of treatmentsInLookback(ctx)) {
+ const year = treatmentYearOf(source.date);
+ byYear.set(year, [...(byYear.get(year) ?? []), source]);
+ }
+ return [...byYear]
+ .sort(([a], [b]) => b - a)
+ .flatMap(([year, sources]): ReadinessFinding[] => {
+ // The rows the PDF prints for this year: the count comes from the
+ // same grouping.
+ const rowsWithGap = groupTreatmentLog(sources, ctx.moves)
+ .flatMap((sheet) => sheet.rows)
+ .filter((row) => treatmentLogGaps(row).length > 0).length;
+ const { subjects, parts } = subjectsOfYear(sources);
+ if (rowsWithGap === 0 || subjects.length === 0) {
+ return [];
+ }
+ return [
+ {
+ subjectId: String(year),
+ title: `Bestandsbuch ${year}: Angaben fehlen`,
+ detail: rowCountText(rowsWithGap, year),
+ fingerprintParts: { year, subjects: parts },
+ subjects,
+ },
+ ];
+ });
+ },
+};
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.spec.ts
index f7c7c85..e4e66f1 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/tsk-report.rule.spec.ts
@@ -2,7 +2,7 @@ import {
complianceSettings,
notice,
readinessContext,
-} from './rule-spec-helpers';
+} from './rule.spec-helper';
import { tskReportRule } from './tsk-report.rule';
function at(today: string, patch: Parameters[0] = {}) {
diff --git a/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.spec.ts b/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.spec.ts
index e867192..bdcf36b 100644
--- a/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.spec.ts
+++ b/ApiCore-Backend/src/compliance/readiness/rules/vet-office.rule.spec.ts
@@ -1,5 +1,5 @@
import { vetOfficeRule } from './vet-office.rule';
-import { complianceSettings, readinessContext } from './rule-spec-helpers';
+import { complianceSettings, readinessContext } from './rule.spec-helper';
describe('vet-office rule', () => {
it('is open without an office name', () => {
diff --git a/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts b/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts
index 4874c2f..dc51181 100644
--- a/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts
+++ b/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts
@@ -5,7 +5,11 @@ import { Test } from '@nestjs/testing';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
-import { berlinToday, dayToDate } from '../src/common/dates';
+import { addDays, berlinToday, dayToDate } from '../src/common/dates';
+import {
+ buildTreatmentLog,
+ treatmentLogGaps,
+} from '../src/export/treatment-log';
import type {
ReadinessItem,
ReadinessReport,
@@ -20,6 +24,7 @@ import { configureApp } from '../src/app.config';
import { THROTTLER_NAMESPACE } from '../src/common/throttler/prisma-throttler.storage';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
+import { pdfText } from './utils/pdf-text';
const READINESS = '/api/v1/compliance/readiness';
const ACKNOWLEDGEMENTS = '/api/v1/compliance/acknowledgements';
@@ -428,6 +433,10 @@ describe('Compliance readiness (e2e)', () => {
});
expect(stale.status).toBe(409);
+ expect(stale.body).toMatchObject({
+ code: 'READINESS_FINGERPRINT_STALE',
+ message: 'Der Punkt hat sich geändert. Bitte neu laden.',
+ });
expect(created.status).toBe(201);
const createdItem = created.body as ReadinessItem;
expect(createdItem.state).toBe('ACKNOWLEDGED');
@@ -458,7 +467,12 @@ describe('Compliance readiness (e2e)', () => {
.set(as('beekeeper'));
expect((await revoke()).status).toBe(201);
expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN');
- expect((await revoke()).status).toBe(409);
+ const again = await revoke();
+ expect(again.status).toBe(409);
+ expect(again.body).toMatchObject({
+ code: 'READINESS_ACK_REVOKED',
+ message: 'Die Quittung ist schon zurückgenommen.',
+ });
const row = await prisma.complianceAcknowledgement.findUniqueOrThrow({
where: { id: acknowledgementId },
});
@@ -466,6 +480,21 @@ describe('Compliance readiness (e2e)', () => {
expect(row.revokedByUserId).not.toBeNull();
});
+ it('acknowledging a subject that is not open answers 409 READINESS_ITEM_NOT_OPEN', async () => {
+ const response = await acknowledge({
+ itemKey: SOFT_KEY,
+ subjectId: 'no-such-subject',
+ fingerprint: 'a'.repeat(64),
+ note: 'Geprüft und in Ordnung.',
+ });
+
+ expect(response.status).toBe(409);
+ expect(response.body).toMatchObject({
+ code: 'READINESS_ITEM_NOT_OPEN',
+ message: 'Der Punkt hat sich geändert. Bitte neu laden.',
+ });
+ });
+
it('revoking an unknown acknowledgement is a 404', async () => {
const response = await request(server())
.post(`${ACKNOWLEDGEMENTS}/does-not-exist/revoke`)
@@ -474,4 +503,457 @@ describe('Compliance readiness (e2e)', () => {
expect(response.status).toBe(404);
});
});
+
+ describe('medicine, label and harvest areas', () => {
+ const area = Date.now();
+ const today = berlinToday();
+ const created = {
+ treatments: [] as string[],
+ lots: [] as string[],
+ harvests: [] as string[],
+ };
+ let colonyId: string;
+ let medicineId: string;
+ let lotOk: string;
+ let lotSmall: string;
+ let lotOld: string;
+ let treatmentOk: string;
+ let treatmentSmall: string;
+ let treatmentOld: string;
+ let harvestInside: string;
+ let harvestAfter: string;
+ let productId: string;
+ let templateId: string;
+ let userId: string;
+ let extractionId: string;
+ let honeyBatchId: string;
+ let bottlingId: string;
+ let medicineName: string;
+
+ const textElement = (id: string, y: number, key: string, size = 9) => ({
+ id,
+ type: 'text',
+ x: 2,
+ y,
+ width: 80,
+ height: 8,
+ text: '',
+ placeholderKey: key,
+ fontSize: size,
+ fontFamily: 'sans',
+ bold: false,
+ italic: false,
+ color: '#000000',
+ align: 'left',
+ });
+
+ const itemsOf = (body: ReadinessReport, key: string) =>
+ body.items.filter((item) => item.key === key);
+ const openSubjectIds = (body: ReadinessReport, key: string) =>
+ itemsOf(body, key)
+ .filter((item) => item.state === 'OPEN')
+ .flatMap((item) => [
+ item.subjectId,
+ ...item.subjects.map((subject) => subject.id),
+ ]);
+
+ async function postTreatment(body: Record) {
+ const response = await request(server())
+ .post('/api/v1/treatments')
+ .set(as('beekeeper'))
+ .send({ colonyId, date: today, ...body });
+ expect(response.status).toBe(201);
+ const id = (response.body as { id: string }).id;
+ created.treatments.push(id);
+ return id;
+ }
+
+ async function makeLot(patch: Record) {
+ const lot = await prisma.veterinaryMedicineLot.create({
+ data: {
+ medicineId,
+ purchasedOn: dayToDate('2026-01-10'),
+ quantity: 100,
+ supplier: 'Apotheke E2E',
+ purchaseProof: 'Beleg E2E',
+ expiresOn: dayToDate('2099-01-01'),
+ ...patch,
+ },
+ });
+ created.lots.push(lot.id);
+ return lot.id;
+ }
+
+ beforeAll(async () => {
+ const owner = await prisma.user.findFirstOrThrow({
+ where: { email: `readiness-e2e-beekeeper-${suffix}@example.com` },
+ });
+ userId = owner.id;
+ colonyId = (
+ await prisma.colony.create({
+ data: {
+ code: `RA-${area}`,
+ apiaryId,
+ colonyType: 'PRODUCTION_COLONY',
+ },
+ })
+ ).id;
+ medicineName = `Readiness-Mittel ${area}`;
+ medicineId = (
+ await prisma.veterinaryMedicine.create({
+ data: { name: medicineName, unit: 'ML', withdrawalPeriodDays: 0 },
+ })
+ ).id;
+ lotOk = await makeLot({});
+ lotSmall = await makeLot({ quantity: 10 });
+ lotOld = await makeLot({
+ quantity: 5,
+ expiresOn: dayToDate('2020-01-01'),
+ });
+ treatmentOk = await postTreatment({
+ medicineLotId: lotOk,
+ doseQuantity: 30,
+ withdrawalPeriodDays: 0,
+ });
+ treatmentSmall = await postTreatment({
+ medicineLotId: lotSmall,
+ doseQuantity: 30,
+ withdrawalPeriodDays: 0,
+ });
+ treatmentOld = await postTreatment({
+ medicineLotId: lotOld,
+ doseQuantity: 1,
+ withdrawalPeriodDays: 0,
+ });
+ await postTreatment({
+ product: `Wartezeit-Mittel ${area}`,
+ amount: '10 ml',
+ supplier: 'Apotheke E2E',
+ purchaseProof: 'Beleg E2E',
+ withdrawalPeriodDays: 10,
+ });
+ harvestInside = (
+ await prisma.harvest.create({
+ data: { apiaryId, date: dayToDate(today), userId },
+ })
+ ).id;
+ harvestAfter = (
+ await prisma.harvest.create({
+ data: { apiaryId, date: dayToDate(addDays(today, 20)), userId },
+ })
+ ).id;
+ created.harvests.push(harvestInside, harvestAfter);
+ for (const harvestId of created.harvests) {
+ await prisma.harvestColony.create({ data: { harvestId, colonyId } });
+ }
+ // A complete label: its own bottling (far in the future, so it is the
+ // newest one with stock) and a product with a legal name.
+ productId = (
+ await prisma.product.create({
+ data: { name: `Readiness-Glas ${area}`, legalName: 'Blütenhonig' },
+ })
+ ).id;
+ extractionId = (
+ await prisma.extraction.create({ data: { date: new Date(), userId } })
+ ).id;
+ honeyBatchId = (
+ await prisma.honeyBatch.create({
+ data: {
+ batchNumber: `RA-${area}`,
+ extractionId,
+ amount: 25,
+ initialAmount: 25,
+ },
+ })
+ ).id;
+ bottlingId = (
+ await prisma.bottlingBatch.create({
+ data: {
+ honeyBatchId,
+ productId,
+ date: new Date('2099-06-01T00:00:00.000Z'),
+ jarSize: 500,
+ jarCount: 2,
+ totalAmount: 1,
+ bestBeforeDate: new Date('2101-06-01T00:00:00.000Z'),
+ lotNumber: `RA-L-${area}`,
+ userId,
+ },
+ })
+ ).id;
+ templateId = (
+ await prisma.labelTemplate.create({
+ data: {
+ name: `Readiness-Vorlage ${area}`,
+ elements: [
+ textElement('legal', 1, 'legalName'),
+ textElement('net', 10, 'jarSize', 16),
+ textElement('mhd', 20, 'bestBeforeLine'),
+ textElement('lot', 29, 'lotLine'),
+ textElement('origin', 38, 'originCountry'),
+ textElement('firm', 47, 'foodBusinessName'),
+ textElement('address', 56, 'foodBusinessAddress'),
+ ],
+ widthMm: 90,
+ heightMm: 70,
+ },
+ })
+ ).id;
+ await setSite({
+ holderName: 'Imker E2E',
+ holderAddress: 'Weg 1, 12345 Ort',
+ foodBusinessName: 'Imkerei E2E',
+ foodBusinessAddress: 'Weg 2, 12345 Ort',
+ });
+ await prisma.complianceSettings.upsert({
+ where: { id: 'singleton' },
+ update: {},
+ create: { id: 'singleton' },
+ });
+ });
+
+ afterAll(async () => {
+ await prisma.veterinaryMedicineMovement.deleteMany({
+ where: { lotId: { in: created.lots } },
+ });
+ await prisma.domainEvent.deleteMany({
+ where: {
+ aggregateType: 'Treatment',
+ aggregateId: { in: created.treatments },
+ },
+ });
+ await prisma.treatment.deleteMany({
+ where: { id: { in: created.treatments } },
+ });
+ await prisma.veterinaryMedicineLot.deleteMany({
+ where: { id: { in: created.lots } },
+ });
+ await prisma.veterinaryMedicine.deleteMany({
+ where: { id: medicineId },
+ });
+ await prisma.harvestColony.deleteMany({
+ where: { harvestId: { in: created.harvests } },
+ });
+ await prisma.harvest.deleteMany({
+ where: { id: { in: created.harvests } },
+ });
+ await prisma.bottlingBatch.deleteMany({ where: { id: bottlingId } });
+ await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
+ await prisma.extraction.deleteMany({ where: { id: extractionId } });
+ await prisma.labelTemplate.deleteMany({ where: { id: templateId } });
+ await prisma.product.deleteMany({ where: { id: productId } });
+ await prisma.colony.deleteMany({ where: { id: colonyId } });
+ });
+
+ it('complete data in every area leaves no open item for the subjects of this test and no open item of the Stammdaten rules', async () => {
+ await setVetOffice('Amt E2E');
+ const body = await report();
+ const keys = [
+ 'treatment-log-gaps',
+ 'treatment-location-uncertain',
+ 'lot-gaps',
+ 'lot-prescription',
+ 'lot-expired-stock',
+ 'treatment-expired-lot',
+ 'label-template-gaps',
+ 'label-template-form',
+ 'medicine-withdrawal-missing',
+ ];
+ const ownIds = [
+ treatmentOk,
+ treatmentSmall,
+ lotOk,
+ lotSmall,
+ medicineId,
+ templateId,
+ harvestAfter,
+ ];
+
+ for (const key of ['holder-data', 'food-business-data', 'vet-office']) {
+ expect(itemOf(body, key).state).toBe('DONE');
+ }
+ for (const key of keys) {
+ const open = openSubjectIds(body, key);
+ for (const id of ownIds) {
+ expect({ key, id, open: open.includes(id) }).toEqual({
+ key,
+ id,
+ open: false,
+ });
+ }
+ }
+ });
+
+ it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', async () => {
+ const year = Number(today.slice(0, 4));
+ const log = await buildTreatmentLog(prisma, year, apiaryId);
+ const rows = log.sheets
+ .flatMap((sheet) => sheet.rows)
+ .filter((row) => row.lotId === lotOk);
+
+ expect(rows).toHaveLength(1);
+ expect(treatmentLogGaps(rows[0])).toEqual([]);
+ const open = openSubjectIds(await report(), 'treatment-log-gaps');
+ expect(open).not.toContain(treatmentOk);
+ expect(open).not.toContain(lotOk);
+ });
+
+ it('the gap names of an incomplete treatment equal the gaps the Bestandsbuch row reports', async () => {
+ // Dated in the first year of the lookback: the item lists at most 50
+ // subjects and the shared database holds other treatments.
+ const year = Number(today.slice(0, 4)) - 5;
+ const id = await postTreatment({
+ date: `${year}-01-01`,
+ product: `Luecken-Mittel ${area}`,
+ withdrawalPeriodDays: 0,
+ });
+
+ const item = itemOf(await report(), 'treatment-log-gaps', String(year));
+ const subject = item.subjects.find((entry) => entry.id === id);
+ const log = await buildTreatmentLog(prisma, year, apiaryId);
+ const row = log.sheets
+ .flatMap((sheet) => sheet.rows)
+ .find((entry) => entry.product === `Luecken-Mittel ${area}`)!;
+ const names = {
+ SUPPLIER: 'Lieferant',
+ PROOF: 'Beleg',
+ AMOUNT: 'Menge',
+ WITHDRAWAL: 'Wartezeit',
+ VET: 'Tierarzt',
+ };
+
+ expect(item.state).toBe('OPEN');
+ expect(subject).toMatchObject({ type: 'TREATMENT', parentId: colonyId });
+ expect(subject!.missing).toEqual(
+ treatmentLogGaps(row).map((code) => names[code]),
+ );
+ expect(JSON.stringify(item)).not.toContain('Luecken-Mittel');
+ });
+
+ it('a negative rest after a treatment from a too small lot opens lot-negative', async () => {
+ const body = await report();
+
+ expect(itemOf(body, 'lot-negative').state).toBe('OPEN');
+ expect(openSubjectIds(body, 'lot-negative')).toContain(lotSmall);
+ expect(openSubjectIds(body, 'lot-negative')).not.toContain(lotOk);
+ });
+
+ it('lot-expired-stock is acknowledgeable per lot, a booking that changes the rest reopens it', async () => {
+ const before = itemOf(await report(), 'lot-expired-stock', lotOld);
+ expect(before).toMatchObject({ state: 'OPEN', acknowledgeable: true });
+ expect(openSubjectIds(await report(), 'treatment-expired-lot')).toContain(
+ treatmentOld,
+ );
+
+ const response = await acknowledge({
+ itemKey: 'lot-expired-stock',
+ subjectId: lotOld,
+ fingerprint: before.fingerprint,
+ note: 'Wird bei der nächsten Abgabe entsorgt.',
+ });
+ expect(response.status).toBe(201);
+ acknowledgementIds.push(
+ (response.body as ReadinessItem).acknowledgement!.id,
+ );
+ expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
+ 'ACKNOWLEDGED',
+ );
+
+ const disposal = await request(server())
+ .post(`/api/v1/veterinary-medicine-lots/${lotOld}/movements`)
+ .set(as('beekeeper'))
+ .send({ type: 'DISPOSAL', quantity: 1, reason: 'Teil entsorgt' });
+ expect(disposal.status).toBe(201);
+
+ expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
+ 'OPEN',
+ );
+ });
+
+ it('hides the lot subjects without veterinary-medicines:read but keeps the count', async () => {
+ const masked = itemOf(await report('complianceonly'), 'lot-negative');
+ const full = itemOf(await report('beekeeper'), 'lot-negative');
+
+ expect(masked.state).toBe('OPEN');
+ expect(masked.subjects).toEqual([]);
+ expect(masked.more).toBeGreaterThanOrEqual(1);
+ expect(JSON.stringify(masked)).not.toContain(medicineName);
+ expect(full.subjects.map((subject) => subject.id)).toContain(lotSmall);
+ });
+
+ it('opens harvest-withdrawal only for the harvest inside the window and acknowledges it', async () => {
+ const body = await report();
+ const open = openSubjectIds(body, 'harvest-withdrawal');
+
+ expect(open).toContain(harvestInside);
+ expect(open).not.toContain(harvestAfter);
+ const item = itemOf(body, 'harvest-withdrawal', harvestInside);
+ expect(JSON.stringify(item)).not.toContain('Wartezeit-Mittel');
+ const response = await acknowledge({
+ itemKey: 'harvest-withdrawal',
+ subjectId: harvestInside,
+ fingerprint: item.fingerprint,
+ note: 'Honig bleibt bis zum Ende der Wartezeit zurück.',
+ });
+ expect(response.status).toBe(201);
+ acknowledgementIds.push(
+ (response.body as ReadinessItem).acknowledgement!.id,
+ );
+ });
+
+ it('the label rules judge a template against the newest bottling with stock', async () => {
+ expect(
+ openSubjectIds(await report(), 'label-template-gaps'),
+ ).not.toContain(templateId);
+ await prisma.labelTemplate.update({
+ where: { id: templateId },
+ data: { elements: [textElement('legal', 1, 'legalName')] },
+ });
+
+ expect(openSubjectIds(await report(), 'label-template-gaps')).toContain(
+ templateId,
+ );
+ });
+
+ const fetchPdf = (user: string) =>
+ request(server())
+ .get(`${READINESS}/report.pdf`)
+ .set(as(user))
+ .buffer(true)
+ .parse((res, callback) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
+ });
+
+ it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', async () => {
+ const response = await fetchPdf('beekeeper');
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toContain('application/pdf');
+ expect(response.headers['cache-control']).toBe('no-store');
+ expect(response.headers['content-disposition']).toContain(
+ 'pruefbericht.pdf',
+ );
+ const text = pdfText(response.body as Buffer);
+ expect(text).toContain('Prüfbericht');
+ expect(text).not.toContain(medicineName);
+ expect((await fetchPdf('apiclient')).status).toBe(403);
+ });
+
+ it('the report PDF of a user with compliance:read but without veterinary-medicines:read names no medicine', async () => {
+ const response = await fetchPdf('complianceonly');
+
+ expect(response.status).toBe(200);
+ const text = pdfText(response.body as Buffer);
+ expect(text).toContain('Prüfbericht');
+ for (const name of [
+ medicineName,
+ `Wartezeit-Mittel ${area}`,
+ `Luecken-Mittel ${area}`,
+ ]) {
+ expect(text).not.toContain(name);
+ }
+ });
+ });
});
diff --git a/ApiCore-Frontend/public/sw-routes.js b/ApiCore-Frontend/public/sw-routes.js
index 2c23698..214077d 100644
--- a/ApiCore-Frontend/public/sw-routes.js
+++ b/ApiCore-Frontend/public/sw-routes.js
@@ -68,8 +68,30 @@
/\/api\/v1\/colonies\/?$/i,
/\/api\/v1\/colonies\/[^/]+\/inspections\/?$/i,
/\/api\/v1\/apiaries\/[^/]+\/(colony-overview|offline-pack)\/?$/i,
+ // Prüfbereitschaft and authority data: file numbers, addresses, notices,
+ // restricted zones around an apiary, medicine packs with receipts and
+ // suppliers, and the label check and print (they carry the food business address)
+ /\/api\/v1\/compliance(\/|$)/i,
+ /\/api\/v1\/authority-notices(\/|$)/i,
+ /\/api\/v1\/apiary-restrictions(\/|$)/i,
+ /\/api\/v1\/apiaries\/[^/]+\/restrictions\/?$/i,
+ /\/api\/v1\/veterinary-medicines(\/|$)/i,
+ /\/api\/v1\/veterinary-medicine-lots(\/|$)/i,
+ /\/api\/v1\/label-templates\/check(\/|$)/i,
+ /\/api\/v1\/label-templates\/[^/]+\/(print|check)(\/|$)/i,
+ // One single document (metadata): its kind is not in the path, and it may
+ // belong to a pack, a notice or a restriction. The lists (with the kind
+ // in the query, see below) and the photo downloads use other routes.
+ /\/api\/v1\/documents\/[^/]+\/?$/i,
];
+ // The document list of these records is never kept either (attachments of a
+ // pack, a notice or a restriction). The list is /documents?entityType=...,
+ // so the query decides; documents of inspections (photos) stay available
+ // offline. Downloads are blobs and never kept anyway.
+ var DOCUMENTS_PATH = /\/api\/v1\/documents\/?$/i;
+ var NEVER_CACHED_DOCUMENT_TYPES = ["MEDICINE_LOT", "AUTHORITY_NOTICE", "APIARY_RESTRICTION"];
+
// A root-relative build asset, in an attribute or in the inline RSC data
// (where quotes are escaped). The leading character keeps absolute URLs of
// other hosts out.
@@ -174,9 +196,35 @@
return true;
}
+ function isNeverCachedDocumentList(url) {
+ if (!DOCUMENTS_PATH.test(url.pathname)) {
+ return false;
+ }
+ var entityType = (url.searchParams.get("entityType") || "").toUpperCase();
+ return NEVER_CACHED_DOCUMENT_TYPES.indexOf(entityType) !== -1;
+ }
+
+ /** The path rules plus the document lists, which only the query tells apart. */
+ function isCacheableApiUrl(url) {
+ return isCacheableApiPath(url.pathname) && !isNeverCachedDocumentList(url);
+ }
+
/** Only answers of the backend named by the runtime config are kept. */
function shouldCacheApi(url, apiOrigin) {
- return Boolean(apiOrigin) && url.origin === apiOrigin && isCacheableApiPath(url.pathname);
+ return Boolean(apiOrigin) && url.origin === apiOrigin && isCacheableApiUrl(url);
+ }
+
+ /**
+ * Whether a backend answer may be stored: a successful JSON record. The
+ * backend marks EVERY answer `Cache-Control: no-store`, so that header says
+ * nothing about one record; what must not stay on the device is decided by
+ * the path rules above. Never downloads, photos, PDFs.
+ */
+ function isKeepableApiAnswer(answer) {
+ return (
+ Boolean(answer.ok) &&
+ String(answer.contentType || "").toLowerCase().indexOf("application/json") !== -1
+ );
}
/** The backend's origin from /api/runtime-config ({ apiUrl }), or null. */
@@ -242,7 +290,9 @@
isShellRoute: isShellRoute,
extractAssetUrls: extractAssetUrls,
isCacheableApiPath: isCacheableApiPath,
+ isCacheableApiUrl: isCacheableApiUrl,
shouldCacheApi: shouldCacheApi,
+ isKeepableApiAnswer: isKeepableApiAnswer,
apiOriginFrom: apiOriginFrom,
isRoutePayload: isRoutePayload,
buildAssetsToPrune: buildAssetsToPrune,
diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js
index 178246c..f323d17 100644
--- a/ApiCore-Frontend/public/sw.js
+++ b/ApiCore-Frontend/public/sw.js
@@ -9,7 +9,9 @@
* The routing rules live in sw-routes.js (unit-tested).
*
* What is never kept: auth answers (tokens, session state), apiaries
- * themselves (coordinates, address), Next.js route payloads, and request
+ * themselves (coordinates, address), Prüfbereitschaft and authority data,
+ * medicine packs, single document records, Next.js route
+ * payloads, and request
* headers - entries are stored under their bare URL, so the Authorization
* header with the access token never lands in Cache Storage. The cached
* HTML holds no user data: the (app) layout renders nothing until the client
@@ -19,7 +21,7 @@
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
// characters; sw-routes.spec.ts checks it). A changed value changes this
// file, so every browser - Safari too - installs the worker with new rules.
-const SW_ROUTES_VERSION = "6a6f9dcbc99f";
+const SW_ROUTES_VERSION = "0681ff4b095d";
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
const {
@@ -28,7 +30,8 @@ const {
offlineHomeFor,
navigationPlanFor,
extractAssetUrls,
- isCacheableApiPath,
+ isCacheableApiUrl,
+ isKeepableApiAnswer: isKeepableApiHeaders,
apiOriginFrom,
isRoutePayload,
buildAssetsToPrune,
@@ -125,7 +128,7 @@ self.addEventListener("fetch", (event) => {
event.respondWith(respondWithRuntimeConfig(event));
return;
}
- if (isCacheableApiPath(url.pathname)) {
+ if (isCacheableApiUrl(url)) {
respondToApiCall(event, url);
return;
}
@@ -202,7 +205,7 @@ function removeUnknownCaches() {
async function pruneForbiddenEntries() {
const data = await caches.open(DATA_CACHE);
for (const request of await data.keys()) {
- if (!isCacheableApiPath(new URL(request.url).pathname)) {
+ if (!isCacheableApiUrl(new URL(request.url))) {
await data.delete(request);
}
}
@@ -299,8 +302,10 @@ function isGoodAnswer(response) {
* so it is readable on the backend's cross-origin answers.
*/
function isKeepableApiAnswer(response) {
- const contentType = response.headers.get("content-type") || "";
- return response.ok && contentType.toLowerCase().includes("application/json");
+ return isKeepableApiHeaders({
+ ok: response.ok,
+ contentType: response.headers.get("content-type"),
+ });
}
function isGoodPage(response) {
diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx
index f61a179..e2f3f84 100644
--- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.spec.tsx
@@ -3,8 +3,10 @@ import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api";
import ApiaryDetailPage from "./page";
+import { useAuth } from "@/lib/auth/auth-context";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) }));
+vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() }));
vi.mock("@/lib/export/api", () => ({
exportApiaryAnnualReportPdf: vi.fn(),
@@ -13,7 +15,24 @@ vi.mock("@/lib/export/api", () => ({
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/detail-header", () => ({
- DetailHeader: ({ title }: { title: string }) => {title} ,
+ DetailHeader: ({
+ title,
+ moreActions,
+ }: {
+ title: string;
+ moreActions?: { label: string; disabled?: boolean }[];
+ }) => (
+
+ {title}
+
+ {moreActions?.map((action) => (
+
+ {action.label}
+
+ ))}
+
+
+ ),
DetailQueryFallback: () => Lädt
,
DetailStatus: () => null,
}));
@@ -29,24 +48,60 @@ vi.mock("@/components/phenology/apiary-season", () => ({
),
}));
+function renderPage(permissions: string[]) {
+ vi.mocked(useAuth).mockReturnValue({
+ hasPermission: (permission: string) => permissions.includes(permission),
+ } as never);
+ vi.mocked(fetchApiary).mockResolvedValue({
+ id: "a-1",
+ name: "Obstwiese",
+ location: null,
+ status: "ACTIVE",
+ latitude: null,
+ longitude: null,
+ } as never);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ return render(
+
+
+ ,
+ );
+}
+
describe("ApiaryDetailPage", () => {
it("shows the season of the apiary below the header", async () => {
- vi.mocked(fetchApiary).mockResolvedValue({
- id: "a-1",
- name: "Obstwiese",
- location: null,
- status: "ACTIVE",
- latitude: null,
- longitude: null,
- } as never);
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
-
- render(
-
-
- ,
- );
+ renderPage(["veterinary-medicines:read"]);
expect(await screen.findByText("Saison von Obstwiese")).toBeInTheDocument();
});
+
+ it("offers the Bestandsbuch with veterinary-medicines:read", async () => {
+ renderPage(["veterinary-medicines:read"]);
+
+ const item = await screen.findByText(/^Bestandsbuch \d{4} \(PDF\)$/);
+ expect(item).not.toHaveAttribute("data-disabled");
+ });
+
+ it("switches the Bestandsbuch off without veterinary-medicines:read and says why", async () => {
+ renderPage([]);
+
+ const item = await screen.findByText(/^Bestandsbuch \d{4} \(PDF\) – nur mit dem Recht Tierarzneimittel lesen$/);
+ expect(item).toHaveAttribute("data-disabled", "true");
+ });
+
+ it("keeps a visible hint about the missing right next to the switched-off Bestandsbuch", async () => {
+ renderPage([]);
+
+ expect(
+ await screen.findByText("Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“."),
+ ).toBeInTheDocument();
+ });
+
+ it("shows no hint with the right", async () => {
+ renderPage(["veterinary-medicines:read"]);
+
+ await screen.findByText("Saison von Obstwiese");
+ expect(screen.queryByText(/Das Bestandsbuch braucht das Recht/)).not.toBeInTheDocument();
+ });
});
diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx
index 44872af..172fb3f 100644
--- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx
@@ -5,6 +5,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { FileText, MapPin, Plus } from "lucide-react";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
+import { useAuth } from "@/lib/auth/auth-context";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
@@ -19,6 +20,7 @@ const BACK = { backHref: "/apiaries", backLabel: "Alle Bienenstände" };
export default function ApiaryDetailPage() {
const params = useParams<{ id: string }>();
+ const { hasPermission } = useAuth();
const apiaryQuery = useQuery({
queryKey: ["apiaries", params.id],
queryFn: () => fetchApiary(params.id),
@@ -39,6 +41,8 @@ export default function ApiaryDetailPage() {
return ;
}
+ const canReadMedicines = hasPermission("veterinary-medicines:read");
+
return (
treatmentLogMutation.mutate(),
},
{
@@ -77,6 +84,11 @@ export default function ApiaryDetailPage() {
]}
{...BACK}
/>
+ {!canReadMedicines ? (
+
+ Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.
+
+ ) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx
index 50c02d6..d643b6b 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx
@@ -86,7 +86,7 @@ export default function BottlingBatchDetailPage() {
unit="Gläser"
canBook={hasPermission("bottling:withdraw")}
/>
-
+
);
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx
new file mode 100644
index 0000000..6bf7dab
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.spec.tsx
@@ -0,0 +1,269 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ApiError } from "@/lib/auth/api-client";
+import { reportRequestOutcome } from "@/lib/offline/connectivity";
+import { downloadBlob } from "@/lib/export/api";
+import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api";
+import { checkTemplateForBottling, type LabelCheckResult } from "@/lib/label-templates/check";
+import type { LabelTemplate } from "@/lib/label-templates/types";
+import { PrintLabelsCard } from "./print-labels-card";
+
+const permissions = new Set(["label-templates:read", "label-templates:print", "bottling:read"]);
+vi.mock("@/lib/auth/auth-context", () => ({
+ useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
+}));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
+vi.mock("@/lib/label-templates/api", () => ({
+ fetchLabelTemplates: vi.fn(),
+ printLabels: vi.fn(),
+}));
+vi.mock("@/lib/label-templates/check", async (importOriginal) => ({
+ ...(await importOriginal
()),
+ checkTemplateForBottling: vi.fn(),
+}));
+
+const TEMPLATE = {
+ id: "tpl-1",
+ name: "Standard",
+ active: true,
+ isDefault: true,
+ columns: 2,
+ rows: 5,
+} as LabelTemplate;
+
+const COMPLETE: LabelCheckResult = {
+ printable: true,
+ gaps: [],
+ warnings: [],
+ sample: false,
+ requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: null },
+};
+
+const GAPS: LabelCheckResult = {
+ ...COMPLETE,
+ printable: false,
+ gaps: [
+ { code: "FOOD_BUSINESS", title: "Lebensmittelunternehmer fehlt", detail: "Name und Anschrift fehlen.", elementIds: [] },
+ { code: "LEGAL_NAME", title: "Verkehrsbezeichnung fehlt", detail: "Kein Wort mit Honig.", elementIds: [] },
+ { code: "NET_QUANTITY", title: "Füllmenge fehlt", detail: "Die Füllmenge fehlt.", elementIds: [] },
+ ],
+};
+
+function renderCard(props: { productId?: string | null } = {}) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ permissions.clear();
+ ["label-templates:read", "label-templates:print", "bottling:read"].forEach((p) => permissions.add(p));
+ vi.mocked(fetchLabelTemplates).mockReset().mockResolvedValue([TEMPLATE]);
+ vi.mocked(checkTemplateForBottling).mockReset().mockResolvedValue(COMPLETE);
+ vi.mocked(printLabels).mockReset().mockResolvedValue(new Blob(["pdf"]));
+ vi.mocked(downloadBlob).mockReset();
+ act(() => reportRequestOutcome("reached-server"));
+});
+afterEach(() => act(() => reportRequestOutcome("reached-server")));
+
+describe("PrintLabelsCard", () => {
+ it("the main button is aria-disabled with the reason in aria-describedby while a particular is missing, and stays focusable", async () => {
+ vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
+ renderCard();
+
+ const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
+ await waitFor(() => expect(main).toHaveAttribute("aria-disabled", "true"));
+ expect(main).not.toBeDisabled();
+ const reason = document.getElementById(main.getAttribute("aria-describedby") ?? "");
+ expect(reason).toHaveTextContent(/Pflichtangaben fehlen/);
+
+ main.focus();
+ expect(main).toHaveFocus();
+ await userEvent.click(main);
+ expect(printLabels).not.toHaveBeenCalled();
+ });
+
+ it("shows the red gap list with links to Stammdaten, Produkt and Designer", async () => {
+ vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
+ renderCard();
+
+ const list = await screen.findByRole("region", { name: /Pflichtangaben fehlen/ });
+ const link = (name: RegExp) => screen.getAllByRole("link", { name });
+ expect(link(/^Stammdaten öffnen/)[0]).toHaveAttribute(
+ "href",
+ "/settings/automation?tab=social-media",
+ );
+ expect(link(/^Produkt öffnen/)[0]).toHaveAttribute("href", "/products/p-9");
+ expect(link(/^Designer öffnen/)[0]).toHaveAttribute("href", "/settings/label-templates/tpl-1");
+ expect(list).toHaveTextContent("Fehlt: Name und Anschrift des Lebensmittelunternehmers");
+ expect(list).toHaveTextContent("Name und Anschrift fehlen.");
+ expect(list).toHaveTextContent("Fehlt");
+ expect(list.querySelector("svg")).not.toBeNull();
+ });
+
+ it("leaves out the product link when the bottling has no product", async () => {
+ vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
+ renderCard({ productId: null });
+
+ await screen.findByRole("region", { name: /Pflichtangaben fehlen/ });
+ expect(screen.queryByRole("link", { name: /^Produkt öffnen/ })).not.toBeInTheDocument();
+ });
+
+ it("Probedruck (MUSTER) stays possible and requests proof=true", async () => {
+ vi.mocked(checkTemplateForBottling).mockResolvedValue(GAPS);
+ renderCard();
+
+ const proof = await screen.findByRole("button", { name: "Probedruck (MUSTER)" });
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Etiketten als PDF" })).toHaveAttribute("aria-disabled", "true"),
+ );
+ expect(proof).not.toHaveAttribute("aria-disabled", "true");
+ await userEvent.click(proof);
+
+ await waitFor(() =>
+ expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: undefined, proof: true }),
+ );
+ await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
+ });
+
+ it("moves focus to the gap list after a 409 LABEL_PARTICULARS_MISSING", async () => {
+ // The check was stale and said "complete"; the server knows better.
+ vi.mocked(printLabels).mockRejectedValue(
+ new ApiError(409, "Es fehlen Pflichtangaben.", {
+ code: "LABEL_PARTICULARS_MISSING",
+ message: "Es fehlen Pflichtangaben.",
+ gaps: GAPS.gaps,
+ }),
+ );
+ renderCard();
+
+ const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
+ await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
+ await userEvent.click(main);
+
+ const list = await screen.findByRole("region", { name: /Pflichtangaben fehlen/ });
+ await waitFor(() => expect(list).toHaveFocus());
+ expect(downloadBlob).not.toHaveBeenCalled();
+ });
+
+ it("explains invalid compliance settings in German after a 409 COMPLIANCE_SETTINGS_INVALID", async () => {
+ vi.mocked(printLabels).mockRejectedValue(
+ new ApiError(409, "Compliance-Einstellungen fehlen oder sind ungültig.", {
+ code: "COMPLIANCE_SETTINGS_INVALID",
+ message: "Compliance-Einstellungen fehlen oder sind ungültig.",
+ }),
+ );
+ renderCard();
+
+ const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
+ await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
+ await userEvent.click(main);
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/Compliance-Einstellungen/);
+ });
+
+ it("prints the PDF when the check has no gaps; warnings do not block", async () => {
+ vi.mocked(checkTemplateForBottling).mockResolvedValue({
+ ...COMPLETE,
+ warnings: [{ code: "X_HEIGHT", title: "Text zu klein (x-Höhe 1,1 mm)", detail: "", elementIds: [] }],
+ });
+ renderCard();
+
+ expect(await screen.findByText("Text zu klein (x-Höhe 1,1 mm)")).toBeInTheDocument();
+ const main = screen.getByRole("button", { name: "Etiketten als PDF" });
+ expect(main).not.toHaveAttribute("aria-disabled", "true");
+ await userEvent.click(main);
+
+ await waitFor(() =>
+ expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: undefined, proof: false }),
+ );
+ await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
+ expect(screen.queryByRole("region", { name: /Pflichtangaben fehlen/ })).not.toBeInTheDocument();
+ });
+
+ it("names the proof print file with MUSTER and the real one without", async () => {
+ renderCard();
+ await userEvent.click(await screen.findByRole("button", { name: "Probedruck (MUSTER)" }));
+ await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
+ expect(vi.mocked(downloadBlob).mock.calls[0][1]).toMatch(/muster/i);
+
+ const main = screen.getByRole("button", { name: "Etiketten als PDF" });
+ await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
+ await userEvent.click(main);
+ await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(2));
+ expect(vi.mocked(downloadBlob).mock.calls[1][1]).not.toMatch(/muster/i);
+ });
+
+ it("sends no second request while the first one is pending", async () => {
+ let finish: (blob: Blob) => void = () => {};
+ vi.mocked(printLabels).mockReturnValue(new Promise((resolve) => (finish = resolve)));
+ renderCard();
+ const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
+ await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
+
+ await userEvent.click(main);
+ await userEvent.click(main);
+ await userEvent.click(screen.getByRole("button", { name: "Probedruck (MUSTER)" }));
+ expect(printLabels).toHaveBeenCalledTimes(1);
+
+ await act(async () => finish(new Blob(["pdf"])));
+ await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1));
+ });
+
+ it("sends the start position when it is above 1", async () => {
+ renderCard();
+ const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
+ const input = screen.getByLabelText(/Startposition/);
+ fireEvent.change(input, { target: { value: "4" } });
+ await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
+ await userEvent.click(main);
+
+ await waitFor(() =>
+ expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: 4, proof: false }),
+ );
+ });
+
+ it("keeps the print button inactive while the check runs", async () => {
+ vi.mocked(checkTemplateForBottling).mockReturnValue(new Promise(() => {}));
+ renderCard();
+
+ const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
+ expect(main).toHaveAttribute("aria-disabled", "true");
+ expect(document.getElementById(main.getAttribute("aria-describedby") ?? "")).toHaveTextContent(/geprüft/);
+ });
+
+ it("is online only: both buttons are inactive offline with the visible reason", async () => {
+ renderCard();
+ const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
+ await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
+
+ act(() => reportRequestOutcome("network-error"));
+
+ expect(main).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByRole("button", { name: "Probedruck (MUSTER)" })).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
+ });
+
+ it("handles a missing permission gracefully without asking the server", async () => {
+ permissions.delete("label-templates:read");
+ renderCard();
+
+ expect(await screen.findByText(/Berechtigung/)).toBeInTheDocument();
+ expect(fetchLabelTemplates).not.toHaveBeenCalled();
+ expect(checkTemplateForBottling).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: "Etiketten als PDF" })).not.toBeInTheDocument();
+ });
+
+ it("shows a 403 of the check as a German message", async () => {
+ vi.mocked(checkTemplateForBottling).mockRejectedValue(new ApiError(403, "Forbidden"));
+ renderCard();
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/);
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx
index 03fa8ec..e3ccb2d 100644
--- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx
+++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx
@@ -1,40 +1,252 @@
"use client";
-import { useState } from "react";
-import { Printer, Tag } from "lucide-react";
+import { useEffect, useId, useRef, useState } from "react";
+import Link from "next/link";
+import { CircleX, Printer, ShieldAlert, Tag, TriangleAlert } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
-import { toast } from "sonner";
import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api";
+import {
+ checkTemplateForBottling,
+ labelCheckErrorText,
+ labelParticularsMissing,
+ type LabelFinding,
+} from "@/lib/label-templates/check";
+import { downloadBlob } from "@/lib/export/api";
+import { useAuth } from "@/lib/auth/auth-context";
+import { PARTICULAR_LABELS } from "@/components/labels/label-compliance-panel";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RecordSelect } from "@/components/form/record-select";
-export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) {
+const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
+const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
+const LINK_CLASS =
+ "inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
+
+type FixTarget = "master" | "product" | "designer";
+
+// Where a person fixes a gap: master data (Verwaltung > Automatisierung), the
+// product of the bottling, or the template itself.
+const FIX_TARGETS: Record = {
+ LEGAL_NAME: ["product", "designer"],
+ NET_QUANTITY: ["designer"],
+ BEST_BEFORE: ["designer"],
+ LOT: ["designer"],
+ ORIGIN: ["master", "designer"],
+ FOOD_BUSINESS: ["master", "designer"],
+};
+
+interface ServerGaps {
+ templateId: string;
+ /** When the server answered; a later check result wins. */
+ at: number;
+ gaps: LabelFinding[];
+}
+
+function gapName(gap: LabelFinding): string {
+ return PARTICULAR_LABELS[gap.code as keyof typeof PARTICULAR_LABELS] ?? gap.title;
+}
+
+function fixLinks(
+ gap: LabelFinding,
+ templateId: string,
+ productId: string | null | undefined,
+): { label: string; href: string }[] {
+ const targets = FIX_TARGETS[gap.code] ?? ["designer"];
+ return targets.flatMap((target): { label: string; href: string }[] => {
+ if (target === "master") {
+ return [{ label: "Stammdaten öffnen", href: "/settings/automation?tab=social-media" }];
+ }
+ if (target === "product") {
+ return productId
+ ? [{ label: "Produkt öffnen", href: `/products/${encodeURIComponent(productId)}` }]
+ : [];
+ }
+ return [
+ {
+ label: "Designer öffnen",
+ href: `/settings/label-templates/${encodeURIComponent(templateId)}`,
+ },
+ ];
+ });
+}
+
+function GapList({
+ gaps,
+ templateId,
+ productId,
+ sectionRef,
+}: {
+ gaps: LabelFinding[];
+ templateId: string;
+ productId: string | null | undefined;
+ sectionRef: React.RefObject;
+}) {
+ const headingId = useId();
+ return (
+
+
+
+ Pflichtangaben fehlen
+
+
+ {gaps.map((gap, index) => (
+
+
+
+
+ Fehlt: {gapName(gap)}
+
+ {gap.detail || gap.title}
+
+ {fixLinks(gap, templateId, productId).map((link) => (
+
+ {link.label}
+ für {gapName(gap)}
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+}
+
+export function PrintLabelsCard({
+ bottlingBatchId,
+ productId,
+}: {
+ bottlingBatchId: string;
+ /** Product of the bottling; the legal name of the label is kept there. */
+ productId?: string | null;
+}) {
+ const { hasPermission } = useAuth();
+ const canReadTemplates = hasPermission("label-templates:read");
+ const canPrint = hasPermission("label-templates:print");
+ const canCheck = canReadTemplates && hasPermission("bottling:read");
+ const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
+ const reasonId = useId();
const [templateId, setTemplateId] = useState("");
const [startPosition, setStartPosition] = useState(1);
+ const [serverGaps, setServerGaps] = useState(null);
+ const [printError, setPrintError] = useState(null);
+ const gapsRef = useRef(null);
+ const focusGapsNext = useRef(false);
+
const { data: templates } = useQuery({
queryKey: ["label-templates"],
queryFn: fetchLabelTemplates,
+ enabled: canReadTemplates,
});
const activeTemplates = (templates ?? []).filter((template) => template.active);
const preferredTemplateId =
activeTemplates.find((template) => template.isDefault)?.id ?? activeTemplates[0]?.id ?? "";
const effectiveTemplateId = templateId || preferredTemplateId;
- const selectedTemplate = activeTemplates.find(
- (template) => template.id === effectiveTemplateId,
- );
+ const selectedTemplate = activeTemplates.find((template) => template.id === effectiveTemplateId);
const slotsPerSheet =
selectedTemplate?.columns && selectedTemplate?.rows
? selectedTemplate.columns * selectedTemplate.rows
: undefined;
- const mutation = useMutation({
- mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId, startPosition),
- onError: () => toast.error("Etiketten konnten nicht erstellt werden"),
+ // The answer holds the food business address: no persistence, dropped as
+ // soon as nobody looks at it any more.
+ const check = useQuery({
+ queryKey: ["label-templates", "check", effectiveTemplateId, bottlingBatchId],
+ queryFn: () => checkTemplateForBottling(effectiveTemplateId, bottlingBatchId),
+ enabled: canCheck && effectiveTemplateId !== "",
+ gcTime: 0,
+ retry: false,
});
+ const gaps =
+ serverGaps && serverGaps.templateId === effectiveTemplateId && serverGaps.at >= check.dataUpdatedAt
+ ? serverGaps.gaps
+ : (check.data?.gaps ?? []);
+ const warnings = check.data?.warnings ?? [];
+ const isChecking = check.isFetching && check.data === undefined;
+
+ useEffect(() => {
+ if (focusGapsNext.current && gapsRef.current) {
+ focusGapsNext.current = false;
+ gapsRef.current.focus();
+ }
+ }, [serverGaps]);
+
+ const mutation = useMutation({
+ mutationFn: (proof: boolean) =>
+ printLabels(effectiveTemplateId, bottlingBatchId, {
+ startPosition: startPosition > 1 ? startPosition : undefined,
+ proof,
+ }),
+ onSuccess: (blob, proof) => {
+ setServerGaps(null);
+ setPrintError(null);
+ downloadBlob(blob, proof ? "etiketten-muster.pdf" : "etiketten.pdf");
+ },
+ onError: (cause) => {
+ const missing = labelParticularsMissing(cause);
+ if (missing) {
+ // The server knows better than the last check: show its list and go there.
+ setPrintError(null);
+ focusGapsNext.current = true;
+ setServerGaps({ templateId: effectiveTemplateId, at: Date.now(), gaps: missing.gaps });
+ return;
+ }
+ setPrintError(labelCheckErrorText(cause, PRINT_FALLBACK));
+ },
+ });
+
+ if (!canReadTemplates) {
+ return (
+
+
+
+
+ Etiketten drucken
+
+
+
+
+ Dir fehlt die Berechtigung für Etiketten. Wende dich an die Administration.
+
+
+
+ );
+ }
+
+ const blockReason =
+ gaps.length > 0
+ ? `${gaps.length === 1 ? "1 Pflichtangabe fehlt" : `${gaps.length} Pflichtangaben fehlen`}. Behebe sie, dann kannst du drucken. Der Probedruck geht trotzdem.`
+ : isChecking
+ ? "Die Pflichtangaben werden noch geprüft."
+ : null;
+ const idle = !effectiveTemplateId || mutation.isPending || isOffline;
+ const mainInactive = idle || blockReason !== null;
+ const mainDescribedBy =
+ [blockReason ? reasonId : undefined, offlineDescribedBy].filter(Boolean).join(" ") || undefined;
+
+ const summary = !canCheck || !effectiveTemplateId
+ ? null
+ : isChecking
+ ? "Die Pflichtangaben werden geprüft …"
+ : check.data || serverGaps
+ ? gaps.length === 0
+ ? "Alle Pflichtangaben sind erfüllt."
+ : gaps.length === 1
+ ? "Es fehlt 1 Pflichtangabe."
+ : `Es fehlen ${gaps.length} Pflichtangaben.`
+ : null;
+
return (
@@ -59,6 +271,37 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
getLabel={(template) => template.name}
placeholder="Vorlage wählen"
/>
+
+ {summary}
+
+ {check.isError ? (
+
+ {labelCheckErrorText(check.error, CHECK_FALLBACK)}
+
+ ) : null}
+ {gaps.length > 0 ? (
+
+ ) : null}
+ {warnings.length > 0 ? (
+
+
Hinweise (sie sperren den Druck nicht)
+
+ {warnings.map((warning, index) => (
+
+
+
+ Hinweis: {warning.title}
+
+
+ ))}
+
+
+ ) : null}
Startposition auf dem Bogen (1 = oben links)
@@ -76,14 +319,53 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
- mutation.mutate()}
- >
-
- {mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
-
+ {canPrint ? (
+
+
+
{
+ if (!mainInactive) mutation.mutate(false);
+ }}
+ >
+
+ {mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
+
+
{
+ if (!idle) mutation.mutate(true);
+ }}
+ >
+ Probedruck (MUSTER)
+
+
+ {blockReason ? (
+
+ {blockReason}
+
+ ) : null}
+
+ {printError ? (
+
+ {printError}
+
+ ) : null}
+
+ ) : (
+
+ Dir fehlt die Berechtigung zum Drucken.
+
+ )}
>
)}
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.spec.tsx
new file mode 100644
index 0000000..4f6b272
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.spec.tsx
@@ -0,0 +1,147 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { AcknowledgementActions } from "./acknowledge-dialog";
+import { acknowledge, revokeAcknowledgement } from "@/lib/compliance/api";
+import { itemFixture } from "@/lib/compliance/fixtures";
+import { ApiError } from "@/lib/auth/api-client";
+
+let connectivity = "online";
+vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => connectivity }));
+vi.mock("@/lib/compliance/api", () => ({
+ acknowledge: vi.fn(),
+ revokeAcknowledgement: vi.fn(),
+}));
+
+const onDone = vi.fn();
+
+function renderActions(item = itemFixture({ acknowledgeable: true })) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+ render(
+
+
+ ,
+ );
+ return { invalidate };
+}
+
+describe("AcknowledgementActions", () => {
+ beforeEach(() => {
+ connectivity = "online";
+ onDone.mockReset();
+ vi.mocked(acknowledge).mockReset();
+ vi.mocked(revokeAcknowledgement).mockReset();
+ });
+
+ it("asks for a note of at least 10 characters before it submits", async () => {
+ vi.mocked(acknowledge).mockResolvedValue(undefined);
+ const item = itemFixture({ acknowledgeable: true, key: "k", subjectId: "s", fingerprint: "fp" });
+ renderActions(item);
+
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
+ const note = await screen.findByLabelText(/Notiz/);
+ await userEvent.type(note, "zu kurz");
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
+
+ expect(acknowledge).not.toHaveBeenCalled();
+ expect(await screen.findByText("Die Notiz braucht mindestens 10 Zeichen.")).toBeInTheDocument();
+
+ await userEvent.type(note, " – jetzt lang genug");
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
+ expect(acknowledge).toHaveBeenCalledWith({
+ itemKey: "k",
+ subjectId: "s",
+ fingerprint: "fp",
+ note: "zu kurz – jetzt lang genug",
+ });
+ });
+
+ it("offers Quittieren only for acknowledgeable items and Zurücknehmen for acknowledged ones", async () => {
+ renderActions(itemFixture({ acknowledgeable: false }));
+
+ expect(screen.queryByRole("button", { name: "Quittieren" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Zurücknehmen" })).not.toBeInTheDocument();
+ });
+
+ it("revokes an acknowledged item after a confirmation", async () => {
+ vi.mocked(revokeAcknowledgement).mockResolvedValue(undefined);
+ const { invalidate } = renderActions(
+ itemFixture({
+ acknowledgeable: true,
+ state: "ACKNOWLEDGED",
+ acknowledgement: { id: "ack-1", note: "Notiz Notiz", userName: "Nick", createdAt: "2026-10-01T00:00:00.000Z" },
+ }),
+ );
+
+ expect(screen.queryByRole("button", { name: "Quittieren" })).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Zurücknehmen" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Zurücknehmen bestätigen" }));
+
+ expect(revokeAcknowledgement).toHaveBeenCalledWith("ack-1");
+ await vi.waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["compliance"] }));
+ });
+
+ it("shows the server message on a 409 stale fingerprint and reloads the report", async () => {
+ vi.mocked(acknowledge).mockRejectedValue(
+ new ApiError(409, "Der Punkt hat sich geändert. Bitte neu laden.", {
+ statusCode: 409,
+ message: "Der Punkt hat sich geändert. Bitte neu laden.",
+ }),
+ );
+ const { invalidate } = renderActions();
+
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
+ await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Der Punkt hat sich geändert. Bitte neu laden.");
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ["compliance"] });
+ });
+
+ it("is off without a connection and says why", () => {
+ connectivity = "offline";
+ renderActions();
+
+ const trigger = screen.getByRole("button", { name: "Quittieren" });
+ expect(trigger).toBeDisabled();
+ expect(trigger).toHaveAccessibleDescription("Nur mit Verbindung");
+ });
+
+ it("shows a distinct text per conflict code", async () => {
+ vi.mocked(acknowledge).mockRejectedValue(
+ new ApiError(409, "x", { statusCode: 409, code: "READINESS_ITEM_NOT_OPEN", message: "x" }),
+ );
+ renderActions();
+
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
+ await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("nicht mehr offen");
+ expect(onDone).not.toHaveBeenCalled();
+ });
+
+ it("reports what happened once the change is saved", async () => {
+ vi.mocked(acknowledge).mockResolvedValue(undefined);
+ renderActions();
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
+ await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
+ await vi.waitFor(() => expect(onDone).toHaveBeenCalledWith("Punkt quittiert"));
+ });
+
+ it("reports the revoke", async () => {
+ vi.mocked(revokeAcknowledgement).mockResolvedValue(undefined);
+ renderActions(
+ itemFixture({
+ state: "ACKNOWLEDGED",
+ acknowledgement: { id: "ack-1", note: "Notiz Notiz", userName: "Nick", createdAt: "2026-10-01T00:00:00.000Z" },
+ }),
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Zurücknehmen" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Zurücknehmen bestätigen" }));
+ await vi.waitFor(() => expect(onDone).toHaveBeenCalledWith("Quittung zurückgenommen"));
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx b/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx
new file mode 100644
index 0000000..9efa928
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/compliance/acknowledge-dialog.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+import { FormEvent, useRef, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { ApiError } from "@/lib/auth/api-client";
+import { acknowledge, revokeAcknowledgement } from "@/lib/compliance/api";
+import { complianceErrorText, readinessConflictText } from "@/lib/compliance/errors";
+import { complianceKey } from "@/lib/compliance/use-readiness";
+import type { ReadinessItem } from "@/lib/compliance/types";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { Field } from "@/components/form/field";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Textarea } from "@/components/ui/textarea";
+
+const MIN_NOTE_LENGTH = 10;
+const MAX_NOTE_LENGTH = 2000;
+const ACKNOWLEDGED_MESSAGE = "Punkt quittiert";
+const REVOKED_MESSAGE = "Quittung zurückgenommen";
+const SHORT_NOTE_ERROR = `Die Notiz braucht mindestens ${MIN_NOTE_LENGTH} Zeichen.`;
+
+/** Reloads every compliance query; after a 409 the item may look different. */
+function useReloadCompliance(): () => Promise {
+ const queryClient = useQueryClient();
+ return () => queryClient.invalidateQueries({ queryKey: complianceKey });
+}
+
+function isConflict(error: unknown): boolean {
+ return error instanceof ApiError && error.status === 409;
+}
+
+interface AcknowledgeDialogProps {
+ item: ReadinessItem;
+ disabled: boolean;
+ describedBy: string | undefined;
+ onDone: (message: string) => void;
+}
+
+function AcknowledgeDialog({ item, disabled, describedBy, onDone }: AcknowledgeDialogProps) {
+ const reload = useReloadCompliance();
+ const [open, setOpen] = useState(false);
+ const [note, setNote] = useState("");
+ const [noteError, setNoteError] = useState(null);
+ const [serverError, setServerError] = useState(null);
+ const noteRef = useRef(null);
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ acknowledge({
+ itemKey: item.key,
+ subjectId: item.subjectId,
+ fingerprint: item.fingerprint,
+ note: note.trim(),
+ }),
+ onSuccess: async () => {
+ setOpen(false);
+ setNote("");
+ await reload();
+ onDone(ACKNOWLEDGED_MESSAGE);
+ },
+ onError: (error) => {
+ setServerError(readinessConflictText(error) ?? complianceErrorText(error, "Das Quittieren hat nicht geklappt."));
+ if (isConflict(error)) void reload();
+ },
+ });
+
+ function handleOpenChange(next: boolean) {
+ setOpen(next);
+ if (!next) {
+ setNoteError(null);
+ setServerError(null);
+ }
+ }
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ if (note.trim().length < MIN_NOTE_LENGTH) {
+ setNoteError(SHORT_NOTE_ERROR);
+ noteRef.current?.focus();
+ return;
+ }
+ setNoteError(null);
+ setServerError(null);
+ mutation.mutate();
+ }
+
+ return (
+
+ setOpen(true)}
+ >
+ Quittieren
+
+
+
+
+
+ );
+}
+
+interface RevokeButtonProps {
+ acknowledgementId: string;
+ disabled: boolean;
+ describedBy: string | undefined;
+ onError: (message: string | null) => void;
+ onDone: (message: string) => void;
+}
+
+function RevokeButton({ acknowledgementId, disabled, describedBy, onError, onDone }: RevokeButtonProps) {
+ const reload = useReloadCompliance();
+ const mutation = useMutation({
+ mutationFn: () => revokeAcknowledgement(acknowledgementId),
+ onSuccess: async () => {
+ onError(null);
+ await reload();
+ onDone(REVOKED_MESSAGE);
+ },
+ onError: (error) => {
+ onError(readinessConflictText(error) ?? complianceErrorText(error, "Das Zurücknehmen hat nicht geklappt."));
+ if (isConflict(error)) void reload();
+ },
+ });
+
+ return (
+ mutation.mutate()}
+ />
+ );
+}
+
+/**
+ * Quittieren for an open item that allows it, Zurücknehmen for an acknowledged
+ * one. Both need the server, so offline they are off with the visible reason.
+ */
+export function AcknowledgementActions({
+ item,
+ onDone,
+}: {
+ item: ReadinessItem;
+ /** Called with the text to announce once the change is saved and the report reloaded. */
+ onDone: (message: string) => void;
+}) {
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+ const [revokeError, setRevokeError] = useState(null);
+ const canAcknowledge = item.state === "OPEN" && item.acknowledgeable;
+ const acknowledgementId = item.state === "ACKNOWLEDGED" ? item.acknowledgement?.id : undefined;
+
+ if (!canAcknowledge && !acknowledgementId) return null;
+
+ return (
+
+ {canAcknowledge ? (
+
+ ) : null}
+ {acknowledgementId ? (
+
+ ) : null}
+
+
+ {revokeError ?
{revokeError}
: null}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/compliance/page.spec.tsx
new file mode 100644
index 0000000..9a86698
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/compliance/page.spec.tsx
@@ -0,0 +1,179 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import CompliancePage from "./page";
+import { downloadReadinessReport, fetchReadiness } from "@/lib/compliance/api";
+import { itemFixture, reportFixture } from "@/lib/compliance/fixtures";
+import { exportTreatmentLogPdf } from "@/lib/export/api";
+
+let permissions = ["compliance:read", "compliance:manage"];
+vi.mock("@/lib/auth/auth-context", () => ({
+ useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
+}));
+vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
+vi.mock("@/lib/compliance/api", () => ({
+ fetchReadiness: vi.fn(),
+ downloadReadinessReport: vi.fn(),
+ acknowledge: vi.fn(),
+ revokeAcknowledgement: vi.fn(),
+}));
+vi.mock("@/lib/export/api", () => ({ exportTreatmentLogPdf: vi.fn() }));
+
+const open = (overrides = {}) => itemFixture({ state: "OPEN", ...overrides });
+
+function renderPage() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("CompliancePage", () => {
+ beforeEach(() => {
+ permissions = ["compliance:read", "compliance:manage"];
+ vi.mocked(fetchReadiness).mockReset();
+ vi.mocked(exportTreatmentLogPdf).mockReset();
+ vi.mocked(downloadReadinessReport).mockReset();
+ });
+
+ it("shows Prüfbereit in green when nothing is open and offers Bestandsbuch drucken as the main action", async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([itemFixture({ state: "DONE", title: "Angaben vollständig" })]),
+ );
+ renderPage();
+
+ expect(await screen.findByText("Prüfbereit")).toBeInTheDocument();
+ const year = new Date().getFullYear();
+ const main = screen.getByRole("button", { name: `Bestandsbuch ${year} drucken` });
+ await userEvent.click(main);
+ expect(exportTreatmentLogPdf).toHaveBeenCalledWith(year);
+ expect(screen.queryByRole("link", { name: "Nächsten Punkt lösen" })).not.toBeInTheDocument();
+ });
+
+ it('shows "3 offen" in red with the symbol and the word Offen for every open item', async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([
+ open({ key: "a", title: "Punkt A" }),
+ open({ key: "b", title: "Punkt B" }),
+ open({ key: "c", title: "Punkt C" }),
+ ]),
+ );
+ renderPage();
+
+ const badge = await screen.findByText("3 offen");
+ expect(badge.closest("[data-slot=badge]")).toHaveClass("text-destructive");
+ expect(badge.parentElement?.querySelector("svg")).not.toBeNull();
+ const items = screen.getAllByRole("listitem").filter((li) => li.hasAttribute("data-item-key"));
+ expect(items).toHaveLength(3);
+ for (const item of items) {
+ expect(within(item).getByText("Offen")).toBeInTheDocument();
+ expect(item.querySelector("svg")).not.toBeNull();
+ expect(item).toHaveClass("border-destructive/40");
+ }
+ });
+
+ it("the main action jumps to the first open PFLICHT item", async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([
+ open({ key: "vet-office", severity: "PRUEFEN", area: "BEHOERDEN", title: "Veterinäramt fehlt" }),
+ open({ key: "holder-data", severity: "PFLICHT", subjects: [] }),
+ ]),
+ );
+ renderPage();
+
+ const main = await screen.findByRole("link", { name: "Nächsten Punkt lösen" });
+ expect(main).toHaveAttribute("href", "/settings/automation?tab=social-media");
+ });
+
+ it("shows the severity as a word, Pflicht or Prüfen, next to the title", async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([
+ open({ key: "holder-data", severity: "PFLICHT", title: "Bienenhalter fehlt" }),
+ open({ key: "vet-office", severity: "PRUEFEN", area: "BEHOERDEN", title: "Veterinäramt fehlt" }),
+ ]),
+ );
+ renderPage();
+
+ const holder = (await screen.findByRole("heading", { name: "Bienenhalter fehlt" })).closest("li");
+ const vet = screen.getByRole("heading", { name: "Veterinäramt fehlt" }).closest("li");
+ expect(within(holder as HTMLElement).getByText("Pflicht")).toBeInTheDocument();
+ expect(within(vet as HTMLElement).getByText("Prüfen")).toBeInTheDocument();
+ });
+
+ it("lists affected records collapsed with a Beheben link each", async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([
+ open({
+ key: "treatment-log-gaps",
+ area: "TIERARZNEIMITTEL",
+ title: "Lücken im Bestandsbuch",
+ subjects: [
+ { type: "treatment", id: "t1", parentId: "c1", label: "Behandlung 1", missing: ["Beleg"] },
+ { type: "lot", id: "l1", label: "Packung 1" },
+ ],
+ }),
+ ]),
+ );
+ renderPage();
+
+ const summary = await screen.findByText("2 betroffen");
+ expect(summary.closest("details")).not.toHaveAttribute("open");
+ const links = screen.getAllByRole("link", { name: /Beheben/, hidden: true });
+ expect(links.map((link) => link.getAttribute("href"))).toEqual([
+ "/colonies/c1",
+ "/medicines/lots/l1",
+ ]);
+ expect(screen.getByText(/Beleg/)).toBeInTheDocument();
+ });
+
+ it("shows Erledigt (n) collapsed", async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([
+ open({ key: "a" }),
+ itemFixture({ key: "b", state: "DONE", title: "Fertig B" }),
+ itemFixture({ key: "c", state: "DONE", title: "Fertig C" }),
+ ]),
+ );
+ renderPage();
+
+ const summary = await screen.findByText("Erledigt (2)");
+ expect(summary.closest("details")).not.toHaveAttribute("open");
+ });
+
+ it("shows the more-count when subjects are cut off", async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([
+ open({
+ key: "harvest-withdrawal",
+ area: "ERNTE",
+ subjects: [{ type: "harvest", id: "h1", label: "Ernte 1" }],
+ more: 7,
+ }),
+ ]),
+ );
+ renderPage();
+
+ expect(await screen.findByText("und 7 weitere")).toBeInTheDocument();
+ });
+
+ it("says why nothing can be shown when the report fails to load", async () => {
+ vi.mocked(fetchReadiness).mockRejectedValue(new Error("boom"));
+ renderPage();
+
+ expect(await screen.findByRole("button", { name: /Erneut versuchen/ })).toBeInTheDocument();
+ });
+
+ it("falls back to showing the item when the page that fixes it does not exist yet", async () => {
+ vi.mocked(fetchReadiness).mockResolvedValue(
+ reportFixture([open({ key: "vet-office", area: "BEHOERDEN", severity: "PFLICHT", subjectId: "global" })]),
+ );
+ renderPage();
+
+ const main = await screen.findByRole("link", { name: "Nächsten Punkt ansehen" });
+ expect(main).toHaveAttribute("href", "#item-vet-office-global");
+ expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/page.tsx b/ApiCore-Frontend/src/app/(app)/compliance/page.tsx
new file mode 100644
index 0000000..7015d7e
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/compliance/page.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import Link from "next/link";
+import { useMemo, useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import {
+ BookOpen,
+ CheckCircle2,
+ FileText,
+ Printer,
+ Settings,
+ ShieldCheck,
+ TriangleAlert,
+ XCircle,
+} from "lucide-react";
+import { DetailHeader, type MoreAction } from "@/components/detail-header";
+import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
+import { ErrorState, LoadingState } from "@/components/query-state";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/lib/auth/auth-context";
+import { downloadReadinessReport } from "@/lib/compliance/api";
+import { complianceErrorText } from "@/lib/compliance/errors";
+import { fixLinkFor } from "@/lib/compliance/fix-links";
+import { AREA_LABEL, AREA_ORDER } from "@/lib/compliance/labels";
+import type { ReadinessItem, ReadinessReport } from "@/lib/compliance/types";
+import { useReadiness } from "@/lib/compliance/use-readiness";
+import { exportTreatmentLogPdf } from "@/lib/export/api";
+import { ReadinessItemCard, cardId } from "./readiness-item-card";
+
+/** The first open item that is a duty; without one, the first open item. */
+function nextItemToSolve(report: ReadinessReport): ReadinessItem | null {
+ const open = report.items.filter((item) => item.state === "OPEN");
+ return open.find((item) => item.severity === "PFLICHT") ?? open[0] ?? null;
+}
+
+function StatusBadge({ report }: { report: ReadinessReport }) {
+ if (report.status === "READY") {
+ return (
+
+
+ Prüfbereit
+
+ );
+ }
+ return (
+
+
+ {report.openCount} offen
+
+ );
+}
+
+function AreaSection({ area, items, canManage }: { area: (typeof AREA_ORDER)[number]; items: ReadinessItem[]; canManage: boolean }) {
+ const headingId = `area-${area}`;
+ return (
+
+
+ {AREA_LABEL[area]}
+
+ {items.length === 0 ? (
+ Hier ist nichts offen.
+ ) : (
+
+ {items.map((item) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function CompliancePage() {
+ const { hasPermission } = useAuth();
+ const query = useReadiness();
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+ const [actionError, setActionError] = useState(null);
+ const year = new Date().getFullYear();
+
+ const treatmentLog = useMutation({
+ mutationFn: () => exportTreatmentLogPdf(year),
+ onMutate: () => setActionError(null),
+ onError: (error) => setActionError(complianceErrorText(error, "Das Bestandsbuch konnte nicht geladen werden.")),
+ });
+ const report = useMutation({
+ mutationFn: downloadReadinessReport,
+ onMutate: () => setActionError(null),
+ onError: (error) => setActionError(complianceErrorText(error, "Der Bericht konnte nicht geladen werden.")),
+ });
+
+ const data = query.data;
+ const grouped = useMemo(() => {
+ const items = data?.items ?? [];
+ return {
+ byArea: AREA_ORDER.map((area) => ({
+ area,
+ items: items.filter((item) => item.area === area && item.state !== "DONE"),
+ })),
+ done: items.filter((item) => item.state === "DONE"),
+ };
+ }, [data]);
+
+ const header = (
+ : undefined}
+ primaryAction={data ? primaryAction(data) : undefined}
+ moreActions={moreActions()}
+ />
+ );
+
+ function primaryAction(current: ReadinessReport) {
+ const next = current.status === "OPEN" ? nextItemToSolve(current) : null;
+ if (next) {
+ const fix = fixLinkFor(next.key, next.subjects[0] ?? null);
+ // Without a page to fix it on, the button shows the item on this page.
+ return (
+ }>
+
+ {fix.available ? "Nächsten Punkt lösen" : "Nächsten Punkt ansehen"}
+
+ );
+ }
+ return (
+ treatmentLog.mutate()}
+ >
+
+ {`Bestandsbuch ${year} drucken`}
+
+ );
+ }
+
+ function moreActions(): MoreAction[] {
+ const actions: MoreAction[] = [
+ { label: "Bericht (PDF)", icon: FileText, onSelect: () => report.mutate(), disabled: isOffline || report.isPending },
+ ];
+ if (data?.status === "OPEN") {
+ actions.push({
+ label: `Bestandsbuch ${year} drucken`,
+ icon: Printer,
+ onSelect: () => treatmentLog.mutate(),
+ disabled: isOffline || treatmentLog.isPending,
+ });
+ }
+ actions.push(
+ { label: "Anzeigen ans Veterinäramt", icon: BookOpen, href: "/compliance/notices" },
+ { label: "Sperrbezirke", icon: TriangleAlert, href: "/compliance/restrictions" },
+ { label: "Einstellungen", icon: Settings, href: "/compliance/settings" },
+ );
+ return actions;
+ }
+
+ return (
+
+ {header}
+
+
+
+ {actionError ?
{actionError}
: null}
+
+
+ {query.isLoading ?
: null}
+ {!query.isLoading && data === undefined ? (
+
void query.refetch()} isRetrying={query.isFetching} />
+ ) : null}
+ {data ? (
+ <>
+ {grouped.byArea.map(({ area, items }) => (
+
+ ))}
+ {grouped.done.length > 0 ? (
+
+
+ Erledigt ({grouped.done.length})
+
+
+ {grouped.done.map((item) => (
+
+ ))}
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.spec.tsx
new file mode 100644
index 0000000..35efd51
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.spec.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReadinessItemCard } from "./readiness-item-card";
+import { acknowledge } from "@/lib/compliance/api";
+import { itemFixture } from "@/lib/compliance/fixtures";
+import type { ReadinessItem } from "@/lib/compliance/types";
+
+vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
+vi.mock("@/lib/compliance/api", () => ({ acknowledge: vi.fn(), revokeAcknowledgement: vi.fn() }));
+
+function renderCard(item: ReadinessItem, canManage = true) {
+ const client = new QueryClient();
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ReadinessItemCard", () => {
+ it("shows the open item red with symbol, word, severity and the legal basis small", () => {
+ renderCard(itemFixture({ legalBasis: "Art. 108 VO (EU) 2019/6" }));
+
+ const card = screen.getByRole("listitem");
+ expect(card).toHaveClass("border-destructive/40");
+ expect(screen.getByText("Offen")).toBeInTheDocument();
+ expect(screen.getByText("Pflicht")).toBeInTheDocument();
+ expect(screen.getByText("Art. 108 VO (EU) 2019/6")).toHaveClass("text-xs");
+ expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute(
+ "href",
+ "/settings/automation?tab=social-media",
+ );
+ });
+
+ it("shows only the count when the caller may not see the subjects", () => {
+ renderCard(itemFixture({ key: "restriction-active", subjects: [], more: 3 }));
+
+ expect(screen.getByText("3 betroffen")).toBeInTheDocument();
+ });
+
+ it("shows an acknowledged item calm, with who wrote which note", () => {
+ renderCard(
+ itemFixture({
+ state: "ACKNOWLEDGED",
+ acknowledgeable: true,
+ acknowledgement: {
+ id: "a1",
+ note: "Mit dem Amt abgesprochen.",
+ userName: "Nick",
+ createdAt: "2026-10-01T10:00:00.000Z",
+ },
+ }),
+ );
+
+ const card = screen.getByRole("listitem");
+ expect(card).not.toHaveClass("border-destructive/40");
+ expect(screen.getByText("Quittiert")).toBeInTheDocument();
+ expect(screen.getByText(/Mit dem Amt abgesprochen\./)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Zurücknehmen" })).toBeInTheDocument();
+ });
+
+ it("hides the acknowledge actions without compliance:manage", () => {
+ renderCard(itemFixture({ acknowledgeable: true }), false);
+
+ expect(screen.queryByRole("button", { name: "Quittieren" })).not.toBeInTheDocument();
+ });
+
+ it("moves focus to the heading and announces Punkt quittiert after acknowledging", async () => {
+ vi.mocked(acknowledge).mockResolvedValue(undefined);
+ renderCard(itemFixture({ acknowledgeable: true, title: "Wichtiger Punkt" }));
+
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
+ await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
+ await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
+
+ await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Punkt quittiert"));
+ expect(screen.getByRole("heading", { name: "Wichtiger Punkt" })).toHaveFocus();
+ });
+
+ it("hides Beheben while the target page does not exist", () => {
+ renderCard(itemFixture({ key: "vet-office", area: "BEHOERDEN" }));
+
+ expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.tsx b/ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.tsx
new file mode 100644
index 0000000..cbb728f
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/compliance/readiness-item-card.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import Link from "next/link";
+import { useRef, useState } from "react";
+import { ArrowRight, CheckCircle2, ClipboardCheck, XCircle, type LucideIcon } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { fixLinkFor } from "@/lib/compliance/fix-links";
+import { SEVERITY_LABEL, itemTone } from "@/lib/compliance/labels";
+import type { ReadinessItem, ReadinessItemState, ReadinessSubject } from "@/lib/compliance/types";
+import { cn } from "@/lib/utils";
+import { AcknowledgementActions } from "./acknowledge-dialog";
+
+const STATE_ICON: Record = {
+ OPEN: XCircle,
+ DONE: CheckCircle2,
+ ACKNOWLEDGED: ClipboardCheck,
+};
+
+const FIX_LINK_CLASS =
+ "inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
+
+function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
+ const { href, label, available } = fixLinkFor(itemKey, subject);
+ // Hidden while the target page does not exist (see ROUTE_AVAILABLE).
+ if (!available) return null;
+ return (
+
+ {label}
+ : {context}
+
+
+ );
+}
+
+function SubjectList({ item }: { item: ReadinessItem }) {
+ const total = item.subjects.length + item.more;
+ if (item.subjects.length === 0) {
+ // Without the right to see names only the count is left.
+ return item.more > 0 ? {item.more} betroffen
: null;
+ }
+ return (
+
+
+ {total} betroffen
+
+
+ {item.subjects.map((subject) => (
+
+
+ {subject.label}
+ {subject.missing && subject.missing.length > 0 ? (
+ – es fehlt: {subject.missing.join(", ")}
+ ) : null}
+
+
+
+ ))}
+
+ {item.more > 0 ? und {item.more} weitere
: null}
+
+ );
+}
+
+/** Anchor of a card, so the main action can point at an item whose page is not there yet. */
+export function cardId(item: Pick): string {
+ return `item-${item.key}-${item.subjectId}`;
+}
+
+function formatDay(iso: string): string {
+ return new Date(iso).toLocaleDateString("de-DE");
+}
+
+interface ReadinessItemCardProps {
+ item: ReadinessItem;
+ /** compliance:manage - acknowledging and revoking. */
+ canManage: boolean;
+}
+
+export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
+ const { tone, word } = itemTone(item.state);
+ const StateIcon = STATE_ICON[item.state];
+ const isOpen = item.state === "OPEN";
+ const { acknowledgement } = item;
+ const headingRef = useRef(null);
+ const [announcement, setAnnouncement] = useState("");
+
+ // After saving, the action buttons may be gone: focus moves to the heading.
+ function handleDone(message: string) {
+ setAnnouncement(message);
+ headingRef.current?.focus();
+ }
+
+ return (
+
+
+
+
+ {word}
+
+
+ {SEVERITY_LABEL[item.severity]}
+
+
+ {item.title}
+
+
+ {item.detail}
+ {item.legalBasis ? {item.legalBasis}
: null}
+ {acknowledgement ? (
+
+ {acknowledgement.userName}, {formatDay(acknowledgement.createdAt)}: {acknowledgement.note}
+
+ ) : null}
+ {isOpen ? : null}
+
+ {isOpen && item.subjects.length === 0 ? (
+
+ ) : null}
+ {canManage ?
: null}
+
+
+ {announcement}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx
index 0c17ee9..f27aa1f 100644
--- a/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx
@@ -29,6 +29,9 @@ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
fetchAutopilotToday: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
+vi.mock("@/lib/compliance/api", () => ({
+ fetchReadinessSummary: vi.fn().mockResolvedValue({ status: "READY", openCount: 0, firstItemKey: null }),
+}));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx
index e8acecd..9ec76fd 100644
--- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx
@@ -26,6 +26,7 @@ import { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { StandNote } from "@/components/offline/stand-note";
+import { ComplianceLine } from "@/components/today/compliance-line";
import { TodayPanel } from "@/components/today/today-panel";
function alertHref(alert: DashboardAlert): string | null {
@@ -191,6 +192,8 @@ export default function DashboardPage() {
) : null}
+
+
({ createLot: vi.fn(), updateLot: vi.fn() }));
+
+function renderDialog(ui: React.ReactElement) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render({ui} );
+}
+
+describe("LotFormDialog", () => {
+ beforeEach(() => {
+ vi.mocked(createLot).mockReset();
+ vi.mocked(updateLot).mockReset();
+ act(() => reportRequestOutcome("reached-server"));
+ });
+
+ it("books a lot with an Idempotency-Key and prefills the default supplier", async () => {
+ vi.mocked(createLot).mockResolvedValue(lot());
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+
+ expect(await screen.findByLabelText(/Lieferant/)).toHaveValue("Tierarzt Muster");
+ await userEvent.type(screen.getByLabelText(/Menge/), "20");
+ await userEvent.type(screen.getByLabelText(/Chargennummer/), "CH-42");
+ await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
+
+ expect(createLot).toHaveBeenCalledWith(
+ expect.objectContaining({
+ medicineId: "m1",
+ quantity: 20,
+ batchNumber: "CH-42",
+ supplier: "Tierarzt Muster",
+ purchasedOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
+ }),
+ { idempotencyKey: expect.any(String) },
+ );
+ });
+
+ it("labels the quantity with the unit of the medicine", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+
+ expect(await screen.findByLabelText(/^Menge \(ml\)/)).toBeInTheDocument();
+ });
+
+ it("needs a positive quantity", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Erfassen" }));
+
+ expect(await screen.findByText("Bitte eine Menge über 0 eingeben.")).toBeInTheDocument();
+ expect(createLot).not.toHaveBeenCalled();
+ });
+
+ it("shows the server message for a unit mismatch or an inactive medicine", async () => {
+ vi.mocked(createLot).mockRejectedValue(new ApiError(400, "Das Mittel ist deaktiviert.", { message: "Das Mittel ist deaktiviert." }));
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+ await userEvent.type(await screen.findByLabelText(/Menge/), "5");
+ await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Das Mittel ist deaktiviert.");
+ });
+
+ it("edits a lot and clears an emptied optional field with null", async () => {
+ vi.mocked(updateLot).mockResolvedValue(lot());
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
+ await userEvent.clear(await screen.findByLabelText(/Lieferant/));
+ await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
+
+ expect(updateLot).toHaveBeenCalledWith(
+ "l1",
+ expect.objectContaining({ supplier: null, quantity: 20, batchNumber: "CH-42" }),
+ );
+ expect(updateLot).toHaveBeenCalledWith("l1", expect.not.objectContaining({ medicineId: expect.anything() }));
+ });
+
+ it("is off offline, with the visible reason", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+ await screen.findByRole("button", { name: "Erfassen" });
+ act(() => reportRequestOutcome("network-error"));
+
+ expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Erfassen" })).toBeDisabled();
+ });
+
+ it("shows the message of a 409 in the dialog", async () => {
+ vi.mocked(createLot).mockRejectedValue(new ApiError(409, "Die Packung wurde gerade geändert. Bitte neu laden.", {}));
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+ await userEvent.type(await screen.findByLabelText(/Menge/), "5");
+ await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Die Packung wurde gerade geändert. Bitte neu laden.");
+ });
+
+ it("refuses an ambiguous thousands dot in the quantity", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+ await userEvent.type(await screen.findByLabelText(/Menge/), "1.000");
+ await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
+
+ expect(
+ await screen.findByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5"),
+ ).toBeInTheDocument();
+ expect(createLot).not.toHaveBeenCalled();
+ });
+
+ it("renews the Idempotency-Key after a 409 and when the values change after a network error", async () => {
+ vi.mocked(createLot)
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockRejectedValueOnce(new ApiError(409, "Gerade geändert.", {}))
+ .mockResolvedValue(lot());
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
+ const quantity = await screen.findByLabelText(/Menge/);
+ await userEvent.type(quantity, "5");
+ const submit = screen.getByRole("button", { name: "Erfassen" });
+
+ await userEvent.click(submit); // network error
+ await userEvent.click(submit); // unchanged retry: same key, network error again
+ await userEvent.type(quantity, "0"); // changed body
+ await userEvent.click(submit); // 409
+ await userEvent.click(submit); // unchanged retry after a server answer
+
+ const keys = vi.mocked(createLot).mock.calls.map((call) => call[1]?.idempotencyKey);
+ expect(keys[1]).toBe(keys[0]);
+ expect(keys[2]).not.toBe(keys[1]);
+ expect(keys[3]).not.toBe(keys[2]);
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/[id]/lot-form-dialog.tsx b/ApiCore-Frontend/src/app/(app)/medicines/[id]/lot-form-dialog.tsx
new file mode 100644
index 0000000..7a37bbe
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/[id]/lot-form-dialog.tsx
@@ -0,0 +1,294 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { createLot, updateLot } from "@/lib/medicines/api";
+import { decimalMessage, MEDICINE_UNIT_LABELS, parseDecimal } from "@/lib/medicines/format";
+import type { LotInput, LotView, MedicineView } from "@/lib/medicines/types";
+import { todayLocalIsoDate } from "@/lib/dates";
+import { emptyToNull } from "@/lib/forms/empty-to-null";
+import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
+import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { Field } from "@/components/form/field";
+import { FormSection } from "@/components/form/form-section";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type LotMedicine = Pick &
+ Partial>;
+
+interface LotFormDialogProps {
+ medicine: LotMedicine;
+ /** Edit this lot; without it the dialog records a new one. */
+ lot?: LotView;
+ /** Switched off from outside, e.g. offline or an inactive medicine; `describedBy` names the reason. */
+ disabled?: boolean;
+ describedBy?: string;
+}
+
+interface LotFormValues {
+ batchNumber: string;
+ purchasedOn: string;
+ quantity: string;
+ expiresOn: string;
+ supplier: string;
+ purchaseProof: string;
+ prescribingVet: string;
+ notes: string;
+}
+
+type LotFormErrors = Partial>;
+
+const INPUT_CLASS = "h-11 sm:h-11";
+
+function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
+ if (lot) {
+ return {
+ batchNumber: lot.batchNumber ?? "",
+ purchasedOn: lot.purchasedOn,
+ quantity: String(lot.quantity),
+ expiresOn: lot.expiresOn ?? "",
+ supplier: lot.supplier ?? "",
+ purchaseProof: lot.purchaseProof ?? "",
+ prescribingVet: lot.prescribingVet ?? "",
+ notes: lot.notes ?? "",
+ };
+ }
+ return {
+ batchNumber: "",
+ purchasedOn: todayLocalIsoDate(),
+ quantity: "",
+ expiresOn: "",
+ supplier: medicine.defaultSupplier ?? "",
+ purchaseProof: "",
+ prescribingVet: "",
+ notes: "",
+ };
+}
+
+function errorsOf(values: LotFormValues): LotFormErrors {
+ const errors: LotFormErrors = {};
+ if (values.purchasedOn === "") errors.purchasedOn = "Bitte das Kaufdatum angeben.";
+ const quantity = parseDecimal(values.quantity);
+ if (!Number.isFinite(quantity) || quantity <= 0) errors.quantity = decimalMessage(values.quantity, "Bitte eine Menge über 0 eingeben.");
+ return errors;
+}
+
+function toPatch(values: LotFormValues): Omit {
+ return {
+ batchNumber: emptyToNull(values.batchNumber),
+ purchasedOn: values.purchasedOn,
+ quantity: parseDecimal(values.quantity),
+ expiresOn: emptyToNull(values.expiresOn),
+ supplier: emptyToNull(values.supplier),
+ purchaseProof: emptyToNull(values.purchaseProof),
+ prescribingVet: emptyToNull(values.prescribingVet),
+ notes: emptyToNull(values.notes),
+ };
+}
+
+/**
+ * Record or correct one bought pack. Batch number, supplier, proof and vet are
+ * entered here once and apply to every treatment taken from the pack.
+ */
+export function LotFormDialog({ medicine, lot, disabled = false, describedBy }: LotFormDialogProps) {
+ const [open, setOpen] = useState(false);
+ const isEdit = lot !== undefined;
+ return (
+
+
+ {isEdit ? "Bearbeiten" : "Packung erfassen"}
+
+ }
+ />
+
+
+ {isEdit ? "Packung bearbeiten" : "Packung erfassen"}
+ {medicine.name}
+
+ setOpen(false)} />
+
+
+ );
+}
+
+function LotForm({ medicine, lot, onDone }: { medicine: LotMedicine; lot?: LotView; onDone: () => void }) {
+ const queryClient = useQueryClient();
+ const isEdit = lot !== undefined;
+ const unitLabel = MEDICINE_UNIT_LABELS[medicine.unit];
+ const [values, setValues] = useState(() => initialValues(medicine, lot));
+ const [localErrors, setLocalErrors] = useState({});
+ const [saveError, setSaveError] = useState(null);
+ const attempt = useAttemptKey();
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+
+ const set = (key: keyof LotFormValues, value: string) =>
+ setValues((current) => ({ ...current, [key]: value }));
+ const errorOf = (key: keyof LotFormValues) => localErrors[key] ?? saveError?.fields[key] ?? null;
+
+ const mutation = useMutation({
+ mutationFn: () => {
+ if (lot) return updateLot(lot.id, toPatch(values));
+ const input = { medicineId: medicine.id, ...toPatch(values) };
+ return createLot(input, { idempotencyKey: attempt.keyFor(input) });
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["medicines"] });
+ void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
+ toast.success(isEdit ? "Packung gespeichert" : "Packung erfasst");
+ attempt.done();
+ onDone();
+ },
+ onError: (error) => {
+ attempt.failed(error);
+ setSaveError(
+ describeSaveError(error, isEdit ? "Die Packung wurde nicht gespeichert." : "Die Packung wurde nicht erfasst."),
+ );
+ },
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setSaveError(null);
+ const errors = errorsOf(values);
+ setLocalErrors(errors);
+ if (Object.keys(errors).length > 0 || isOffline) return;
+ mutation.mutate();
+ }
+
+ return (
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("batchNumber", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy, invalid }) => (
+ set("purchasedOn", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy, invalid }) => (
+ set("quantity", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("expiresOn", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("supplier", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("purchaseProof", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("prescribingVet", event.target.value)}
+ />
+ )}
+
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("notes", event.target.value)}
+ />
+ )}
+
+
+ {saveError?.general ? (
+
+ {saveError.general}
+
+ ) : null}
+
+
+ {mutation.isPending ? "Speichert…" : isEdit ? "Speichern" : "Erfassen"}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/[id]/page.spec.tsx
new file mode 100644
index 0000000..99ae5ad
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/[id]/page.spec.tsx
@@ -0,0 +1,104 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import MedicineDetailPage from "./page";
+import { useAuth } from "@/lib/auth/auth-context";
+import { fetchMedicine } from "@/lib/medicines/api";
+import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
+
+vi.mock("next/navigation", () => ({ useParams: () => ({ id: "m1" }) }));
+vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
+vi.mock("@/lib/medicines/api", () => ({
+ fetchMedicine: vi.fn(),
+ createLot: vi.fn(),
+ updateLot: vi.fn(),
+ updateMedicine: vi.fn(),
+ setMedicineActive: vi.fn(),
+}));
+
+function renderPage(permissions: string[]) {
+ vi.mocked(useAuth).mockReturnValue({
+ hasPermission: (permission: string) => permissions.includes(permission),
+ } as never);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+const ALL = ["veterinary-medicines:read", "veterinary-medicines:create", "veterinary-medicines:update"];
+
+describe("MedicineDetailPage", () => {
+ beforeEach(() => {
+ vi.mocked(fetchMedicine).mockReset();
+ vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine(), lots: [lot()] });
+ });
+
+ it("the main action in the header is Packung erfassen", async () => {
+ const { container } = renderPage(ALL);
+
+ await screen.findByRole("heading", { name: "Apivar" });
+ const header = container.querySelector("header") as HTMLElement;
+ expect(within(header).getByRole("button", { name: /Packung erfassen/ })).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: /Packung erfassen/ })).toHaveLength(1);
+ });
+
+ it("lists the lots with batch number, rest and expiry as links to the lot", async () => {
+ renderPage(ALL);
+
+ const link = await screen.findByRole("link", { name: /CH-42/ });
+ expect(link).toHaveAttribute("href", "/medicines/lots/l1");
+ expect(link).toHaveTextContent("12 Stück");
+ expect(link).toHaveTextContent("31.01.2028");
+ });
+
+ it("shows a negative lot rest in red with the word Negativ", async () => {
+ vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine(), lots: [lot({ remaining: -3 })] });
+ renderPage(ALL);
+
+ const negative = await screen.findByText(/Negativ/);
+ expect(negative.closest("span.text-destructive")).not.toBeNull();
+ });
+
+ it("hides create buttons without veterinary-medicines:create", async () => {
+ renderPage(["veterinary-medicines:read", "veterinary-medicines:update"]);
+
+ await screen.findByRole("heading", { name: "Apivar" });
+ expect(screen.queryByRole("button", { name: /Packung erfassen/ })).not.toBeInTheDocument();
+ });
+
+ it("hides editing without veterinary-medicines:update", async () => {
+ renderPage(["veterinary-medicines:read"]);
+
+ await screen.findByRole("heading", { name: "Apivar" });
+ expect(screen.queryByRole("button", { name: "Bearbeiten" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Deaktivieren/ })).not.toBeInTheDocument();
+ });
+
+ it("gives an inactive medicine no new lots and says why", async () => {
+ vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine({ active: false }), lots: [] });
+ renderPage(ALL);
+
+ const button = await screen.findByRole("button", { name: /Packung erfassen/ });
+ expect(button).toBeDisabled();
+ expect(button).toHaveAccessibleDescription("Das Mittel ist deaktiviert.");
+ expect(screen.getByRole("button", { name: "Aktivieren" })).toBeInTheDocument();
+ });
+
+ it("shows the empty state of a medicine without lots", async () => {
+ vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine({ lotCount: 0, totalRemaining: 0 }), lots: [] });
+ renderPage(ALL);
+
+ expect(await screen.findByText("Noch keine Packung")).toBeInTheDocument();
+ });
+
+ it("shows no data and no retry without veterinary-medicines:read", async () => {
+ renderPage([]);
+
+ expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
+ expect(fetchMedicine).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/medicines/[id]/page.tsx
new file mode 100644
index 0000000..a0a5814
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/[id]/page.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useId, type ReactNode } from "react";
+import { useParams } from "next/navigation";
+import { Package, Pill } from "lucide-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { useAuth } from "@/lib/auth/auth-context";
+import { fetchMedicine, setMedicineActive } from "@/lib/medicines/api";
+import { formatDay, formatQuantity, MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
+import type { LotView, MedicineView } from "@/lib/medicines/types";
+import { saveErrorText } from "@/lib/honey-batches/errors";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
+import { EmptyState } from "@/components/empty-state";
+import { EntityCard } from "@/components/entity-card";
+import { RestText } from "@/components/medicines/rest-text";
+import { NoReadAccess } from "@/components/medicines/no-read-access";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { MedicineFormDialog } from "../medicine-form-dialog";
+import { LotFormDialog } from "./lot-form-dialog";
+
+const BACK = { backHref: "/medicines", backLabel: "Alle Tierarzneimittel" };
+const INACTIVE_REASON = "Das Mittel ist deaktiviert.";
+
+export default function MedicineDetailPage() {
+ const params = useParams<{ id: string }>();
+ const { hasPermission } = useAuth();
+ const queryClient = useQueryClient();
+ const { isOffline, hintId } = useOnlineOnly();
+ const inactiveId = useId();
+ const query = useQuery({
+ queryKey: ["medicines", params.id],
+ queryFn: () => fetchMedicine(params.id),
+ enabled: hasPermission("veterinary-medicines:read"),
+ });
+ const medicine = query.data;
+
+ const activeMutation = useMutation({
+ mutationFn: (active: boolean) => setMedicineActive(params.id, active),
+ onSuccess: (_result, active) => {
+ void queryClient.invalidateQueries({ queryKey: ["medicines"] });
+ toast.success(active ? "Mittel aktiviert" : "Mittel deaktiviert");
+ },
+ onError: (error) => toast.error(saveErrorText(error, "Das Mittel wurde nicht geändert.")),
+ });
+
+ if (!hasPermission("veterinary-medicines:read")) return ;
+
+ if (query.isLoading || query.isError || !medicine) {
+ return ;
+ }
+
+ const canCreate = hasPermission("veterinary-medicines:create");
+ const canUpdate = hasPermission("veterinary-medicines:update");
+ const newLotReason = [isOffline ? hintId : null, medicine.active ? null : inactiveId]
+ .filter((id): id is string => id !== null)
+ .join(" ");
+
+ return (
+
+
+ }
+ primaryAction={
+ canCreate ? (
+
+ ) : undefined
+ }
+ {...BACK}
+ />
+ {!medicine.active ? (
+
+ {INACTIVE_REASON}
+
+ ) : null}
+ {canUpdate || canCreate ? : null}
+
+
+
+
+ Angaben
+ {canUpdate ? (
+
+
+ {medicine.active ? (
+ activeMutation.mutate(false)}
+ isPending={activeMutation.isPending}
+ />
+ ) : (
+ activeMutation.mutate(true)}
+ >
+ Aktivieren
+
+ )}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ Packungen
+
+ {medicine.lots.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function MedicineFacts({ medicine }: { medicine: MedicineView }) {
+ const facts: { label: string; value: ReactNode }[] = [
+ { label: "Rest", value: },
+ { label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) },
+ {
+ label: "Wartezeit Honig",
+ value: medicine.withdrawalPeriodDays === null ? "—" : `${medicine.withdrawalPeriodDays} Tage`,
+ },
+ { label: "Verschreibungspflichtig", value: medicine.prescriptionOnly ? "Ja" : "Nein" },
+ { label: "Zulassungsnummer", value: medicine.authorisationNumber ?? "—" },
+ {
+ label: "Dosis je Volk",
+ value: medicine.dosePerColony === null ? "—" : formatQuantity(medicine.dosePerColony, medicine.unit),
+ },
+ { label: "Dosis-Hinweis", value: medicine.doseNote ?? "—" },
+ { label: "Anwendung", value: medicine.applicationMethod ?? "—" },
+ { label: "Lagertemperatur", value: temperatureWindow(medicine) },
+ { label: "Üblicher Lieferant", value: medicine.defaultSupplier ?? "—" },
+ { label: "Notizen", value: medicine.notes ?? "—" },
+ ];
+ return (
+
+ {facts.map((fact) => (
+
+
{fact.label}
+ {fact.value}
+
+ ))}
+
+ );
+}
+
+function temperatureWindow(medicine: MedicineView): string {
+ const { minTemperatureC: min, maxTemperatureC: max } = medicine;
+ if (min === null && max === null) return "—";
+ if (min !== null && max !== null) return `${min} bis ${max} °C`;
+ return min !== null ? `ab ${min} °C` : `bis ${max} °C`;
+}
+
+function LotGrid({ lots }: { lots: LotView[] }) {
+ return (
+
+ {lots.map((lot) => (
+ },
+ { label: "Haltbar bis", value: formatDay(lot.expiresOn) },
+ ]}
+ />
+ ))}
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.spec.tsx
new file mode 100644
index 0000000..2f50b79
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.spec.tsx
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { DisposalDialog } from "./disposal-dialog";
+import { bookDisposal } from "@/lib/medicines/api";
+import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
+import { ApiError } from "@/lib/auth/api-client";
+import { reportRequestOutcome } from "@/lib/offline/connectivity";
+
+vi.mock("@/lib/medicines/api", () => ({ bookDisposal: vi.fn() }));
+
+function renderDialog() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+async function fill(quantity: string, reason: string) {
+ await userEvent.click(screen.getByRole("button", { name: "Entsorgung" }));
+ if (quantity) await userEvent.type(await screen.findByLabelText(/^Menge/), quantity);
+ if (reason) await userEvent.type(await screen.findByLabelText(/^Grund/), reason);
+}
+
+describe("DisposalDialog", () => {
+ beforeEach(() => {
+ vi.mocked(bookDisposal).mockReset();
+ act(() => reportRequestOutcome("reached-server"));
+ });
+
+ it("Entsorgung needs a reason", async () => {
+ renderDialog();
+ await fill("2", "");
+
+ expect(screen.getByText("Bitte einen Grund angeben.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
+ expect(bookDisposal).not.toHaveBeenCalled();
+ });
+
+ it("a reason of blanks is no reason", async () => {
+ renderDialog();
+ await fill("2", " ");
+
+ expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
+ });
+
+ it("needs a positive quantity", async () => {
+ renderDialog();
+ await fill("", "Abgelaufen");
+
+ expect(screen.getByText("Bitte eine Menge eingeben.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
+ });
+
+ it("books quantity and trimmed reason with an Idempotency-Key", async () => {
+ vi.mocked(bookDisposal).mockResolvedValue(lotDetail());
+ renderDialog();
+ await fill("2,5", " Abgelaufen ");
+ await userEvent.click(screen.getByRole("button", { name: "Entsorgung buchen" }));
+
+ expect(bookDisposal).toHaveBeenCalledWith("l1", 2.5, "Abgelaufen", {
+ idempotencyKey: expect.any(String),
+ });
+ });
+
+ it("lets more than the rest through (a negative rest is allowed) but says so", async () => {
+ renderDialog();
+ await fill("20", "Verschüttet");
+
+ expect(screen.getByText(/mehr als der Rest/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeEnabled();
+ });
+
+ it("shows the server message of a failed booking", async () => {
+ vi.mocked(bookDisposal).mockRejectedValue(new ApiError(400, "Die Menge ist zu klein", { message: "Die Menge ist zu klein" }));
+ renderDialog();
+ await fill("0,0001", "Test");
+ await userEvent.click(screen.getByRole("button", { name: "Entsorgung buchen" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Die Menge ist zu klein");
+ });
+
+ it("marks the negative-rest warning with a symbol, the word Negativ and red text", async () => {
+ renderDialog();
+ await fill("20", "Verschüttet");
+
+ const warning = screen.getByText(/mehr als der Rest/i);
+ expect(warning.closest("span.text-destructive")).not.toBeNull();
+ expect(warning).toHaveTextContent("Negativ");
+ });
+
+ it("refuses an ambiguous thousands dot", async () => {
+ renderDialog();
+ await fill("1.000", "Abgelaufen");
+
+ expect(screen.getByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx
new file mode 100644
index 0000000..6adb075
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+import { AlertTriangle, Trash2 } from "lucide-react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { bookDisposal } from "@/lib/medicines/api";
+import { decimalMessage, formatQuantity, MEDICINE_UNIT_LABELS, parseDecimal } from "@/lib/medicines/format";
+import type { MedicineUnit } from "@/lib/medicines/types";
+import { saveErrorText } from "@/lib/honey-batches/errors";
+import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+interface DisposalDialogProps {
+ lotId: string;
+ remaining: number;
+ unit: MedicineUnit;
+ /** Switched off from outside, e.g. offline; `describedBy` names the reason. */
+ disabled?: boolean;
+ describedBy?: string;
+}
+
+/** „Entsorgung“: expired or spilled medicine leaves the lot, always with a reason. */
+export function DisposalDialog({
+ lotId,
+ remaining,
+ unit,
+ disabled = false,
+ describedBy: triggerDescribedBy,
+}: DisposalDialogProps) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+ Entsorgung
+
+ }
+ />
+
+
+ Entsorgung
+
+ setOpen(false)} />
+
+
+ );
+}
+
+interface DisposalFormProps {
+ lotId: string;
+ remaining: number;
+ unit: MedicineUnit;
+ onDone: () => void;
+}
+
+function problemWith(quantityText: string, reason: string): string | null {
+ const quantity = parseDecimal(quantityText);
+ if (quantityText.trim() === "" || !Number.isFinite(quantity)) {
+ return decimalMessage(quantityText, "Bitte eine Menge eingeben.");
+ }
+ if (quantity <= 0) return "Die Menge muss größer als 0 sein.";
+ if (reason.trim() === "") return "Bitte einen Grund angeben.";
+ return null;
+}
+
+function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
+ const queryClient = useQueryClient();
+ const [quantityText, setQuantityText] = useState("");
+ const [reason, setReason] = useState("");
+ const [error, setError] = useState(null);
+ const attempt = useAttemptKey();
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+
+ const problem = problemWith(quantityText, reason);
+ const quantity = parseDecimal(quantityText);
+ const exceedsRest = problem === null && quantity > remaining;
+ const note = problem ?? "";
+
+ const mutation = useMutation({
+ mutationFn: () => bookDisposal(lotId, quantity, reason.trim(), {
+ idempotencyKey: attempt.keyFor({ quantity, reason: reason.trim() }),
+ }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
+ void queryClient.invalidateQueries({ queryKey: ["medicines"] });
+ toast.success(`${formatQuantity(quantity, unit)} entsorgt`);
+ attempt.done();
+ onDone();
+ },
+ onError: (mutationError) => {
+ attempt.failed(mutationError);
+ setError(saveErrorText(mutationError, "Die Entsorgung wurde nicht gebucht."));
+ },
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setError(null);
+ if (problem !== null || isOffline) return;
+ mutation.mutate();
+ }
+
+ return (
+
+ Noch da: {formatQuantity(remaining, unit)}.
+
+ Menge ({MEDICINE_UNIT_LABELS[unit]})
+ setQuantityText(event.target.value)}
+ />
+
+
+ Grund
+ setReason(event.target.value)}
+ />
+
+
+ {note}
+ {exceedsRest ? (
+
+
+ Negativ: Das ist mehr als der Rest ({formatQuantity(remaining, unit)}). Der Rest wird negativ.
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {mutation.isPending ? "Bucht…" : "Entsorgung buchen"}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.spec.tsx
new file mode 100644
index 0000000..5b5ea6c
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.spec.tsx
@@ -0,0 +1,143 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { InventoryDialog } from "./inventory-dialog";
+import { bookCorrection } from "@/lib/medicines/api";
+import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
+import { ApiError } from "@/lib/auth/api-client";
+import { reportRequestOutcome } from "@/lib/offline/connectivity";
+
+vi.mock("@/lib/medicines/api", () => ({ bookCorrection: vi.fn() }));
+
+function renderDialog(remaining = 12) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+async function open() {
+ await userEvent.click(screen.getByRole("button", { name: "Inventur" }));
+}
+
+describe("InventoryDialog", () => {
+ beforeEach(() => {
+ vi.mocked(bookCorrection).mockReset();
+ act(() => reportRequestOutcome("reached-server"));
+ });
+
+ it("asks for the actual rest and shows the delta before saving", async () => {
+ renderDialog();
+ await open();
+
+ const input = await screen.findByLabelText("Tatsächlicher Rest (Stück)");
+ expect(input).toHaveValue("12");
+ expect(screen.getByText("Keine Abweichung")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
+
+ await userEvent.clear(input);
+ await userEvent.type(input, "9");
+ expect(screen.getByText("Differenz: −3 Stück")).toBeInTheDocument();
+ await userEvent.clear(input);
+ await userEvent.type(input, "13,5");
+ expect(screen.getByText("Differenz: +1,5 Stück")).toBeInTheDocument();
+ });
+
+ it("books the counted rest with an Idempotency-Key", async () => {
+ vi.mocked(bookCorrection).mockResolvedValue(lotDetail());
+ renderDialog();
+ await open();
+ const input = await screen.findByLabelText(/Tatsächlicher Rest/);
+ await userEvent.clear(input);
+ await userEvent.type(input, "9");
+ await userEvent.click(screen.getByRole("button", { name: "Inventur buchen" }));
+
+ expect(bookCorrection).toHaveBeenCalledWith("l1", 9, { idempotencyKey: expect.any(String) });
+ });
+
+ it("does not accept a negative count", async () => {
+ renderDialog();
+ await open();
+ const input = await screen.findByLabelText(/Tatsächlicher Rest/);
+ await userEvent.clear(input);
+ await userEvent.type(input, "-1");
+
+ expect(screen.getByText("Der Rest kann nicht negativ gezählt werden.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
+ });
+
+ it("shows a failed booking in the dialog", async () => {
+ vi.mocked(bookCorrection).mockRejectedValue(new ApiError(409, "Gerade geändert.", {}));
+ renderDialog();
+ await open();
+ const input = await screen.findByLabelText(/Tatsächlicher Rest/);
+ await userEvent.clear(input);
+ await userEvent.type(input, "9");
+ await userEvent.click(screen.getByRole("button", { name: "Inventur buchen" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
+ });
+
+ it("is off offline, with the visible reason", async () => {
+ renderDialog();
+ await open();
+ const input = await screen.findByLabelText(/Tatsächlicher Rest/);
+ await userEvent.clear(input);
+ await userEvent.type(input, "9");
+ act(() => reportRequestOutcome("network-error"));
+
+ expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
+ });
+
+ it("points the submit button at the status line", async () => {
+ renderDialog();
+ await open();
+
+ expect(await screen.findByRole("button", { name: "Inventur buchen" })).toHaveAttribute(
+ "aria-describedby",
+ expect.stringContaining("inventory-status"),
+ );
+ });
+
+ it("refuses an ambiguous thousands dot", async () => {
+ renderDialog();
+ await open();
+ const input = await screen.findByLabelText(/Tatsächlicher Rest/);
+ await userEvent.clear(input);
+ await userEvent.type(input, "1.000");
+
+ expect(screen.getByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
+ });
+
+ it("keeps the key for an unchanged retry after a network error, renews it after a 409 or a change", async () => {
+ vi.mocked(bookCorrection)
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
+ .mockRejectedValueOnce(new ApiError(409, "Die Packung wurde gerade geändert.", {}))
+ .mockResolvedValue(lotDetail());
+ renderDialog();
+ await open();
+ const input = await screen.findByLabelText(/Tatsächlicher Rest/);
+ await userEvent.clear(input);
+ await userEvent.type(input, "9");
+ const submit = screen.getByRole("button", { name: "Inventur buchen" });
+
+ await userEvent.click(submit); // network error
+ await userEvent.click(submit); // same body: same key
+ await userEvent.clear(input);
+ await userEvent.type(input, "8"); // changed body
+ await userEvent.click(submit); // 409
+ expect(await screen.findByRole("alert")).toHaveTextContent("Die Packung wurde gerade geändert.");
+ await userEvent.click(submit); // same body after a server answer
+
+ const keys = vi.mocked(bookCorrection).mock.calls.map((call) => call[2]?.idempotencyKey);
+ expect(keys[1]).toBe(keys[0]);
+ expect(keys[2]).not.toBe(keys[1]);
+ expect(keys[3]).not.toBe(keys[2]);
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx
new file mode 100644
index 0000000..aacd083
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+import { ClipboardList } from "lucide-react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { bookCorrection } from "@/lib/medicines/api";
+import {
+ decimalMessage,
+ formatQuantity,
+ formatSignedQuantity,
+ MEDICINE_UNIT_LABELS,
+ parseDecimal,
+ roundToThousandths,
+} from "@/lib/medicines/format";
+import type { MedicineUnit } from "@/lib/medicines/types";
+import { saveErrorText } from "@/lib/honey-batches/errors";
+import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+interface InventoryDialogProps {
+ lotId: string;
+ /** The booked rest. */
+ remaining: number;
+ unit: MedicineUnit;
+ /** Switched off from outside, e.g. offline; `describedBy` names the reason. */
+ disabled?: boolean;
+ describedBy?: string;
+}
+
+/** „Inventur“: the beekeeper types what is left in the box; the server books the difference. */
+export function InventoryDialog({
+ lotId,
+ remaining,
+ unit,
+ disabled = false,
+ describedBy: triggerDescribedBy,
+}: InventoryDialogProps) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+ Inventur
+
+ }
+ />
+
+
+ Inventur
+
+ setOpen(false)} />
+
+
+ );
+}
+
+interface InventoryFormProps {
+ lotId: string;
+ remaining: number;
+ unit: MedicineUnit;
+ onDone: () => void;
+}
+
+function InventoryForm({ lotId, remaining, unit, onDone }: InventoryFormProps) {
+ const queryClient = useQueryClient();
+ // Fixed when the dialog opens, so a reload behind it cannot move the booking.
+ const [baseline] = useState(remaining);
+ const [counted, setCounted] = useState(baseline < 0 ? "" : String(baseline).replace(".", ","));
+ const [error, setError] = useState(null);
+ const attempt = useAttemptKey();
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+
+ const value = parseDecimal(counted);
+ const isNumber = Number.isFinite(value);
+ const isNegative = isNumber && value < 0;
+ const delta = isNumber && !isNegative ? roundToThousandths(value - baseline) : 0;
+ const status = !isNumber
+ ? decimalMessage(counted, "Bitte den gezählten Rest eingeben.")
+ : isNegative
+ ? "Der Rest kann nicht negativ gezählt werden."
+ : delta === 0
+ ? "Keine Abweichung"
+ : `Differenz: ${formatSignedQuantity(delta, unit)}`;
+
+ const mutation = useMutation({
+ mutationFn: () => bookCorrection(lotId, value, { idempotencyKey: attempt.keyFor({ actualRemaining: value }) }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
+ void queryClient.invalidateQueries({ queryKey: ["medicines"] });
+ toast.success(`Inventur gebucht: ${formatSignedQuantity(delta, unit)}`);
+ attempt.done();
+ onDone();
+ },
+ onError: (mutationError) => {
+ attempt.failed(mutationError);
+ setError(saveErrorText(mutationError, "Die Inventur wurde nicht gebucht."));
+ },
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setError(null);
+ if (!isNumber || isNegative || delta === 0 || isOffline) return;
+ mutation.mutate();
+ }
+
+ return (
+
+
+ Gebucht sind {formatQuantity(baseline, unit)}. Zähl, was in der Packung ist – gebucht wird die
+ Differenz.
+
+
+
Tatsächlicher Rest ({MEDICINE_UNIT_LABELS[unit]})
+
setCounted(event.target.value)}
+ />
+
+ {status}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {mutation.isPending ? "Bucht…" : "Inventur buchen"}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.spec.tsx
new file mode 100644
index 0000000..57af570
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.spec.tsx
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import LotDetailPage from "./page";
+import { useAuth } from "@/lib/auth/auth-context";
+import { fetchLot } from "@/lib/medicines/api";
+import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
+
+vi.mock("next/navigation", () => ({ useParams: () => ({ id: "l1" }) }));
+vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
+vi.mock("@/lib/medicines/api", () => ({
+ fetchLot: vi.fn(),
+ updateLot: vi.fn(),
+ bookCorrection: vi.fn(),
+ bookDisposal: vi.fn(),
+}));
+vi.mock("@/components/document-upload-card", () => ({
+ DocumentUploadCard: ({
+ entityType,
+ entityId,
+ canWrite,
+ }: {
+ entityType: string;
+ entityId: string;
+ canWrite?: boolean;
+ }) => (
+
+ Upload {entityType} {entityId} {canWrite ? "schreiben" : "nur lesen"}
+
+ ),
+}));
+
+function renderPage(permissions: string[]) {
+ vi.mocked(useAuth).mockReturnValue({
+ hasPermission: (permission: string) => permissions.includes(permission),
+ } as never);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+const ALL = ["veterinary-medicines:read", "veterinary-medicines:update"];
+
+describe("LotDetailPage", () => {
+ beforeEach(() => {
+ vi.mocked(fetchLot).mockReset();
+ vi.mocked(fetchLot).mockResolvedValue(lotDetail());
+ });
+
+ it("shows the rest, the movement book and the receipt upload card", async () => {
+ renderPage(ALL);
+
+ expect(await screen.findByRole("heading", { name: /CH-42/ })).toBeInTheDocument();
+ expect(screen.getByText("Rest")).toBeInTheDocument();
+ expect(screen.getAllByText("12 Stück").length).toBeGreaterThan(0);
+ const book = screen.getByRole("table", { name: "Bewegungsbuch" });
+ expect(within(book).getByText("Behandlung")).toBeInTheDocument();
+ expect(within(book).getByText("−8 Stück")).toBeInTheDocument();
+ expect(within(book).getByText("Eingang")).toBeInTheDocument();
+ expect(screen.getByText("Upload MEDICINE_LOT l1 schreiben")).toBeInTheDocument();
+ });
+
+ it("shows the running rest after every booking", async () => {
+ renderPage(ALL);
+
+ const book = await screen.findByRole("table", { name: "Bewegungsbuch" });
+ const rows = within(book).getAllByRole("row");
+ expect(within(rows[1]).getByText("12 Stück")).toBeInTheDocument();
+ expect(within(rows[2]).getByText("20 Stück")).toBeInTheDocument();
+ });
+
+ it("a negative rest is shown in red with the word Negativ", async () => {
+ vi.mocked(fetchLot).mockResolvedValue(lotDetail({ remaining: -3 }));
+ renderPage(ALL);
+
+ const negative = await screen.findByText(/Negativ/);
+ expect(negative.closest("span.text-destructive")).not.toBeNull();
+ });
+
+ it("the main action in the header is Inventur", async () => {
+ const { container } = renderPage(ALL);
+
+ await screen.findByRole("heading", { name: /CH-42/ });
+ const header = container.querySelector("header") as HTMLElement;
+ expect(within(header).getByRole("button", { name: /Inventur/ })).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: /Inventur/ })).toHaveLength(1);
+ expect(screen.getByRole("button", { name: /Entsorgung/ })).toBeInTheDocument();
+ });
+
+ it("hides inventory, disposal and editing without veterinary-medicines:update", async () => {
+ renderPage(["veterinary-medicines:read"]);
+
+ await screen.findByRole("heading", { name: /CH-42/ });
+ expect(screen.queryByRole("button", { name: /Inventur/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Entsorgung/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Bearbeiten" })).not.toBeInTheDocument();
+ });
+
+ it("names the empty book instead of showing a bare table", async () => {
+ vi.mocked(fetchLot).mockResolvedValue(lotDetail({ movements: [], remaining: 20 }));
+ renderPage(ALL);
+
+ expect(await screen.findByText("Noch keine Bewegung außer dem Eingang.")).toBeInTheDocument();
+ });
+
+ it("lets a reader see and download the receipts but not change them", async () => {
+ renderPage(["veterinary-medicines:read"]);
+
+ expect(await screen.findByText("Upload MEDICINE_LOT l1 nur lesen")).toBeInTheDocument();
+ });
+
+ it("shows no data and no retry without veterinary-medicines:read", async () => {
+ renderPage([]);
+
+ expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
+ expect(fetchLot).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
+ expect(screen.queryByText("Upload MEDICINE_LOT l1 nur lesen")).not.toBeInTheDocument();
+ });
+
+ it("warns when the running rest does not end at the rest the server reports", async () => {
+ vi.mocked(fetchLot).mockResolvedValue(lotDetail({ remaining: 99 }));
+ renderPage(ALL);
+
+ expect(await screen.findByText("Stand weicht vom Server ab – bitte neu laden")).toBeInTheDocument();
+ });
+
+ it("stays quiet when book and server agree", async () => {
+ renderPage(ALL);
+
+ await screen.findByRole("table", { name: "Bewegungsbuch" });
+ expect(screen.queryByText(/weicht vom Server ab/)).not.toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx
new file mode 100644
index 0000000..e83e588
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/lots/[id]/page.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { Package } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { useAuth } from "@/lib/auth/auth-context";
+import { buildBook } from "@/lib/medicines/book";
+import { fetchLot } from "@/lib/medicines/api";
+import { formatDay, formatQuantity, formatSignedQuantity } from "@/lib/medicines/format";
+import type { LotDetailView } from "@/lib/medicines/types";
+import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
+import { DocumentUploadCard } from "@/components/document-upload-card";
+import { RestText } from "@/components/medicines/rest-text";
+import { NoReadAccess } from "@/components/medicines/no-read-access";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { LotFormDialog } from "../../[id]/lot-form-dialog";
+import { DisposalDialog } from "./disposal-dialog";
+import { InventoryDialog } from "./inventory-dialog";
+
+export default function LotDetailPage() {
+ const params = useParams<{ id: string }>();
+ const { hasPermission } = useAuth();
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+ const query = useQuery({
+ queryKey: ["medicine-lots", params.id],
+ queryFn: () => fetchLot(params.id),
+ enabled: hasPermission("veterinary-medicines:read"),
+ });
+ const lot = query.data;
+
+ if (!hasPermission("veterinary-medicines:read")) return ;
+
+ if (query.isLoading || query.isError || !lot) {
+ return (
+
+ );
+ }
+
+ const canUpdate = hasPermission("veterinary-medicines:update");
+ const offlineReason = isOffline ? describedBy : undefined;
+
+ return (
+
+
+
+ {lot.medicineName}
+
+ {` · gekauft am ${formatDay(lot.purchasedOn)}`}
+ >
+ }
+ primaryAction={
+ canUpdate ? (
+
+ ) : undefined
+ }
+ backHref={`/medicines/${lot.medicineId}`}
+ backLabel={`Zurück zu ${lot.medicineName}`}
+ />
+ {canUpdate ? : null}
+
+
+
+
+
+ Angaben
+ {canUpdate ? (
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function LotFacts({ lot }: { lot: LotDetailView }) {
+ const facts: { label: string; value: ReactNode }[] = [
+ { label: "Rest", value: },
+ { label: "Anfangsmenge", value: formatQuantity(lot.quantity, lot.unit) },
+ { label: "Haltbar bis", value: formatDay(lot.expiresOn) },
+ { label: "Chargennummer", value: lot.batchNumber ?? "—" },
+ { label: "Lieferant", value: lot.supplier ?? "—" },
+ { label: "Beleg", value: lot.purchaseProof ?? "—" },
+ { label: "Verschreibender Tierarzt", value: lot.prescribingVet ?? "—" },
+ { label: "Notizen", value: lot.notes ?? "—" },
+ ];
+ return (
+
+ {facts.map((fact) => (
+
+
{fact.label}
+ {fact.value}
+
+ ))}
+
+ );
+}
+
+function MovementBook({ lot }: { lot: LotDetailView }) {
+ const rows = buildBook(lot);
+ return (
+
+
+ Bewegungen
+
+
+
+
+
+ Datum
+ Art
+ Menge
+ Stand danach
+ Grund
+
+
+
+ {rows.map((row) => (
+
+ {formatDay(row.day)}
+ {row.label}
+ {formatSignedQuantity(row.quantity, lot.unit)}
+
+
+
+ {row.reason ?? "—"}
+
+ ))}
+
+
+
+ {rows[0].balance !== lot.remaining ? (
+ Stand weicht vom Server ab – bitte neu laden
+ ) : null}
+ {lot.movements.length === 0 ? (
+ Noch keine Bewegung außer dem Eingang.
+ ) : null}
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.spec.tsx
new file mode 100644
index 0000000..881cbe7
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.spec.tsx
@@ -0,0 +1,126 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MedicineFormDialog } from "./medicine-form-dialog";
+import { createMedicine, updateMedicine } from "@/lib/medicines/api";
+import { medicine } from "@/lib/medicines/fixtures.spec-helper";
+import { ApiError } from "@/lib/auth/api-client";
+import { reportRequestOutcome } from "@/lib/offline/connectivity";
+
+vi.mock("@/lib/medicines/api", () => ({ createMedicine: vi.fn(), updateMedicine: vi.fn() }));
+
+function renderDialog(ui: React.ReactElement) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render({ui} );
+}
+
+describe("MedicineFormDialog", () => {
+ beforeEach(() => {
+ vi.mocked(createMedicine).mockReset();
+ vi.mocked(updateMedicine).mockReset();
+ act(() => reportRequestOutcome("reached-server"));
+ });
+
+ it("creates a medicine with an Idempotency-Key and the typed values", async () => {
+ vi.mocked(createMedicine).mockResolvedValue(medicine());
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
+
+ await userEvent.type(await screen.findByLabelText(/^Name/), "Apivar");
+ await userEvent.click(screen.getByRole("radio", { name: "Stück" }));
+ await userEvent.type(screen.getByLabelText(/Wartezeit/), "0");
+ await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
+
+ expect(createMedicine).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "Apivar",
+ unit: "PIECE",
+ withdrawalPeriodDays: 0,
+ prescriptionOnly: false,
+ }),
+ { idempotencyKey: expect.any(String) },
+ );
+ });
+
+ it("asks for a name and a unit before it sends anything", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Anlegen" }));
+
+ expect(await screen.findByText("Bitte einen Namen eingeben.")).toBeInTheDocument();
+ expect(screen.getByText("Bitte eine Einheit wählen.")).toBeInTheDocument();
+ expect(createMedicine).not.toHaveBeenCalled();
+ });
+
+ it("keeps the rare fields folded away until they are asked for", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
+
+ expect(screen.queryByLabelText(/Niedrigste Lagertemperatur/)).not.toBeInTheDocument();
+ await userEvent.click(await screen.findByRole("button", { name: /Weitere Angaben/ }));
+ expect(screen.getByLabelText(/Niedrigste Lagertemperatur/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Dosis-Hinweis/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Notizen/)).toBeInTheDocument();
+ });
+
+ it("hides the unit field once lots exist and shows the server message", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
+
+ expect(
+ await screen.findByText("Einheit ist festgelegt, sobald Packungen erfasst sind"),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("radio", { name: "Stück" })).not.toBeInTheDocument();
+ });
+
+ it("offers the unit while no lot exists, and never sends it once lots exist", async () => {
+ vi.mocked(updateMedicine).mockResolvedValue(medicine());
+ const { unmount } = renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
+ expect(await screen.findByRole("radio", { name: "Stück" })).toBeChecked();
+ unmount();
+
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Speichern" }));
+ expect(updateMedicine).toHaveBeenCalledWith(
+ "m1",
+ expect.not.objectContaining({ unit: expect.anything() }),
+ );
+ });
+
+ it("clears an emptied optional field with null", async () => {
+ vi.mocked(updateMedicine).mockResolvedValue(medicine());
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
+ await userEvent.clear(await screen.findByLabelText(/Wirkstoff/));
+ await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
+
+ expect(updateMedicine).toHaveBeenCalledWith(
+ "m1",
+ expect.objectContaining({ activeIngredient: null }),
+ );
+ });
+
+ it("shows the German message of a failed save in the dialog", async () => {
+ vi.mocked(createMedicine).mockRejectedValue(new ApiError(409, "Gerade geändert.", {}));
+ renderDialog(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Anlegen" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
+ });
+
+ it("is off offline, with the visible reason", async () => {
+ renderDialog( );
+ await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
+ await screen.findByRole("button", { name: "Anlegen" });
+ act(() => reportRequestOutcome("network-error"));
+
+ expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Anlegen" })).toBeDisabled();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx
new file mode 100644
index 0000000..8a69f5e
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-dialog.tsx
@@ -0,0 +1,342 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { createMedicine, updateMedicine } from "@/lib/medicines/api";
+import { MEDICINE_UNITS, MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
+import type { MedicineFormPrefill } from "@/lib/medicines/prefill";
+import type { MedicineUnit, MedicineView } from "@/lib/medicines/types";
+import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
+import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
+import { Field } from "@/components/form/field";
+import { FormSection } from "@/components/form/form-section";
+import { ToggleChip } from "@/components/form/toggle-chip";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ initialMedicineValues,
+ medicineFormErrors,
+ toMedicineInput,
+ type MedicineFormValues,
+} from "./medicine-form-values";
+
+export const UNIT_LOCKED_TEXT = "Einheit ist festgelegt, sobald Packungen erfasst sind";
+
+interface MedicineFormDialogProps {
+ /** Edit this medicine; without it the dialog creates one. */
+ medicine?: MedicineView;
+ /** Values proposed from a treatment (create only). */
+ prefill?: MedicineFormPrefill;
+ triggerLabel?: string;
+ triggerAriaLabel?: string;
+ triggerVariant?: "default" | "outline";
+ /** Switched off from outside, e.g. offline; `describedBy` names the reason. */
+ disabled?: boolean;
+ describedBy?: string;
+}
+
+/** Create or edit one catalogue entry; every value is typed in, the code ships none. */
+export function MedicineFormDialog({
+ medicine,
+ prefill,
+ triggerLabel,
+ triggerAriaLabel,
+ triggerVariant,
+ disabled = false,
+ describedBy,
+}: MedicineFormDialogProps) {
+ const [open, setOpen] = useState(false);
+ const isEdit = medicine !== undefined;
+ return (
+
+
+ {triggerLabel ?? (isEdit ? "Bearbeiten" : "Mittel anlegen")}
+
+ }
+ />
+
+
+ {isEdit ? "Mittel bearbeiten" : "Neues Tierarzneimittel"}
+
+ setOpen(false)} />
+
+
+ );
+}
+
+interface MedicineFormProps {
+ medicine?: MedicineView;
+ prefill?: MedicineFormPrefill;
+ onDone: () => void;
+}
+
+const INPUT_CLASS = "h-11 sm:h-11";
+
+function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
+ const queryClient = useQueryClient();
+ const isEdit = medicine !== undefined;
+ const unitLocked = isEdit && medicine.lotCount > 0;
+ const [values, setValues] = useState(() => initialMedicineValues(medicine, prefill));
+ const [localErrors, setLocalErrors] = useState>>({});
+ const [saveError, setSaveError] = useState(null);
+ const attempt = useAttemptKey();
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+
+ const set = (key: K, value: MedicineFormValues[K]) =>
+ setValues((current) => ({ ...current, [key]: value }));
+ const errorOf = (key: keyof MedicineFormValues) => localErrors[key] ?? saveError?.fields[key] ?? null;
+
+ const mutation = useMutation({
+ mutationFn: () => {
+ const input = toMedicineInput(values, unitLocked);
+ return medicine
+ ? updateMedicine(medicine.id, input)
+ : createMedicine(input as Parameters[0], { idempotencyKey: attempt.keyFor(input) });
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["medicines"] });
+ void queryClient.invalidateQueries({ queryKey: ["medicine-suggestions"] });
+ toast.success(isEdit ? "Mittel gespeichert" : "Mittel angelegt");
+ attempt.done();
+ onDone();
+ },
+ onError: (error) => {
+ attempt.failed(error);
+ setSaveError(
+ describeSaveError(error, isEdit ? "Das Mittel wurde nicht gespeichert." : "Das Mittel wurde nicht angelegt."),
+ );
+ },
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setSaveError(null);
+ const errors = medicineFormErrors(values, unitLocked);
+ setLocalErrors(errors);
+ if (Object.keys(errors).length > 0 || isOffline) return;
+ mutation.mutate();
+ }
+
+ const hasRareValues = [
+ values.authorisationNumber,
+ values.dosePerColony,
+ values.doseNote,
+ values.minTemperatureC,
+ values.maxTemperatureC,
+ values.notes,
+ ].some((value) => value.trim() !== "");
+
+ return (
+
+ {prefill && !isEdit ? (
+
+ Vorbelegt aus deiner letzten Behandlung. Prüfe die Werte gegen die Packungsbeilage, bevor du
+ speicherst.
+
+ ) : null}
+
+ {({ id, describedBy: fieldDescribedBy, invalid }) => (
+ set("name", event.target.value)}
+ />
+ )}
+
+ {unitLocked ? (
+
+
Einheit: {MEDICINE_UNIT_LABELS[medicine.unit]}
+
{UNIT_LOCKED_TEXT}
+
+ ) : (
+
+ {({ id, labelId, describedBy: fieldDescribedBy }) => (
+
+ id={id}
+ labelledBy={labelId}
+ describedBy={fieldDescribedBy}
+ options={choiceOptions(MEDICINE_UNIT_LABELS, MEDICINE_UNITS)}
+ value={values.unit}
+ onChange={(unit) => set("unit", unit)}
+ className="grid-cols-3"
+ />
+ )}
+
+ )}
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("activeIngredient", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy, invalid }) => (
+ set("withdrawalPeriodDays", event.target.value)}
+ />
+ )}
+
+ set("prescriptionOnly", checked)}
+ />
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("applicationMethod", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("defaultSupplier", event.target.value)}
+ />
+ )}
+
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("authorisationNumber", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy, invalid }) => (
+ set("dosePerColony", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("doseNote", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy, invalid }) => (
+ set("minTemperatureC", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy, invalid }) => (
+ set("maxTemperatureC", event.target.value)}
+ />
+ )}
+
+
+ {({ id, describedBy: fieldDescribedBy }) => (
+ set("notes", event.target.value)}
+ />
+ )}
+
+
+ {saveError?.general ? (
+
+ {saveError.general}
+
+ ) : null}
+
+
+ {mutation.isPending ? "Speichert…" : isEdit ? "Speichern" : "Anlegen"}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-values.ts b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-values.ts
new file mode 100644
index 0000000..746dbad
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/medicine-form-values.ts
@@ -0,0 +1,100 @@
+import { emptyToNull } from "@/lib/forms/empty-to-null";
+import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
+import type { MedicineFormPrefill } from "@/lib/medicines/prefill";
+import type { MedicineInput, MedicineUnit, MedicineView } from "@/lib/medicines/types";
+
+export interface MedicineFormValues {
+ name: string;
+ unit: MedicineUnit | "";
+ activeIngredient: string;
+ authorisationNumber: string;
+ prescriptionOnly: boolean;
+ withdrawalPeriodDays: string;
+ dosePerColony: string;
+ doseNote: string;
+ applicationMethod: string;
+ minTemperatureC: string;
+ maxTemperatureC: string;
+ defaultSupplier: string;
+ notes: string;
+}
+
+const text = (value: string | null | undefined): string => value ?? "";
+const num = (value: number | null | undefined): string => (value === null || value === undefined ? "" : String(value));
+
+export function initialMedicineValues(
+ medicine?: MedicineView,
+ prefill?: MedicineFormPrefill,
+): MedicineFormValues {
+ const source: MedicineFormPrefill = medicine ?? prefill ?? {};
+ const full: Partial = medicine ?? {};
+ return {
+ name: text(source.name),
+ unit: source.unit ?? "",
+ activeIngredient: text(source.activeIngredient),
+ authorisationNumber: text(full.authorisationNumber),
+ prescriptionOnly: full.prescriptionOnly ?? false,
+ withdrawalPeriodDays: num(source.withdrawalPeriodDays),
+ dosePerColony: num(full.dosePerColony),
+ doseNote: text(source.doseNote),
+ applicationMethod: text(source.applicationMethod),
+ minTemperatureC: num(full.minTemperatureC),
+ maxTemperatureC: num(full.maxTemperatureC),
+ defaultSupplier: text(full.defaultSupplier),
+ notes: text(full.notes),
+ };
+}
+
+export type MedicineFormErrors = Partial>;
+
+const NOT_A_NUMBER = "Bitte eine Zahl eingeben.";
+
+function decimalProblem(value: string): string | null {
+ return value.trim() !== "" && !Number.isFinite(parseDecimal(value)) ? decimalMessage(value, NOT_A_NUMBER) : null;
+}
+
+/** What keeps the form from being sent, per field; empty = fine. */
+export function medicineFormErrors(values: MedicineFormValues, unitLocked: boolean): MedicineFormErrors {
+ const errors: MedicineFormErrors = {};
+ if (values.name.trim() === "") errors.name = "Bitte einen Namen eingeben.";
+ if (!unitLocked && values.unit === "") errors.unit = "Bitte eine Einheit wählen.";
+
+ const days = values.withdrawalPeriodDays.trim();
+ if (days !== "" && !(Number.isInteger(Number(days)) && Number(days) >= 0)) {
+ errors.withdrawalPeriodDays = "Bitte ganze Tage ab 0 eingeben.";
+ }
+ const dose = decimalProblem(values.dosePerColony);
+ if (dose) errors.dosePerColony = dose;
+ else if (values.dosePerColony.trim() !== "" && parseDecimal(values.dosePerColony) <= 0) {
+ errors.dosePerColony = "Die Dosis muss größer als 0 sein.";
+ }
+ const min = decimalProblem(values.minTemperatureC);
+ if (min) errors.minTemperatureC = min;
+ const max = decimalProblem(values.maxTemperatureC);
+ if (max) errors.maxTemperatureC = max;
+ if (!min && !max && values.minTemperatureC.trim() !== "" && values.maxTemperatureC.trim() !== "") {
+ if (parseDecimal(values.minTemperatureC) > parseDecimal(values.maxTemperatureC)) {
+ errors.minTemperatureC = "Die niedrigste Temperatur liegt über der höchsten.";
+ }
+ }
+ return errors;
+}
+
+/** An emptied optional field goes out as null, so it can be cleared (owner rule). */
+export function toMedicineInput(values: MedicineFormValues, unitLocked: boolean): Partial {
+ const base = {
+ name: values.name.trim(),
+ activeIngredient: emptyToNull(values.activeIngredient),
+ authorisationNumber: emptyToNull(values.authorisationNumber),
+ prescriptionOnly: values.prescriptionOnly,
+ withdrawalPeriodDays: emptyToNull(values.withdrawalPeriodDays, Number),
+ dosePerColony: emptyToNull(values.dosePerColony, parseDecimal),
+ doseNote: emptyToNull(values.doseNote),
+ applicationMethod: emptyToNull(values.applicationMethod),
+ minTemperatureC: emptyToNull(values.minTemperatureC, parseDecimal),
+ maxTemperatureC: emptyToNull(values.maxTemperatureC, parseDecimal),
+ defaultSupplier: emptyToNull(values.defaultSupplier),
+ notes: emptyToNull(values.notes),
+ };
+ return unitLocked || values.unit === "" ? base : { ...base, unit: values.unit };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/medicine-suggestions.tsx b/ApiCore-Frontend/src/app/(app)/medicines/medicine-suggestions.tsx
new file mode 100644
index 0000000..2a2d831
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/medicine-suggestions.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { Pill } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { fetchMedicineSuggestions } from "@/lib/medicines/api";
+import { prefillFromSuggestion } from "@/lib/medicines/prefill";
+import { EmptyState } from "@/components/empty-state";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { MedicineFormDialog } from "./medicine-form-dialog";
+
+interface MedicineSuggestionsProps {
+ canCreate: boolean;
+ disabled?: boolean;
+ describedBy?: string;
+}
+
+/**
+ * The catalogue is empty: offer the product names from the beekeeper's own
+ * treatments instead of a blank page. Nothing is created by looking at them;
+ * „Übernehmen“ opens the form with the values of the latest treatment, which
+ * the beekeeper checks before saving.
+ */
+export function MedicineSuggestions({ canCreate, disabled, describedBy }: MedicineSuggestionsProps) {
+ const query = useQuery({ queryKey: ["medicine-suggestions"], queryFn: fetchMedicineSuggestions });
+ const suggestions = query.data ?? [];
+
+ if (suggestions.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Aus deinen Behandlungen übernehmen?
+
+ Diese Mittel hast du schon angewendet. Du prüfst die Werte, bevor etwas gespeichert wird.
+
+
+
+
+ {suggestions.map((suggestion) => (
+
+
+
{suggestion.name}
+
+ {suggestion.treatmentCount === 1
+ ? "1 Behandlung"
+ : `${suggestion.treatmentCount} Behandlungen`}
+
+
+ {canCreate ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx
new file mode 100644
index 0000000..c3821e2
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/page.spec.tsx
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import MedicinesPage from "./page";
+import { useAuth } from "@/lib/auth/auth-context";
+import { createMedicine, fetchMedicines, fetchMedicineSuggestions } from "@/lib/medicines/api";
+import { medicine, suggestion } from "@/lib/medicines/fixtures.spec-helper";
+
+vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
+vi.mock("@/lib/medicines/api", () => ({
+ fetchMedicines: vi.fn(),
+ fetchMedicineSuggestions: vi.fn(),
+ createMedicine: vi.fn(),
+ updateMedicine: vi.fn(),
+}));
+
+function renderPage(permissions = ["veterinary-medicines:read", "veterinary-medicines:create"]) {
+ vi.mocked(useAuth).mockReturnValue({
+ hasPermission: (permission: string) => permissions.includes(permission),
+ } as never);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("MedicinesPage", () => {
+ beforeEach(() => {
+ vi.mocked(fetchMedicines).mockReset();
+ vi.mocked(fetchMedicineSuggestions).mockReset();
+ vi.mocked(createMedicine).mockReset();
+ vi.mocked(fetchMedicineSuggestions).mockResolvedValue([]);
+ });
+
+ it("lists name, rest and next expiry per medicine", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([
+ medicine({ totalRemaining: 12, nextExpiry: "2028-01-31" }),
+ ]);
+ renderPage();
+
+ const card = (await screen.findByRole("link", { name: /Apivar/ })) as HTMLElement;
+ expect(card).toHaveAttribute("href", "/medicines/m1");
+ expect(within(card).getByText("12 Stück")).toBeInTheDocument();
+ expect(within(card).getByText("31.01.2028")).toBeInTheDocument();
+ });
+
+ it("shows a negative rest in red with the word Negativ", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([medicine({ totalRemaining: -2, unit: "ML" })]);
+ renderPage();
+
+ const negative = await screen.findByText(/Negativ/);
+ expect(negative.closest("span.text-destructive")).not.toBeNull();
+ expect(negative.closest("a")).toHaveTextContent("−2 ml");
+ });
+
+ it("offers the empty catalogue 'Aus deinen Behandlungen übernehmen?' with the suggestions", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([]);
+ vi.mocked(fetchMedicineSuggestions).mockResolvedValue([
+ suggestion(),
+ suggestion({ name: "Oxalsäure", treatmentCount: 1 }),
+ ]);
+ renderPage();
+
+ expect(await screen.findByText("Aus deinen Behandlungen übernehmen?")).toBeInTheDocument();
+ expect(screen.getByText("Thymovar")).toBeInTheDocument();
+ expect(screen.getByText("Oxalsäure")).toBeInTheDocument();
+ });
+
+ it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([]);
+ vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
+ renderPage();
+
+ await userEvent.click(await screen.findByRole("button", { name: /Übernehmen: Thymovar/ }));
+
+ expect(await screen.findByLabelText(/^Name/)).toHaveValue("Thymovar");
+ expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Thymol");
+ expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(7);
+ expect(screen.getByText(/Vorbelegt aus deiner letzten Behandlung/)).toBeInTheDocument();
+ expect(createMedicine).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the plain empty state when there is nothing to suggest", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([]);
+ renderPage();
+
+ expect(await screen.findByText("Noch keine Tierarzneimittel")).toBeInTheDocument();
+ expect(screen.queryByText("Aus deinen Behandlungen übernehmen?")).not.toBeInTheDocument();
+ });
+
+ it("the main action is Mittel anlegen, once in the header", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([medicine()]);
+ renderPage();
+
+ await screen.findByRole("link", { name: /Apivar/ });
+ expect(screen.getAllByRole("button", { name: "Mittel anlegen" })).toHaveLength(1);
+ });
+
+ it("hides create buttons without veterinary-medicines:create", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([]);
+ vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
+ renderPage(["veterinary-medicines:read"]);
+
+ await screen.findByText("Thymovar");
+ expect(screen.queryByRole("button", { name: /Mittel anlegen/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Übernehmen/ })).not.toBeInTheDocument();
+ });
+
+ it("announces the number of shown medicines in one short line instead of the whole list", async () => {
+ vi.mocked(fetchMedicines).mockResolvedValue([medicine(), medicine({ id: "m2", name: "Thymovar" })]);
+ renderPage();
+
+ const count = await screen.findByText("2 aktive Mittel");
+ expect(count).toHaveAttribute("aria-live", "polite");
+ expect(document.querySelectorAll("[aria-live]").length).toBe(1);
+ });
+
+ it("shows no data and no retry without veterinary-medicines:read", async () => {
+ renderPage([]);
+
+ expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
+ expect(fetchMedicines).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/medicines/page.tsx b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx
new file mode 100644
index 0000000..1c6d99f
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/medicines/page.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useState } from "react";
+import { Pill } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { useAuth } from "@/lib/auth/auth-context";
+import { fetchMedicines } from "@/lib/medicines/api";
+import { formatDay } from "@/lib/medicines/format";
+import type { MedicineView } from "@/lib/medicines/types";
+import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
+import { EmptyState } from "@/components/empty-state";
+import { EntityCard } from "@/components/entity-card";
+import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
+import { PageHeader } from "@/components/page-header";
+import { QueryState } from "@/components/query-state";
+import { RestText } from "@/components/medicines/rest-text";
+import { Badge } from "@/components/ui/badge";
+import { NoReadAccess } from "@/components/medicines/no-read-access";
+import { MedicineFormDialog } from "./medicine-form-dialog";
+import { MedicineSuggestions } from "./medicine-suggestions";
+
+export default function MedicinesPage() {
+ const { hasPermission } = useAuth();
+ const canRead = hasPermission("veterinary-medicines:read");
+ const canCreate = hasPermission("veterinary-medicines:create");
+ const [tab, setTab] = useState("active");
+ const { isOffline, hintId, describedBy } = useOnlineOnly();
+ const medicinesQuery = useQuery({ queryKey: ["medicines"], queryFn: () => fetchMedicines(), enabled: canRead });
+
+ const medicines = medicinesQuery.data ?? [];
+ const active = medicines.filter((medicine) => medicine.active);
+ const inactive = medicines.filter((medicine) => !medicine.active);
+ const visible = tab === "active" ? active : inactive;
+ const countText = medicinesQuery.data
+ ? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
+ : "";
+
+ if (!canRead) return ;
+
+ return (
+
+ : undefined}
+ />
+ {canCreate ? : null}
+
+ {medicines.length > 0 ? (
+
+ ) : null}
+
+
+ {medicines.length > 0 ? countText : ""}
+
+
+
+ {() =>
+ medicines.length === 0 ? (
+
+ ) : visible.length === 0 ? (
+
+ ) : (
+
+ )
+ }
+
+
+ );
+}
+
+function MedicineGrid({ medicines }: { medicines: MedicineView[] }) {
+ return (
+
+ {medicines.map((medicine) => (
+ Deaktiviert}
+ fields={[
+ { label: "Rest", value: },
+ { label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) },
+ ]}
+ />
+ ))}
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts b/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts
index 498de11..7113f77 100644
--- a/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts
+++ b/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts
@@ -65,4 +65,14 @@ describe("nav-config", () => {
expect(item?.label).toBe("Vorschläge");
expect(item?.counter).toBe("suggestions");
});
+
+ it("shows Prüfbereitschaft only with compliance:read", () => {
+ expect(hrefs([])).not.toContain("/compliance");
+ expect(hrefs(["compliance:read"])).toContain("/compliance");
+ const group = visibleNavGroups({ permissions: ["compliance:read"] }).find((g) =>
+ g.items.some((item) => item.href === "/compliance"),
+ );
+ expect(group?.label).toBe("Betrieb");
+ expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft");
+ });
});
diff --git a/ApiCore-Frontend/src/app/(app)/nav-config.ts b/ApiCore-Frontend/src/app/(app)/nav-config.ts
index 2e2a11b..4cd30c2 100644
--- a/ApiCore-Frontend/src/app/(app)/nav-config.ts
+++ b/ApiCore-Frontend/src/app/(app)/nav-config.ts
@@ -18,6 +18,7 @@ import {
Package,
Scissors,
Search,
+ ShieldCheck,
ShoppingBag,
Snowflake,
Users,
@@ -70,6 +71,12 @@ export const NAV_GROUPS: NavGroup[] = [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
+ {
+ label: "Prüfbereitschaft",
+ href: "/compliance",
+ icon: ShieldCheck,
+ permission: "compliance:read",
+ },
{
label: "Überwinterung",
href: "/winter",
diff --git a/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx
index 0d8b2be..9fee5ad 100644
--- a/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/[id]/page.spec.tsx
@@ -43,6 +43,7 @@ describe("ProductDetailPage", () => {
sku: null,
packagingSize: null,
shelfLifeMonths: null,
+ legalName: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
@@ -102,6 +103,7 @@ describe("ProductDetailPage", () => {
sku: null,
packagingSize: 250,
shelfLifeMonths: 18,
+ legalName: "Blütenhonig",
weight: null,
salesUnit: null,
status: "ACTIVE",
@@ -113,6 +115,7 @@ describe("ProductDetailPage", () => {
renderPage();
expect(await screen.findByText("Gewicht pro Glas: 250 g")).toBeInTheDocument();
expect(screen.getByText("Haltbarkeit: 18 Monate")).toBeInTheDocument();
+ expect(screen.getByText("Bezeichnung auf dem Etikett: Blütenhonig")).toBeInTheDocument();
});
it("hides jar weight and shelf life when they are not set", async () => {
diff --git a/ApiCore-Frontend/src/app/(app)/products/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/products/[id]/page.tsx
index f6db357..d5f49b7 100644
--- a/ApiCore-Frontend/src/app/(app)/products/[id]/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/[id]/page.tsx
@@ -86,6 +86,7 @@ export default function ProductDetailPage() {
Haltbarkeit: {product.shelfLifeMonths} Monate
) : null}
{product.sku ? SKU: {product.sku}
: null}
+ {product.legalName ? Bezeichnung auf dem Etikett: {product.legalName}
: null}
{product.salesUnit ? (
Verkaufseinheit: {product.salesUnit}
) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.spec.tsx
index 0de94fd..2e84424 100644
--- a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.spec.tsx
@@ -16,6 +16,7 @@ const PRODUCT: Product = {
sku: null,
packagingSize: 500,
shelfLifeMonths: 24,
+ legalName: "Blütenhonig",
weight: null,
salesUnit: null,
status: "ACTIVE",
@@ -108,4 +109,16 @@ describe("EditProductDialog", () => {
expect(screen.getByLabelText("Name")).toHaveValue("Frühtracht");
});
+
+ it("the product form shows, saves and clears the label name", async () => {
+ await openDialog();
+ const field = screen.getByLabelText(/Bezeichnung auf dem Etikett \(z\. B\. Blütenhonig\)/);
+ expect(field).toHaveValue("Blütenhonig");
+
+ await userEvent.clear(field);
+ await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
+
+ await waitFor(() => expect(updateProduct).toHaveBeenCalled());
+ expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({ legalName: null });
+ });
});
diff --git a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx
index 7b0bbbf..f19ed32 100644
--- a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx
@@ -35,6 +35,7 @@ export function EditProductDialog({ product }: { product: Product }) {
const [sku, setSku] = useState(product.sku ?? "");
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
const [shelfLifeMonths, setShelfLifeMonths] = useState(product.shelfLifeMonths?.toString() ?? "");
+ const [legalName, setLegalName] = useState(product.legalName ?? "");
const [nameError, setNameError] = useState(null);
const [packagingError, setPackagingError] = useState(null);
const [shelfLifeError, setShelfLifeError] = useState(null);
@@ -49,6 +50,7 @@ export function EditProductDialog({ product }: { product: Product }) {
setSku(product.sku ?? "");
setPackagingSize(product.packagingSize?.toString() ?? "");
setShelfLifeMonths(product.shelfLifeMonths?.toString() ?? "");
+ setLegalName(product.legalName ?? "");
setWeight(product.weight?.toString() ?? "");
setSalesUnit(product.salesUnit ?? "");
setStatus(product.status);
@@ -66,6 +68,7 @@ export function EditProductDialog({ product }: { product: Product }) {
sku: emptyToNull(sku),
packagingSize: emptyToNull(packagingSize, Number),
shelfLifeMonths: emptyToNull(shelfLifeMonths, Number),
+ legalName: emptyToNull(legalName),
weight: emptyToNull(weight, Number),
salesUnit: emptyToNull(salesUnit),
status,
@@ -204,6 +207,21 @@ export function EditProductDialog({ product }: { product: Product }) {
) : null}
+
+
+ Bezeichnung auf dem Etikett (z. B. Blütenhonig)
+
+
setLegalName(event.target.value)}
+ />
+
+ Steht als Verkehrsbezeichnung auf dem Etikett. Ohne Angabe fehlt sie dort.
+
+
Artikelgewicht gesamt (g)
{
weight: null,
salesUnit: null,
shelfLifeMonths: null,
+ legalName: null,
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
});
@@ -115,4 +116,17 @@ describe("NewProductPage", () => {
expect(screen.getByText("Bitte ganze Monate von 1 bis 60 angeben.")).toBeInTheDocument();
expect(createProduct).not.toHaveBeenCalled();
});
+
+ it("saves the label name of the product trimmed", async () => {
+ vi.mocked(createProduct).mockResolvedValue({ id: "p-1" } as never);
+ renderPage();
+
+ await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
+ const legalName = screen.getByLabelText(/Bezeichnung auf dem Etikett \(z\. B\. Blütenhonig\)/);
+ expect(legalName).toHaveAttribute("maxlength", "200");
+ await userEvent.type(legalName, " Blütenhonig ");
+ await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
+
+ expect(vi.mocked(createProduct).mock.calls[0][0]).toMatchObject({ legalName: "Blütenhonig" });
+ });
});
diff --git a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx
index bddc41e..c0df145 100644
--- a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx
@@ -165,6 +165,20 @@ export default function NewProductPage() {
/>
)}
+
+ {({ id, describedBy }) => (
+ set("legalName", event.target.value)}
+ />
+ )}
+
{
expect(await screen.findByText("Bitte ein Land angeben.")).toBeInTheDocument();
});
});
+
+describe("SocialMediaTab food business", () => {
+ beforeEach(() => {
+ vi.mocked(fetchSiteSettings).mockReset();
+ vi.mocked(updateSiteSettings).mockReset();
+ vi.mocked(updateSiteSettings).mockResolvedValue(settings);
+ });
+
+ it("shows the saved food business fields with their limits", async () => {
+ vi.mocked(fetchSiteSettings).mockResolvedValue({
+ ...settings,
+ foodBusinessName: "Imkerei Muster",
+ foodBusinessAddress: "Bühlweg 10, 86450 Altenmünster",
+ });
+ renderTab();
+
+ const name = await screen.findByLabelText(/^Name des Lebensmittelunternehmers/);
+ expect(name).toHaveValue("Imkerei Muster");
+ expect(name).toHaveAttribute("maxlength", "200");
+ const address = screen.getByLabelText(/^Anschrift des Lebensmittelunternehmers/);
+ expect(address).toHaveValue("Bühlweg 10, 86450 Altenmünster");
+ expect(address).toHaveAttribute("maxlength", "300");
+ });
+
+ it("Vom Bienenhalter übernehmen fills the fields visibly and saves only on Speichern", async () => {
+ vi.mocked(fetchSiteSettings).mockResolvedValue({
+ ...settings,
+ holderName: "Max Imker",
+ holderAddress: "Dorfstraße 1, 12345 Ort",
+ });
+ renderTab();
+
+ await userEvent.click(await screen.findByRole("button", { name: "Vom Bienenhalter übernehmen" }));
+
+ expect(screen.getByLabelText(/^Name des Lebensmittelunternehmers/)).toHaveValue("Max Imker");
+ expect(screen.getByLabelText(/^Anschrift des Lebensmittelunternehmers/)).toHaveValue(
+ "Dorfstraße 1, 12345 Ort",
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(/noch nicht gespeichert/);
+ expect(updateSiteSettings).not.toHaveBeenCalled();
+
+ await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
+ expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({
+ foodBusinessName: "Max Imker",
+ foodBusinessAddress: "Dorfstraße 1, 12345 Ort",
+ });
+ });
+
+ it("does not overwrite the fields when the beekeeper has no data", async () => {
+ vi.mocked(fetchSiteSettings).mockResolvedValue({ ...settings, foodBusinessName: "Eigener Name" });
+ renderTab();
+
+ await userEvent.click(await screen.findByRole("button", { name: "Vom Bienenhalter übernehmen" }));
+
+ expect(screen.getByLabelText(/^Name des Lebensmittelunternehmers/)).toHaveValue("Eigener Name");
+ expect(screen.getByRole("status")).toHaveTextContent(/nichts eingetragen/);
+ });
+
+ it("saves emptied or blank food business fields as null so the server clears them", async () => {
+ vi.mocked(fetchSiteSettings).mockResolvedValue({ ...settings, foodBusinessName: "Alt" });
+ renderTab();
+
+ await userEvent.clear(await screen.findByLabelText(/^Name des Lebensmittelunternehmers/));
+ await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
+
+ expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({ foodBusinessName: null });
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx
index a082989..e832792 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx
@@ -2,9 +2,11 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { UserRound } from "lucide-react";
import { toast } from "sonner";
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
import { SiteSettings } from "@/lib/site-settings/types";
+import { saveErrorText } from "@/lib/honey-batches/errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -21,6 +23,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const [holderName, setHolderName] = useState(settings.holderName ?? "");
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
+ const [foodBusinessName, setFoodBusinessName] = useState(settings.foodBusinessName ?? "");
+ const [foodBusinessAddress, setFoodBusinessAddress] = useState(
+ settings.foodBusinessAddress ?? "",
+ );
+ const [adoptNote, setAdoptNote] = useState(null);
const [originCountry, setOriginCountry] = useState(settings.originCountry);
const [originCountryError, setOriginCountryError] = useState(null);
@@ -30,9 +37,21 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
void queryClient.invalidateQueries({ queryKey: ["site-settings"] });
toast.success("Website-Einstellungen gespeichert");
},
- onError: () => toast.error("Einstellungen konnten nicht gespeichert werden"),
+ onError: (cause) =>
+ toast.error(saveErrorText(cause, "Einstellungen konnten nicht gespeichert werden")),
});
+ // Fills the form fields only; nothing is saved until „Speichern“.
+ function adoptFromHolder() {
+ if (holderName.trim() === "" && holderAddress.trim() === "") {
+ setAdoptNote("Beim Bienenhalter ist noch nichts eingetragen.");
+ return;
+ }
+ setFoodBusinessName(holderName);
+ setFoodBusinessAddress(holderAddress);
+ setAdoptNote("Übernommen, noch nicht gespeichert. Prüfe die Angaben und klicke auf Speichern.");
+ }
+
function handleSubmit(event: FormEvent) {
event.preventDefault();
// NOT NULL in the database: it can be changed, not emptied.
@@ -47,6 +66,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
websiteUrl,
holderName,
holderAddress,
+ foodBusinessName: foodBusinessName.trim() || null,
+ foodBusinessAddress: foodBusinessAddress.trim() || null,
originCountry: originCountry.trim(),
});
}
@@ -141,6 +162,52 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
/>
+
+ Lebensmittelunternehmer (für das Etikett)
+
+ Name und Anschrift stehen als Pflichtangabe auf jedem Honigetikett. Sie werden nicht
+ im Shop und nicht auf der Herkunftsseite angezeigt.
+
+
+ Name des Lebensmittelunternehmers
+ {
+ setFoodBusinessName(event.target.value);
+ setAdoptNote(null);
+ }}
+ />
+
+
+
+ Anschrift des Lebensmittelunternehmers
+
+ {
+ setFoodBusinessAddress(event.target.value);
+ setAdoptNote(null);
+ }}
+ />
+
+
+
+ Vom Bienenhalter übernehmen
+
+
+ {adoptNote}
+
+
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx
index 22b0374..235901f 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/[id]/page.tsx
@@ -40,6 +40,7 @@ export default function EditLabelTemplatePage() {
elements: template.elements,
widthMm: template.widthMm,
heightMm: template.heightMm,
+ minXHeightMm: template.minXHeightMm,
columns: template.columns,
rows: template.rows,
marginTopMm: template.marginTopMm,
diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.spec.tsx
index 65152b1..705370b 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.spec.tsx
@@ -1,13 +1,77 @@
-import { describe, expect, it, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { LabelDesigner } from "./label-designer";
+import { toast } from "sonner";
+import { apiFetch } from "@/lib/auth/api-client";
+import type { LabelCheckResult } from "@/lib/label-templates/check";
+import type { LabelElement } from "@/lib/label-templates/types";
+import { LabelDesigner, type LabelDesignerValue } from "./label-designer";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
}));
+vi.mock("sonner", () => ({
+ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
+}));
+vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
+ ...(await importOriginal()),
+ apiFetch: vi.fn(),
+}));
+
+const RESULT: LabelCheckResult = {
+ printable: false,
+ gaps: [{ code: "ORIGIN", title: "Herkunftsland fehlt", detail: "Fehlt auf dem Etikett.", elementIds: [] }],
+ warnings: [],
+ sample: true,
+ // Deliberately different from the stored value: the hint must show this one.
+ requirements: { minXHeightMm: 2.3, netQuantityMinCapHeightMm: 3 },
+};
+
+const TEXT: LabelElement = {
+ id: "t1",
+ type: "text",
+ x: 1,
+ y: 1,
+ width: 40,
+ height: 8,
+ text: "Honig",
+ placeholderKey: null,
+ fontSize: 8,
+ fontFamily: "sans",
+ bold: false,
+ italic: false,
+ color: "#000000",
+ align: "left",
+};
+
+const INITIAL: LabelDesignerValue = {
+ name: "Etikett",
+ elements: [TEXT, { id: "q1", type: "qrCode", x: 50, y: 1, size: 10 }],
+ widthMm: 90,
+ heightMm: 35,
+ minXHeightMm: 1.7,
+ columns: null,
+ rows: null,
+ marginTopMm: 5,
+ marginLeftMm: 5,
+ gapXMm: 2,
+ gapYMm: 2,
+ cropMarks: true,
+ isDefault: false,
+};
+
+function sentBodies(): { elements: { id: string }[]; minXHeightMm: number }[] {
+ return vi
+ .mocked(apiFetch)
+ .mock.calls.map((call) => JSON.parse(String((call[1] as RequestInit).body)));
+}
+
+describe("LabelDesigner placeholder menu", () => {
+ beforeEach(() => {
+ vi.mocked(apiFetch).mockReset();
+ vi.mocked(apiFetch).mockResolvedValue(RESULT);
+ });
-describe("LabelDesigner", () => {
it("keeps the placeholder menu labelled as an action after inserting, never showing the raw key", async () => {
render( );
const insertMenu = screen.getByText("Platzhalter einfügen").closest('[role="combobox"]');
@@ -25,3 +89,137 @@ describe("LabelDesigner", () => {
expect(insertMenu).not.toHaveTextContent("Chargennummer");
});
});
+
+describe("LabelDesigner compliance check", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.mocked(apiFetch).mockReset();
+ vi.mocked(apiFetch).mockResolvedValue(RESULT);
+ vi.mocked(toast).mockReset();
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("debounces the check by 500 ms and sends text elements only", async () => {
+ render( );
+
+ await act(async () => {
+ vi.advanceTimersByTime(499);
+ });
+ expect(apiFetch).not.toHaveBeenCalled();
+
+ await act(async () => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(apiFetch).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/label-templates/check");
+ expect(sentBodies()[0].elements.map((element) => element.id)).toEqual(["t1"]);
+ expect(sentBodies()[0].minXHeightMm).toBe(INITIAL.minXHeightMm);
+ expect(screen.getByText("Fehlt auf dem Etikett.")).toBeInTheDocument();
+ });
+
+ it("Pflichtangaben-Block einfügen adds the block and Rückgängig removes it again", async () => {
+ render( );
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ const insert = screen.getByRole("button", { name: "Pflichtangaben-Block einfügen" });
+ expect(insert).not.toHaveAttribute("aria-disabled", "true");
+
+ await act(async () => {
+ insert.click();
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+
+ // The next check carries the block: the original text plus one element per new placeholder and the net quantity.
+ const afterInsert = sentBodies().at(-1)!.elements;
+ expect(afterInsert.length).toBeGreaterThan(1);
+ const toastCall = vi.mocked(toast).mock.calls.at(-1)!;
+ const action = (toastCall[1] as { action: { label: string; onClick: () => void } }).action;
+ expect(action.label).toBe("Rückgängig");
+
+ await act(async () => {
+ action.onClick();
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(sentBodies().at(-1)!.elements.map((element) => element.id)).toEqual(["t1"]);
+ });
+
+ it("keeps the insert button inactive with a reason until the first check is back", async () => {
+ vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
+ render( );
+ const insert = screen.getByRole("button", { name: "Pflichtangaben-Block einfügen" });
+
+ expect(insert).toHaveAttribute("aria-disabled", "true");
+ const reason = document.getElementById(insert.getAttribute("aria-describedby") ?? "");
+ expect(reason).toHaveTextContent(/Prüfung/);
+ });
+
+ it("shows the stored minimum and the one the check demands, and states no legal number itself", async () => {
+ render( );
+ expect(screen.getByLabelText(/Mindest-x-Höhe/)).toHaveValue("1,7");
+ const hint = document.getElementById("designer-min-x-height-hint")!;
+ expect(hint).toHaveTextContent(/gesetzlichen Mindestwert/);
+ expect(hint).not.toHaveTextContent(/mm/);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ // The number comes from the check result, not from the frontend.
+ expect(hint).toHaveTextContent("2,3 mm");
+ });
+
+ it("leaves the field empty for a new template and sends no minimum on create", async () => {
+ vi.useRealTimers();
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ const { minXHeightMm: stored, ...withoutMin } = INITIAL;
+ void stored;
+ render( );
+ expect(screen.getByLabelText(/Mindest-x-Höhe/)).toHaveValue("");
+ expect(screen.getByLabelText(/Mindest-x-Höhe/)).not.toHaveAttribute("aria-invalid", "true");
+
+ await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("minXHeightMm");
+ expect(apiFetch).not.toHaveBeenCalled();
+ });
+
+ it("the minimum x-height field accepts the technical range 0.5 to 5", async () => {
+ vi.useRealTimers();
+ render( );
+ const field = screen.getByLabelText(/Mindest-x-Höhe/);
+
+ for (const valid of ["0,5", "0.5", "5", "2,75"]) {
+ await userEvent.clear(field);
+ await userEvent.type(field, valid);
+ expect(field).not.toHaveAttribute("aria-invalid", "true");
+ }
+ for (const invalid of ["0,4", "5,1", "0", "-1", "abc"]) {
+ await userEvent.clear(field);
+ await userEvent.type(field, invalid);
+ expect(field).toHaveAttribute("aria-invalid", "true");
+ expect(screen.getByText(/zwischen 0,5 und 5 mm/)).toBeInTheDocument();
+ }
+ });
+
+ it("saves the minimum x-height and refuses to save an invalid one", async () => {
+ vi.useRealTimers();
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
+ render( );
+ const field = screen.getByLabelText(/Mindest-x-Höhe/);
+
+ await userEvent.clear(field);
+ await userEvent.type(field, "9");
+ await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(field).toHaveFocus();
+
+ await userEvent.clear(field);
+ await userEvent.type(field, "0,9");
+ await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ minXHeightMm: 0.9 }));
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx
index 4d8c01d..ca233e9 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-designer.tsx
@@ -1,12 +1,13 @@
"use client";
-import { ChangeEvent, useEffect, useState } from "react";
+import { ChangeEvent, useEffect, useId, useState } from "react";
import { useRouter } from "next/navigation";
import {
Download,
Image as ImageIcon,
Minus,
QrCode,
+ ShieldCheck,
Type,
Upload,
ZoomIn,
@@ -20,6 +21,12 @@ import {
PLACEHOLDER_LABELS,
PlaceholderKey,
} from "@/lib/label-templates/types";
+import {
+ MIN_X_HEIGHT_RANGE_MM,
+ useDebouncedLabelCheck,
+} from "@/lib/label-templates/check";
+import { buildComplianceBlock } from "@/lib/label-templates/compliance-block";
+import { LabelCompliancePanel } from "@/components/labels/label-compliance-panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -40,6 +47,18 @@ const A4_HEIGHT_MM = 297;
const ZOOM_STEPS: number[] = [0.5, 0.75, 1, 1.25, 1.5, 2];
const ZOOM_MIN = ZOOM_STEPS[0];
const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1];
+const MIN_X_HEIGHT_ERROR = "Bitte einen Wert zwischen 0,5 und 5 mm eingeben.";
+
+function formatDecimal(value: number): string {
+ return String(value).replace(".", ",");
+}
+
+/** German or English decimal sign; `null` for empty input or outside the range the server accepts. */
+function parseMinXHeight(text: string): number | null {
+ const value = Number(text.trim().replace(",", "."));
+ if (text.trim() === "" || !Number.isFinite(value)) return null;
+ return value >= MIN_X_HEIGHT_RANGE_MM.min && value <= MIN_X_HEIGHT_RANGE_MM.max ? value : null;
+}
function nextZoomStep(current: number, direction: 1 | -1): number {
const index = ZOOM_STEPS.indexOf(current);
@@ -76,6 +95,8 @@ export interface LabelDesignerValue {
elements: LabelElement[];
widthMm: number;
heightMm: number;
+ /** Absent on a new template: the server sets its default on create. */
+ minXHeightMm?: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
@@ -141,6 +162,9 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
const [name, setName] = useState(initial?.name ?? "");
const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90);
const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35);
+ const [minXHeightText, setMinXHeightText] = useState(
+ initial?.minXHeightMm === undefined ? "" : formatDecimal(initial.minXHeightMm),
+ );
const [columns, setColumns] = useState(initial?.columns ?? null);
const [rows, setRows] = useState(initial?.rows ?? null);
const [marginTopMm, setMarginTopMm] = useState(initial?.marginTopMm ?? 5);
@@ -155,6 +179,16 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
const [zoom, setZoom] = useState(1);
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
+ const insertHintId = useId();
+ const minXHeightMm = parseMinXHeight(minXHeightText);
+ const minXHeightInvalid = minXHeightText.trim() !== "" && minXHeightMm === null;
+ const check = useDebouncedLabelCheck({
+ elements,
+ widthMm,
+ heightMm,
+ minXHeightMm: minXHeightMm ?? Number.NaN,
+ });
+ const canInsertBlock = check.result !== null;
// Mirrors the backend's clamp in label-templates.service.ts exactly, so
// the hint shown here always matches what actually prints - a value typed
@@ -205,6 +239,27 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
});
}
+ // The block is a start, not a finished layout: it lands on the label and can
+ // be taken back in one step.
+ function insertComplianceBlock() {
+ if (!check.result) return;
+ const block = buildComplianceBlock({ widthMm, heightMm }, check.result.requirements);
+ const ids = new Set(block.map((element) => element.id));
+ setElements((current) => [...current, ...block]);
+ setSelectedId(block[0]?.id ?? null);
+ toast("Pflichtangaben-Block eingefügt", {
+ description: "Ordne die Felder auf dem Etikett an.",
+ action: {
+ label: "Rückgängig",
+ onClick: () => {
+ setElements((current) => current.filter((element) => !ids.has(element.id)));
+ setSelectedId((current) => (current !== null && ids.has(current) ? null : current));
+ },
+ },
+ duration: 8000,
+ });
+ }
+
function duplicateElement(id: string) {
const source = elements.find((element) => element.id === id);
if (!source) return;
@@ -231,12 +286,13 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
}, [selectedId, elements]);
// Leaving the page (tab close, reload) with unsaved changes asks first.
- const currentSnapshot = JSON.stringify({ name, elements, widthMm, heightMm });
+ const currentSnapshot = JSON.stringify({ name, elements, widthMm, heightMm, minXHeightText });
const initialSnapshot = JSON.stringify({
name: initial?.name ?? "",
elements: initial?.elements ?? [],
widthMm: initial?.widthMm ?? 90,
heightMm: initial?.heightMm ?? 35,
+ minXHeightText: initial?.minXHeightMm === undefined ? "" : formatDecimal(initial.minXHeightMm),
});
const hasUnsavedChanges = currentSnapshot !== initialSnapshot;
useEffect(() => {
@@ -277,6 +333,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
elements,
widthMm,
heightMm,
+ ...(minXHeightMm === null ? {} : { minXHeightMm }),
columns,
rows,
marginTopMm,
@@ -320,6 +377,9 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
if (typeof value.name === "string" && value.name && !name) setName(value.name);
if (typeof value.widthMm === "number") setWidthMm(value.widthMm);
if (typeof value.heightMm === "number") setHeightMm(value.heightMm);
+ if (typeof value.minXHeightMm === "number") {
+ setMinXHeightText(formatDecimal(value.minXHeightMm));
+ }
if (typeof value.columns === "number" || value.columns === null) setColumns(value.columns);
if (typeof value.rows === "number" || value.rows === null) setRows(value.rows);
if (typeof value.marginTopMm === "number") setMarginTopMm(value.marginTopMm);
@@ -342,6 +402,10 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
toast.error("Bitte einen Namen für die Vorlage angeben.");
return;
}
+ if (minXHeightInvalid) {
+ document.getElementById("designer-min-x-height")?.focus();
+ return;
+ }
setIsSubmitting(true);
try {
await onSubmit({
@@ -349,6 +413,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
elements,
widthMm,
heightMm,
+ ...(minXHeightMm === null ? {} : { minXHeightMm }),
columns,
rows,
marginTopMm,
@@ -405,6 +470,33 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
/>
+
+
Mindest-x-Höhe (mm)
+
setMinXHeightText(event.target.value)}
+ />
+
+ Der Wert muss den gesetzlichen Mindestwert erreichen.
+ {check.result
+ ? ` Die Prüfung verlangt aktuell mindestens ${check.result.requirements.minXHeightMm.toLocaleString("de-DE", { maximumFractionDigits: 2 })} mm.`
+ : ""}
+ {minXHeightText.trim() === ""
+ ? " Leer lassen: Der Server setzt den Standardwert. Die Prüfung läuft, sobald hier ein Wert steht."
+ : ""}
+
+ {minXHeightInvalid ? (
+
+ {MIN_X_HEIGHT_ERROR}
+
+ ) : null}
+
@@ -551,7 +644,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
}
/>
-
+
Exportieren
@@ -562,6 +655,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
type="button"
variant="outline"
size="sm"
+ className="min-h-11"
onClick={() => addElement(newTextElement())}
>
@@ -577,7 +671,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
if (key) handlePlaceholderInsert(key);
}}
>
-
+
Platzhalter einfügen
@@ -592,6 +686,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
type="button"
variant="outline"
size="sm"
+ className="min-h-11"
onClick={() =>
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
}
@@ -603,6 +698,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
type="button"
variant="outline"
size="sm"
+ className="min-h-11"
onClick={() =>
addElement({
id: newId(),
@@ -619,16 +715,42 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
Linie
-
Bild/Logo
} />
+ {
+ if (canInsertBlock) insertComplianceBlock();
+ }}
+ >
+
+ Pflichtangaben-Block einfügen
+
+ {canInsertBlock ? null : (
+
+ Geht, sobald die erste Prüfung da ist.
+
+ )}
+
+
({ useAuth: vi.fn() }));
+vi.mock("@/lib/storage/api", () => ({
+ fetchStorageContainers: vi.fn(async () => []),
+ fetchStockItems: vi.fn(async () => []),
+}));
+vi.mock("./create-storage-container-dialog", () => ({ CreateStorageContainerDialog: () => null }));
+vi.mock("./create-stock-item-dialog", () => ({ CreateStockItemDialog: () => null }));
+vi.mock("./edit-storage-container-dialog", () => ({ EditStorageContainerDialog: () => null }));
+vi.mock("./edit-stock-item-dialog", () => ({ EditStockItemDialog: () => null }));
+vi.mock("./record-movement-dialog", () => ({ RecordMovementDialog: () => null }));
+vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
+
+function renderPage(permissions: string[]) {
+ vi.mocked(useAuth).mockReturnValue({
+ hasPermission: (permission: string) => permissions.includes(permission),
+ } as never);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("StoragePage", () => {
+ it("shows the Tierarzneimittel card with a link to /medicines with veterinary-medicines:read", async () => {
+ renderPage(["veterinary-medicines:read"]);
+
+ const link = await screen.findByRole("link", { name: /Tierarzneimittel/ });
+ expect(link).toHaveAttribute("href", "/medicines");
+ });
+
+ it("does not show the Tierarzneimittel card without veterinary-medicines:read", async () => {
+ renderPage([]);
+
+ await screen.findByText("Noch keine Lagerbehälter");
+ expect(screen.queryByRole("link", { name: /Tierarzneimittel/ })).not.toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/storage/page.tsx b/ApiCore-Frontend/src/app/(app)/storage/page.tsx
index 1980e6f..4c0ec8a 100644
--- a/ApiCore-Frontend/src/app/(app)/storage/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/storage/page.tsx
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
-import { AlertTriangle, Package, Warehouse } from "lucide-react";
+import { AlertTriangle, Package, Pill, Warehouse } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
+import { useAuth } from "@/lib/auth/auth-context";
import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api";
import { StockItem, StorageContainer } from "@/lib/storage/types";
import { STOCK_CATEGORY_LABELS, STORAGE_CONTAINER_STATUS_LABELS } from "@/lib/storage/labels";
@@ -20,6 +21,7 @@ import { EditStockItemDialog } from "./edit-stock-item-dialog";
import { RecordMovementDialog } from "./record-movement-dialog";
export default function StoragePage() {
+ const { hasPermission } = useAuth();
const [containerTab, setContainerTab] = useState("active");
const containersQuery = useQuery({
queryKey: ["storage-containers"],
@@ -45,6 +47,7 @@ export default function StoragePage() {
gradient="var(--gradient-sky)"
actions={ }
/>
+ {hasPermission("veterinary-medicines:read") ? : null}
+ );
+}
+
function ContainersEmpty({ tab }: { tab: EntityLifecycleTab }) {
if (tab === "history") {
return (
diff --git a/ApiCore-Frontend/src/components/document-upload-card.spec.tsx b/ApiCore-Frontend/src/components/document-upload-card.spec.tsx
index e9ae962..2a596d5 100644
--- a/ApiCore-Frontend/src/components/document-upload-card.spec.tsx
+++ b/ApiCore-Frontend/src/components/document-upload-card.spec.tsx
@@ -133,4 +133,20 @@ describe("DocumentUploadCard", () => {
);
expect(outerSubmit).not.toHaveBeenCalled();
});
+
+ it("offers no upload, rename or delete when canWrite is false, but still downloads", async () => {
+ vi.mocked(fetchDocuments).mockResolvedValue([STORED]);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByText("rahmen.jpg")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Herunterladen" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Datei hochladen/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Umbenennen/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Löschen" })).not.toBeInTheDocument();
+ });
});
diff --git a/ApiCore-Frontend/src/components/document-upload-card.tsx b/ApiCore-Frontend/src/components/document-upload-card.tsx
index d37809b..ec8dafa 100644
--- a/ApiCore-Frontend/src/components/document-upload-card.tsx
+++ b/ApiCore-Frontend/src/components/document-upload-card.tsx
@@ -150,12 +150,15 @@ interface DocumentUploadCardProps {
entityType: DocumentEntityType;
entityId: string;
title?: string;
+ /** False: only look at and download the files (no upload, rename or delete). */
+ canWrite?: boolean;
}
export function DocumentUploadCard({
entityType,
entityId,
title = "Dokumente",
+ canWrite = true,
}: DocumentUploadCardProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef(null);
@@ -205,22 +208,26 @@ export function DocumentUploadCard({
{title}
- fileInputRef.current?.click()}
- >
- {uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"}
-
-
+ {canWrite ? (
+ <>
+ fileInputRef.current?.click()}
+ >
+ {uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"}
+
+
+ >
+ ) : null}
{isLoading ? Lädt…
: null}
@@ -228,7 +235,9 @@ export function DocumentUploadCard({
) : null}
{documents?.map((document) => (
@@ -258,8 +267,8 @@ export function DocumentUploadCard({
>
Herunterladen
-
-
: null}
+ {canWrite ?
deleteMutation.mutate(document.id)}
- />
+ /> : null}
))}
diff --git a/ApiCore-Frontend/src/components/labels/label-compliance-panel.spec.tsx b/ApiCore-Frontend/src/components/labels/label-compliance-panel.spec.tsx
new file mode 100644
index 0000000..71e3fbc
--- /dev/null
+++ b/ApiCore-Frontend/src/components/labels/label-compliance-panel.spec.tsx
@@ -0,0 +1,149 @@
+import { describe, expect, it } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import { LabelCompliancePanel } from "./label-compliance-panel";
+import type { LabelCheckResult, LabelFinding } from "@/lib/label-templates/check";
+
+function finding(code: LabelFinding["code"], title: string, detail = ""): LabelFinding {
+ return { code, title, detail, elementIds: [] };
+}
+
+const COMPLETE: LabelCheckResult = {
+ printable: true,
+ gaps: [],
+ warnings: [],
+ sample: false,
+ requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: 3 },
+};
+
+function items(): HTMLElement[] {
+ return within(screen.getByRole("list", { name: "Pflichtangaben" })).getAllByRole("listitem");
+}
+
+describe("LabelCompliancePanel", () => {
+ it("shows each particular with a check mark or a cross and the word Erfüllt or Fehlt", () => {
+ render(
+ ,
+ );
+
+ expect(items()).toHaveLength(6);
+ const origin = items().find((item) => /Herkunftsland/.test(item.textContent ?? ""))!;
+ expect(origin).toHaveTextContent("Fehlt");
+ expect(origin).toHaveTextContent("Das Herkunftsland steht nicht auf dem Etikett.");
+ expect(origin.querySelector("svg")).not.toBeNull();
+ const legalName = items().find((item) => /Verkehrsbezeichnung/.test(item.textContent ?? ""))!;
+ expect(legalName).toHaveTextContent("Erfüllt");
+ expect(legalName).not.toHaveTextContent("Fehlt");
+ expect(legalName.querySelector("svg")).not.toBeNull();
+ expect(items().filter((item) => /Fehlt/.test(item.textContent ?? ""))).toHaveLength(2);
+ expect(items().filter((item) => /Erfüllt/.test(item.textContent ?? ""))).toHaveLength(4);
+ });
+
+ it("lists warnings separately and announces changes via aria-live polite", () => {
+ render(
+ ,
+ );
+
+ const warnings = screen.getByRole("list", { name: "Hinweise" });
+ expect(within(warnings).getAllByRole("listitem")).toHaveLength(2);
+ expect(within(warnings).getByText("Hinweis: Text zu klein (x-Höhe 1,1 mm)")).toBeInTheDocument();
+ // The warnings are not part of the particulars list.
+ expect(items()).toHaveLength(6);
+ // Changes are announced through the summary line only.
+ expect(screen.getByText("Alle Pflichtangaben sind erfüllt.")).toHaveAttribute("aria-live", "polite");
+ });
+
+ it('shows "Beispielwerte" when the result is a sample', () => {
+ render( );
+ expect(screen.getByText(/Beispielwerte/)).toBeInTheDocument();
+ });
+
+ it("does not mention sample values for a real bottling", () => {
+ render( );
+ expect(screen.queryByText(/Beispielwerte/)).not.toBeInTheDocument();
+ });
+
+ it("takes the legal minimums from the result and states none of its own", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/1,7 mm/)).toBeInTheDocument();
+ expect(screen.getByText(/4 mm/)).toBeInTheDocument();
+ });
+
+ it("keeps the live region while the check runs and says so", () => {
+ const { container } = render( );
+ expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
+ expect(screen.getByText(/wird geprüft/i)).toBeInTheDocument();
+ });
+
+ it("puts only the summary line in the live region", () => {
+ const { container } = render(
+ ,
+ );
+ const live = container.querySelectorAll('[aria-live="polite"]');
+ expect(live).toHaveLength(1);
+ expect(live[0]).toHaveTextContent("Es fehlt 1 Pflichtangabe.");
+ expect(live[0]).not.toContainElement(screen.getByRole("list", { name: "Pflichtangaben" }));
+ expect(live[0]).not.toContainElement(screen.getByRole("list", { name: "Hinweise" }));
+ });
+
+ it("prefixes every warning with Hinweis:", () => {
+ render(
+ ,
+ );
+ const items = within(screen.getByRole("list", { name: "Hinweise" })).getAllByRole("listitem");
+ expect(items[0]).toHaveTextContent("Hinweis: Text zu klein");
+ expect(items[1]).toHaveTextContent("Hinweis: Text abgeschnitten");
+ });
+
+ it("says the result is veraltet when it answers an older draft", () => {
+ render( );
+ expect(screen.getByText(/Veraltet/)).toBeInTheDocument();
+ });
+
+ it("shows an error as an alert", () => {
+ render( );
+ expect(screen.getByRole("alert")).toHaveTextContent("Die Prüfung ist fehlgeschlagen.");
+ });
+});
diff --git a/ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx b/ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx
new file mode 100644
index 0000000..b57936d
--- /dev/null
+++ b/ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { CircleCheck, CircleX, Loader, TriangleAlert } from "lucide-react";
+import {
+ PARTICULAR_CODES,
+ type LabelCheckResult,
+ type LabelFinding,
+ type LabelFindingCode,
+} from "@/lib/label-templates/check";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+export const PARTICULAR_LABELS: Record<(typeof PARTICULAR_CODES)[number], string> = {
+ LEGAL_NAME: "Verkehrsbezeichnung",
+ NET_QUANTITY: "Füllmenge",
+ BEST_BEFORE: "Mindesthaltbarkeitsdatum",
+ LOT: "Losnummer",
+ ORIGIN: "Herkunftsland",
+ FOOD_BUSINESS: "Name und Anschrift des Lebensmittelunternehmers",
+};
+
+function formatMm(value: number): string {
+ return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} mm`;
+}
+
+function summaryOf(result: LabelCheckResult | null, pending: boolean): string | null {
+ if (pending) return "Das Etikett wird geprüft …";
+ if (!result) return null;
+ const missing = result.gaps.length;
+ if (missing === 0) return "Alle Pflichtangaben sind erfüllt.";
+ return missing === 1 ? "Es fehlt 1 Pflichtangabe." : `Es fehlen ${missing} Pflichtangaben.`;
+}
+
+function ParticularRow({ code, gap }: { code: LabelFindingCode; gap: LabelFinding | undefined }) {
+ const label = PARTICULAR_LABELS[code as keyof typeof PARTICULAR_LABELS];
+ return (
+
+ {gap ? (
+
+ ) : (
+
+ )}
+
+
+ {label}: {" "}
+
+ {gap ? "Fehlt" : "Erfüllt"}
+
+
+ {gap ? {gap.detail || gap.title} : null}
+
+
+ );
+}
+
+interface LabelCompliancePanelProps {
+ result: LabelCheckResult | null;
+ pending: boolean;
+ error: string | null;
+ /** `result` answers an older draft; the current one cannot be checked. */
+ stale?: boolean;
+}
+
+/**
+ * Shows the answer of the label check: the six particulars with symbol and
+ * word, the warnings apart (they never block printing) and the minimums the
+ * server used. The panel decides nothing itself.
+ */
+export function LabelCompliancePanel({
+ result,
+ pending,
+ error,
+ stale = false,
+}: LabelCompliancePanelProps) {
+ const summary = stale
+ ? "Veraltet: Dieser Stand passt nicht mehr zu deinen Eingaben."
+ : summaryOf(result, pending);
+ return (
+
+
+ Pflichtangaben
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {summary ? (
+
+ {pending ? : null}
+ {summary}
+
+ ) : null}
+ {result ? (
+ <>
+ {result.sample ? (
+
+ Geprüft mit Beispielwerten, nicht mit einer echten Abfüllung.
+
+ ) : null}
+
+ {PARTICULAR_CODES.map((code) => (
+ gap.code === code)}
+ />
+ ))}
+
+
+ Mindest-x-Höhe: {formatMm(result.requirements.minXHeightMm)}
+ {result.requirements.netQuantityMinCapHeightMm !== null
+ ? `. Ziffernhöhe der Füllmenge: mindestens ${formatMm(result.requirements.netQuantityMinCapHeightMm)}`
+ : ""}
+ .
+
+ {result.warnings.length > 0 ? (
+
+
Hinweise (sie sperren den Druck nicht)
+
+ {result.warnings.map((warning, index) => (
+
+
+
+ Hinweis: {warning.title}
+ {warning.detail ? (
+ {warning.detail}
+ ) : null}
+
+
+ ))}
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/medicines/no-read-access.tsx b/ApiCore-Frontend/src/components/medicines/no-read-access.tsx
new file mode 100644
index 0000000..f26e1d7
--- /dev/null
+++ b/ApiCore-Frontend/src/components/medicines/no-read-access.tsx
@@ -0,0 +1,18 @@
+import Link from "next/link";
+
+/** Shown instead of a page whose data needs `veterinary-medicines:read`: no retry, it would not help. */
+export function NoReadAccess() {
+ return (
+
+
+ Dazu fehlt dir das Recht.
+
+
+ Zurück zum Lager
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/medicines/rest-text.tsx b/ApiCore-Frontend/src/components/medicines/rest-text.tsx
new file mode 100644
index 0000000..1ee765c
--- /dev/null
+++ b/ApiCore-Frontend/src/components/medicines/rest-text.tsx
@@ -0,0 +1,20 @@
+import { AlertTriangle } from "lucide-react";
+import { formatQuantity } from "@/lib/medicines/format";
+import type { MedicineUnit } from "@/lib/medicines/types";
+
+/**
+ * A rest of a medicine. Below zero it is red AND carries a symbol and the
+ * word "Negativ": the treatment happened, the book has a gap, and the state
+ * must not rely on colour alone.
+ */
+export function RestText({ value, unit }: { value: number; unit: MedicineUnit }) {
+ if (value < 0) {
+ return (
+
+
+ {formatQuantity(value, unit)} · Negativ
+
+ );
+ }
+ return {formatQuantity(value, unit)} ;
+}
diff --git a/ApiCore-Frontend/src/components/today/compliance-line.spec.tsx b/ApiCore-Frontend/src/components/today/compliance-line.spec.tsx
new file mode 100644
index 0000000..b7df623
--- /dev/null
+++ b/ApiCore-Frontend/src/components/today/compliance-line.spec.tsx
@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ComplianceLine } from "./compliance-line";
+import userEvent from "@testing-library/user-event";
+import { ApiError } from "@/lib/auth/api-client";
+import { fetchReadinessSummary } from "@/lib/compliance/api";
+
+let permissions = ["compliance:read"];
+vi.mock("@/lib/auth/auth-context", () => ({
+ useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
+}));
+vi.mock("@/lib/compliance/api", () => ({ fetchReadinessSummary: vi.fn() }));
+
+function renderLine() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ComplianceLine", () => {
+ beforeEach(() => {
+ permissions = ["compliance:read"];
+ vi.mocked(fetchReadinessSummary).mockReset();
+ });
+
+ it("renders only when openCount > 0 and the user holds compliance:read", async () => {
+ vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "OPEN", openCount: 2, firstItemKey: "holder-data" });
+ renderLine();
+
+ const link = await screen.findByRole("link", { name: /2 Punkte offen/ });
+ expect(link).toHaveAttribute("href", "/compliance");
+ expect(link).toHaveTextContent("Prüfbereitschaft");
+ });
+
+ it("stays away when nothing is open", async () => {
+ vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "READY", openCount: 0, firstItemKey: null });
+ const { container } = renderLine();
+
+ await vi.waitFor(() => expect(fetchReadinessSummary).toHaveBeenCalled());
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("does not even ask without compliance:read", () => {
+ permissions = [];
+ const { container } = renderLine();
+
+ expect(fetchReadinessSummary).not.toHaveBeenCalled();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("says quietly that it was not loaded and offers a retry", async () => {
+ vi.mocked(fetchReadinessSummary).mockRejectedValueOnce(new ApiError(500, "Serverfehler"));
+ renderLine();
+
+ expect(await screen.findByText("Prüfbereitschaft nicht geladen.")).toBeInTheDocument();
+ vi.mocked(fetchReadinessSummary).mockResolvedValue({ status: "OPEN", openCount: 1, firstItemKey: "k" });
+ await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
+ expect(await screen.findByRole("link", { name: /1 Punkt offen/ })).toBeInTheDocument();
+ });
+
+ it("stays away when the device is offline", async () => {
+ vi.mocked(fetchReadinessSummary).mockRejectedValue(new TypeError("Failed to fetch"));
+ const { container } = renderLine();
+
+ await vi.waitFor(() => expect(fetchReadinessSummary).toHaveBeenCalled());
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/ApiCore-Frontend/src/components/today/compliance-line.tsx b/ApiCore-Frontend/src/components/today/compliance-line.tsx
new file mode 100644
index 0000000..47ee136
--- /dev/null
+++ b/ApiCore-Frontend/src/components/today/compliance-line.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import Link from "next/link";
+import { ChevronRight, RotateCw, XCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { isNetworkError } from "@/lib/http/fetch-with-timeout";
+import { useAuth } from "@/lib/auth/auth-context";
+import { useReadinessSummary } from "@/lib/compliance/use-readiness";
+
+/**
+ * One line on the dashboard when the Prüfbereitschaft has open items. Nothing
+ * at all otherwise, without the right to read it, or when it cannot be loaded.
+ */
+export function ComplianceLine() {
+ const { hasPermission } = useAuth();
+ const canRead = hasPermission("compliance:read");
+ const { data, error, isError, isFetching, refetch } = useReadinessSummary(canRead);
+
+ if (!canRead) return null;
+ // Offline is normal on the dashboard; any other failure is said, quietly.
+ if (!data && isError && !isNetworkError(error)) {
+ return (
+
+
Prüfbereitschaft nicht geladen.
+
void refetch()}>
+
+ Erneut versuchen
+
+
+ );
+ }
+ if (!data || data.openCount <= 0) return null;
+
+ const count = data.openCount === 1 ? "1 Punkt offen" : `${data.openCount} Punkte offen`;
+ return (
+
+
+
+ {count} in der Prüfbereitschaft
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/lib/auth/api-client.spec.ts b/ApiCore-Frontend/src/lib/auth/api-client.spec.ts
index 9dde697..6fd781a 100644
--- a/ApiCore-Frontend/src/lib/auth/api-client.spec.ts
+++ b/ApiCore-Frontend/src/lib/auth/api-client.spec.ts
@@ -94,6 +94,13 @@ describe("api-client", () => {
});
});
+ it("apiFetchBlob passes the request options on, e.g. cache: no-store", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response("pdf"));
+ vi.stubGlobal("fetch", fetchMock);
+ await apiFetchBlob("/x", "f.pdf", { cache: "no-store" });
+ expect((fetchMock.mock.calls[0][1] as RequestInit).cache).toBe("no-store");
+ });
+
it("apiFetchBlob builds an ApiError with the parsed body", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(403, { message: "Nein", why: 1 })));
const error = (await apiFetchBlob("/x", "f.csv").catch((e: unknown) => e)) as ApiError;
diff --git a/ApiCore-Frontend/src/lib/auth/api-client.ts b/ApiCore-Frontend/src/lib/auth/api-client.ts
index af5ada1..fb38b0f 100644
--- a/ApiCore-Frontend/src/lib/auth/api-client.ts
+++ b/ApiCore-Frontend/src/lib/auth/api-client.ts
@@ -58,13 +58,17 @@ function filenameFromContentDisposition(header: string | null, fallback: string)
return match?.[1] ?? fallback;
}
-export async function apiFetchBlob(path: string, fallbackFilename: string): Promise {
- let response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
+export async function apiFetchBlob(
+ path: string,
+ fallbackFilename: string,
+ init: RequestInit = {},
+): Promise {
+ let response = await rawFetch(path, init, DOWNLOAD_TIMEOUT_MS);
if (response.status === 401) {
const refreshed = await refreshRequest();
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
- response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
+ response = await rawFetch(path, init, DOWNLOAD_TIMEOUT_MS);
}
}
diff --git a/ApiCore-Frontend/src/lib/compliance/api.spec.ts b/ApiCore-Frontend/src/lib/compliance/api.spec.ts
new file mode 100644
index 0000000..859aba1
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/api.spec.ts
@@ -0,0 +1,54 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
+import { downloadBlob } from "@/lib/export/api";
+import {
+ acknowledge,
+ downloadReadinessReport,
+ fetchReadiness,
+ fetchReadinessSummary,
+ revokeAcknowledgement,
+} from "./api";
+
+vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchBlob: vi.fn() }));
+vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
+
+describe("compliance api", () => {
+ beforeEach(() => {
+ vi.mocked(apiFetch).mockReset();
+ vi.mocked(apiFetch).mockResolvedValue(undefined);
+ });
+
+ it("reads the report and the summary", async () => {
+ await fetchReadiness();
+ await fetchReadinessSummary();
+ expect(apiFetch).toHaveBeenNthCalledWith(1, "/compliance/readiness");
+ expect(apiFetch).toHaveBeenNthCalledWith(2, "/compliance/readiness/summary");
+ });
+
+ it("acknowledges with one idempotency key per call", async () => {
+ const input = {
+ itemKey: "k",
+ subjectId: "global",
+ fingerprint: "f".repeat(64),
+ note: "Mit dem Amt abgesprochen.",
+ };
+ await acknowledge(input);
+ const [path, init] = vi.mocked(apiFetch).mock.calls[0];
+ expect(path).toBe("/compliance/acknowledgements");
+ expect(init).toMatchObject({ method: "POST", body: JSON.stringify(input) });
+ expect((init?.headers as Record)["Idempotency-Key"]).toMatch(/^[A-Za-z0-9_-]{16,128}$/);
+ });
+
+ it("revokes by id", async () => {
+ await revokeAcknowledgement("a/1");
+ expect(apiFetch).toHaveBeenCalledWith("/compliance/acknowledgements/a%2F1/revoke", { method: "POST" });
+ });
+
+ it("downloads the report as pruefbericht.pdf", async () => {
+ const blob = new Blob(["x"]);
+ vi.mocked(apiFetchBlob).mockResolvedValue({ blob, filename: "pruefbericht.pdf" });
+ await downloadReadinessReport();
+ expect(apiFetchBlob).toHaveBeenCalledWith("/compliance/readiness/report.pdf", "pruefbericht.pdf");
+ expect(downloadBlob).toHaveBeenCalledWith(blob, "pruefbericht.pdf");
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/compliance/api.ts b/ApiCore-Frontend/src/lib/compliance/api.ts
new file mode 100644
index 0000000..a225b3b
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/api.ts
@@ -0,0 +1,32 @@
+import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
+import { downloadBlob } from "@/lib/export/api";
+import { idempotencyHeaders, newIdempotencyKey } from "@/lib/offline/idempotency";
+import type { AcknowledgeInput, ReadinessReport, ReadinessSummary } from "./types";
+
+export function fetchReadiness(): Promise {
+ return apiFetch("/compliance/readiness");
+}
+
+export function fetchReadinessSummary(): Promise {
+ return apiFetch("/compliance/readiness/summary");
+}
+
+export function acknowledge(input: AcknowledgeInput): Promise {
+ return apiFetch("/compliance/acknowledgements", {
+ method: "POST",
+ headers: idempotencyHeaders({ idempotencyKey: newIdempotencyKey() }),
+ body: JSON.stringify(input),
+ });
+}
+
+export function revokeAcknowledgement(id: string): Promise {
+ return apiFetch(`/compliance/acknowledgements/${encodeURIComponent(id)}/revoke`, {
+ method: "POST",
+ });
+}
+
+/** The Prüfbericht as PDF (authenticated download, never cached). */
+export async function downloadReadinessReport(): Promise {
+ const { blob, filename } = await apiFetchBlob("/compliance/readiness/report.pdf", "pruefbericht.pdf");
+ downloadBlob(blob, filename);
+}
diff --git a/ApiCore-Frontend/src/lib/compliance/errors.spec.ts b/ApiCore-Frontend/src/lib/compliance/errors.spec.ts
new file mode 100644
index 0000000..0395fb4
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/errors.spec.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { ApiError } from "@/lib/auth/api-client";
+import { complianceErrorText, draftExists, readinessConflictText, restrictedZone, staleFingerprint } from "./errors";
+
+const conflict = (body: unknown) => new ApiError(409, "x", body);
+
+describe("compliance errors", () => {
+ it("reads DRAFT_EXISTS with existingId, RESTRICTED_ZONE with its restrictions and a stale fingerprint", () => {
+ expect(
+ draftExists(conflict({ code: "DRAFT_EXISTS", existingId: "n1", message: "Es gibt schon einen Entwurf." })),
+ ).toEqual({ existingId: "n1", message: "Es gibt schon einen Entwurf." });
+
+ const restriction = { id: "r1", apiaryId: "a1", apiaryName: "Wiese", disease: "AFB", startsOn: "2026-10-01" };
+ expect(
+ restrictedZone(
+ conflict({ code: "RESTRICTED_ZONE", message: "Sperrbezirk.", restrictionIds: ["r1"], restrictions: [restriction] }),
+ ),
+ ).toEqual({ message: "Sperrbezirk.", restrictionIds: ["r1"], restrictions: [restriction] });
+
+ expect(staleFingerprint(conflict({ message: "Der Punkt hat sich geändert. Bitte neu laden." }))).toBe(
+ "Der Punkt hat sich geändert. Bitte neu laden.",
+ );
+ });
+
+ it("ignores other errors", () => {
+ expect(draftExists(conflict({ code: "RESTRICTED_ZONE" }))).toBeNull();
+ expect(draftExists(new ApiError(400, "x", { code: "DRAFT_EXISTS", existingId: "n1" }))).toBeNull();
+ expect(draftExists(conflict({ code: "DRAFT_EXISTS", existingId: 5 }))).toBeNull();
+ expect(restrictedZone(conflict({ code: "DRAFT_EXISTS" }))).toBeNull();
+ expect(restrictedZone(new Error("boom"))).toBeNull();
+ expect(staleFingerprint(conflict({ message: "Die Quittung ist schon zurückgenommen." }))).toBeNull();
+ expect(staleFingerprint(null)).toBeNull();
+ });
+
+ it("drops malformed restrictions instead of throwing", () => {
+ expect(
+ restrictedZone(
+ conflict({ code: "RESTRICTED_ZONE", message: "m", restrictionIds: ["r1", 3], restrictions: [{ id: 1 }, null] }),
+ ),
+ ).toEqual({ message: "m", restrictionIds: ["r1"], restrictions: [] });
+ });
+
+ it("builds on saveErrorText for the text line", () => {
+ expect(complianceErrorText(new ApiError(500, "Serverfehler"), "Fallback")).toBe("Serverfehler");
+ expect(complianceErrorText(new Error("x"), "Fallback")).toBe("Fallback");
+ });
+
+ it("gives each conflict code its own German text", () => {
+ const texts = ["READINESS_FINGERPRINT_STALE", "READINESS_ITEM_NOT_OPEN", "READINESS_ACK_REVOKED"].map((code) =>
+ readinessConflictText(conflict({ code, message: "English or whatever" })),
+ );
+ expect(texts.every((text) => typeof text === "string" && text.length > 0)).toBe(true);
+ expect(new Set(texts).size).toBe(3);
+ expect(texts[0]).toContain("geändert");
+ expect(texts[1]).toContain("nicht mehr offen");
+ expect(texts[2]).toContain("zurückgenommen");
+ });
+
+ it("prefers the code over the message and falls back to the message text", () => {
+ expect(
+ readinessConflictText(
+ conflict({ code: "READINESS_ITEM_NOT_OPEN", message: "Der Punkt hat sich geändert. Bitte neu laden." }),
+ ),
+ ).toContain("nicht mehr offen");
+ expect(readinessConflictText(conflict({ message: "Der Punkt hat sich geändert. Bitte neu laden." }))).toBe(
+ "Der Punkt hat sich geändert. Bitte neu laden.",
+ );
+ expect(readinessConflictText(conflict({ code: "SOMETHING_ELSE", message: "x" }))).toBeNull();
+ expect(readinessConflictText(new ApiError(500, "x", { code: "READINESS_ACK_REVOKED" }))).toBeNull();
+ expect(readinessConflictText(new Error("x"))).toBeNull();
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/compliance/errors.ts b/ApiCore-Frontend/src/lib/compliance/errors.ts
new file mode 100644
index 0000000..9316a15
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/errors.ts
@@ -0,0 +1,97 @@
+import { ApiError } from "@/lib/auth/api-client";
+import { saveErrorText } from "@/lib/honey-batches/errors";
+
+/** The server's wording for an acknowledgement whose item changed meanwhile. */
+const STALE_FINGERPRINT_MESSAGE = "Der Punkt hat sich geändert. Bitte neu laden.";
+
+export interface DraftExists {
+ existingId: string;
+ message: string;
+}
+
+export interface RestrictionInfo {
+ id: string;
+ apiaryId: string;
+ apiaryName: string;
+ disease: string;
+ startsOn: string;
+}
+
+export interface RestrictedZone {
+ message: string;
+ restrictionIds: string[];
+ restrictions: RestrictionInfo[];
+}
+
+function conflictBody(error: unknown): Record | null {
+ if (!(error instanceof ApiError) || error.status !== 409) return null;
+ return typeof error.body === "object" && error.body !== null
+ ? (error.body as Record)
+ : null;
+}
+
+function textOf(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+function isRestrictionInfo(value: unknown): value is RestrictionInfo {
+ if (typeof value !== "object" || value === null) return false;
+ const row = value as Record;
+ return ["id", "apiaryId", "apiaryName", "disease", "startsOn"].every(
+ (key) => typeof row[key] === "string",
+ );
+}
+
+/** A 409 `DRAFT_EXISTS`: the id of the draft that is already open. */
+export function draftExists(error: unknown): DraftExists | null {
+ const body = conflictBody(error);
+ if (body?.code !== "DRAFT_EXISTS" || typeof body.existingId !== "string") return null;
+ return { existingId: body.existingId, message: textOf(body.message) };
+}
+
+/** A 409 `RESTRICTED_ZONE`: the restrictions that block the move or the change. */
+export function restrictedZone(error: unknown): RestrictedZone | null {
+ const body = conflictBody(error);
+ if (body?.code !== "RESTRICTED_ZONE") return null;
+ const ids = Array.isArray(body.restrictionIds) ? body.restrictionIds : [];
+ const rows = Array.isArray(body.restrictions) ? body.restrictions : [];
+ return {
+ message: textOf(body.message),
+ restrictionIds: ids.filter((id): id is string => typeof id === "string"),
+ restrictions: rows.filter(isRestrictionInfo),
+ };
+}
+
+/** Codes of a 409 on acknowledging or revoking, each with its own German text. */
+const READINESS_CONFLICT_TEXT: Readonly> = {
+ READINESS_FINGERPRINT_STALE:
+ "Der Punkt hat sich inzwischen geändert. Die Liste wird neu geladen, bitte prüf ihn noch einmal.",
+ READINESS_ITEM_NOT_OPEN: "Dieser Punkt ist nicht mehr offen. Die Liste wird neu geladen.",
+ READINESS_ACK_REVOKED: "Diese Quittung ist schon zurückgenommen. Die Liste wird neu geladen.",
+};
+
+/**
+ * A 409 on an acknowledgement whose item changed since the page was loaded
+ * (stale fingerprint): the server text. Fallback for a server that sends no
+ * code; `readinessConflictText` prefers the code.
+ */
+export function staleFingerprint(error: unknown): string | null {
+ const body = conflictBody(error);
+ return body && textOf(body.message) === STALE_FINGERPRINT_MESSAGE ? STALE_FINGERPRINT_MESSAGE : null;
+}
+
+/**
+ * The German text for a 409 of the acknowledgement endpoints: by `body.code`
+ * (stale fingerprint, item not open, already revoked); the message match is
+ * only the fallback. `null` for every other error.
+ */
+export function readinessConflictText(error: unknown): string | null {
+ const body = conflictBody(error);
+ const code = typeof body?.code === "string" ? body.code : "";
+ return READINESS_CONFLICT_TEXT[code] ?? staleFingerprint(error);
+}
+
+/** One line for every other failure: the shared reader, no second error logic. */
+export function complianceErrorText(error: unknown, fallback: string): string {
+ return saveErrorText(error, fallback);
+}
diff --git a/ApiCore-Frontend/src/lib/compliance/fix-links.spec.ts b/ApiCore-Frontend/src/lib/compliance/fix-links.spec.ts
new file mode 100644
index 0000000..37a465b
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/fix-links.spec.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { ROUTE_AVAILABLE, fixLinkFor } from "./fix-links";
+
+const STAMMDATEN = "/settings/automation?tab=social-media";
+
+describe("fixLinkFor", () => {
+ it.each([
+ ["medicine-lot-gaps", { type: "lot", id: "l1" }, "/medicines/lots/l1"],
+ ["treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }, "/colonies/c1"],
+ ["label-gaps", { type: "template", id: "x1" }, "/settings/label-templates/x1"],
+ ["harvest-withdrawal", { type: "harvest", id: "h1" }, "/harvests/h1"],
+ ["registration", { type: "notice", id: "n1" }, "/compliance/notices/n1"],
+ ["registration", { type: "AUTHORITY_NOTICE", id: "n2" }, "/compliance/notices/n2"],
+ ["restriction-active", { type: "apiary", id: "a1" }, "/compliance/restrictions"],
+ ["restriction-active", { type: "APIARY", id: "a1" }, "/compliance/restrictions"],
+ ["restriction-active", { type: "restriction", id: "r1" }, "/compliance/restrictions"],
+ ["herd-change", { type: "APIARY", id: "a1" }, "/compliance/notices"],
+ ["vet-office", { type: "COMPLIANCE_SETTINGS", id: "global" }, "/compliance/settings"],
+ ])("maps %s with %j to %s", (itemKey, subject, href) => {
+ expect(fixLinkFor(itemKey, subject).href).toBe(href);
+ });
+
+ it.each([
+ ["holder-data", STAMMDATEN],
+ ["food-business-data", STAMMDATEN],
+ ["vet-office", "/compliance/settings"],
+ ["registration", "/compliance/notices"],
+ ["herd-change", "/compliance/notices"],
+ ["tsk-report", "/compliance/notices"],
+ ["restriction-active", "/compliance/restrictions"],
+ ["medicine-withdrawal-missing", "/medicines"],
+ ])("maps the global item %s to %s", (itemKey, href) => {
+ expect(fixLinkFor(itemKey, null).href).toBe(href);
+ });
+
+ it("treats the site settings subject like a global item", () => {
+ expect(fixLinkFor("holder-data", { type: "SITE_SETTINGS", id: "global" }).href).toBe(STAMMDATEN);
+ });
+
+ it("encodes ids", () => {
+ expect(fixLinkFor("x", { type: "lot", id: "a/b" }).href).toBe("/medicines/lots/a%2Fb");
+ });
+
+ it("falls back to the compliance page and always says Beheben", () => {
+ expect(fixLinkFor("unknown-rule", null)).toEqual({ href: "/compliance", label: "Beheben", available: false });
+ expect(fixLinkFor("holder-data", null).label).toBe("Beheben");
+ });
+
+ it("links to the colony page for a treatment, no parameter nothing reads", () => {
+ expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }).href).toBe("/colonies/c1");
+ expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1" }).available).toBe(false);
+ });
+
+ it.each([
+ ["registration", { type: "notice", id: "n1" }],
+ ["restriction-active", { type: "apiary", id: "a1" }],
+ ["vet-office", null],
+ ["herd-change", null],
+ ])("hides %s until its target page exists", (itemKey, subject) => {
+ expect(fixLinkFor(itemKey, subject).available).toBe(false);
+ });
+
+ it.each([
+ ["holder-data", null],
+ ["medicine-lot-gaps", { type: "lot", id: "l1" }],
+ ["treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }],
+ ["label-gaps", { type: "template", id: "x1" }],
+ ["harvest-withdrawal", { type: "harvest", id: "h1" }],
+ ["medicine-withdrawal-missing", null],
+ ])("shows %s, its page exists", (itemKey, subject) => {
+ expect(fixLinkFor(itemKey, subject).available).toBe(true);
+ });
+
+ it("is switched on per route in one set", () => {
+ expect(ROUTE_AVAILABLE.has("/compliance/notices")).toBe(false);
+ expect(ROUTE_AVAILABLE.has("/compliance/restrictions")).toBe(false);
+ expect(ROUTE_AVAILABLE.has("/compliance/settings")).toBe(false);
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/compliance/fix-links.ts b/ApiCore-Frontend/src/lib/compliance/fix-links.ts
new file mode 100644
index 0000000..cae744b
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/fix-links.ts
@@ -0,0 +1,96 @@
+const FIX_LABEL = "Beheben";
+
+/**
+ * The pages that exist. A „Beheben“ link to any other route is hidden, so
+ * nobody lands on a 404. Tasks 18 and 19 add their routes here to switch the
+ * links on: `/compliance/notices`, `/compliance/notices/[id]`,
+ * `/compliance/restrictions` and `/compliance/settings`.
+ */
+export const ROUTE_AVAILABLE: ReadonlySet = new Set([
+ "/colonies/[id]",
+ "/harvests/[id]",
+ "/medicines",
+ "/medicines/lots/[id]",
+ "/settings/automation",
+ "/settings/label-templates/[id]",
+]);
+
+/** The tab "Stammdaten & Web" under Verwaltung > Automatisierung. */
+const MASTER_DATA = "/settings/automation?tab=social-media";
+
+interface Target {
+ /** The route pattern, as a key of ROUTE_AVAILABLE. */
+ route: string;
+ href: string;
+}
+
+/** A rule without a record of its own, or the fallback when nothing else fits. */
+const TARGET_BY_ITEM_KEY: Readonly> = {
+ "holder-data": { route: "/settings/automation", href: MASTER_DATA },
+ "food-business-data": { route: "/settings/automation", href: MASTER_DATA },
+ "vet-office": { route: "/compliance/settings", href: "/compliance/settings" },
+ registration: { route: "/compliance/notices", href: "/compliance/notices" },
+ "herd-change": { route: "/compliance/notices", href: "/compliance/notices" },
+ "tsk-report": { route: "/compliance/notices", href: "/compliance/notices" },
+ "restriction-active": { route: "/compliance/restrictions", href: "/compliance/restrictions" },
+ "medicine-withdrawal-missing": { route: "/medicines", href: "/medicines" },
+};
+
+/** This page itself: never a fix target. */
+const COMPLIANCE_HOME: Target = { route: "/compliance", href: "/compliance" };
+
+export interface FixSubject {
+ type: string;
+ id: string;
+ parentId?: string;
+}
+
+export interface FixLink {
+ href: string;
+ label: string;
+ /** False while the target page does not exist yet: show no link. */
+ available: boolean;
+}
+
+function targetForSubject(itemKey: string, subject: FixSubject): Target | null {
+ const id = encodeURIComponent(subject.id);
+ switch (subject.type.toLowerCase()) {
+ case "lot":
+ return { route: "/medicines/lots/[id]", href: `/medicines/lots/${id}` };
+ case "treatment":
+ // The treatments are listed (and edited) on the colony page.
+ return subject.parentId
+ ? { route: "/colonies/[id]", href: `/colonies/${encodeURIComponent(subject.parentId)}` }
+ : null;
+ case "template":
+ return { route: "/settings/label-templates/[id]", href: `/settings/label-templates/${id}` };
+ case "harvest":
+ return { route: "/harvests/[id]", href: `/harvests/${id}` };
+ case "notice":
+ case "authority_notice":
+ return { route: "/compliance/notices/[id]", href: `/compliance/notices/${id}` };
+ case "apiary":
+ // A herd change is settled by a notice; every other apiary item is a restriction.
+ return itemKey === "herd-change"
+ ? TARGET_BY_ITEM_KEY["herd-change"]
+ : TARGET_BY_ITEM_KEY["restriction-active"];
+ case "restriction":
+ return TARGET_BY_ITEM_KEY["restriction-active"];
+ case "compliance_settings":
+ return TARGET_BY_ITEM_KEY["vet-office"];
+ default:
+ return null;
+ }
+}
+
+/**
+ * The ONE place that maps a readiness subject to the page that fixes it.
+ * Without a subject of its own (or for the settings subjects) the rule decides.
+ */
+export function fixLinkFor(itemKey: string, subject: FixSubject | null): FixLink {
+ const target =
+ (subject ? targetForSubject(itemKey, subject) : null) ??
+ TARGET_BY_ITEM_KEY[itemKey] ??
+ COMPLIANCE_HOME;
+ return { href: target.href, label: FIX_LABEL, available: ROUTE_AVAILABLE.has(target.route) };
+}
diff --git a/ApiCore-Frontend/src/lib/compliance/fixtures.ts b/ApiCore-Frontend/src/lib/compliance/fixtures.ts
new file mode 100644
index 0000000..1c49dc1
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/fixtures.ts
@@ -0,0 +1,35 @@
+import type { ReadinessItem, ReadinessReport } from "./types";
+
+/** Test data for the readiness report; override only what a test is about. */
+export function itemFixture(overrides: Partial = {}): ReadinessItem {
+ return {
+ key: "holder-data",
+ area: "STAMMDATEN",
+ severity: "PFLICHT",
+ subjectId: "global",
+ title: "Angaben zum Bienenhalter fehlen",
+ detail: "Es fehlen: Name, Anschrift.",
+ legalBasis: null,
+ acknowledgeable: false,
+ fingerprint: "f".repeat(64),
+ subjects: [],
+ more: 0,
+ state: "OPEN",
+ acknowledgement: null,
+ ...overrides,
+ };
+}
+
+export function reportFixture(
+ items: ReadinessItem[],
+ overrides: Partial = {},
+): ReadinessReport {
+ const openCount = items.filter((item) => item.state === "OPEN").length;
+ return {
+ status: openCount > 0 ? "OPEN" : "READY",
+ generatedAt: "2026-10-15T08:00:00.000Z",
+ openCount,
+ items,
+ ...overrides,
+ };
+}
diff --git a/ApiCore-Frontend/src/lib/compliance/labels.spec.ts b/ApiCore-Frontend/src/lib/compliance/labels.spec.ts
new file mode 100644
index 0000000..31500ad
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/labels.spec.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { AREA_LABEL, AREA_ORDER, SEVERITY_LABEL, STATE_LABEL, itemTone } from "./labels";
+
+describe("compliance labels", () => {
+ it("maps the severity to its German word", () => {
+ expect(SEVERITY_LABEL).toEqual({ PFLICHT: "Pflicht", PRUEFEN: "Prüfen" });
+ });
+
+ it("names the five areas in a fixed order", () => {
+ expect(AREA_ORDER).toEqual(["STAMMDATEN", "BEHOERDEN", "TIERARZNEIMITTEL", "ETIKETTEN", "ERNTE"]);
+ expect(AREA_ORDER.every((area) => AREA_LABEL[area].length > 0)).toBe(true);
+ });
+
+ it("shows every open item red with symbol and word, whatever its severity", () => {
+ expect(itemTone("OPEN")).toEqual({ tone: "destructive", word: "Offen" });
+ expect(STATE_LABEL).toEqual({ OPEN: "Offen", DONE: "Erledigt", ACKNOWLEDGED: "Quittiert" });
+ expect(itemTone("DONE").tone).not.toBe("destructive");
+ expect(itemTone("ACKNOWLEDGED").tone).not.toBe("destructive");
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/compliance/labels.ts b/ApiCore-Frontend/src/lib/compliance/labels.ts
new file mode 100644
index 0000000..840475c
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/labels.ts
@@ -0,0 +1,44 @@
+import type { ComponentProps } from "react";
+import type { Badge } from "@/components/ui/badge";
+import type { ReadinessArea, ReadinessItemState, ReadinessSeverity } from "./types";
+
+/** The severity is always a word next to the title, never only a colour. */
+export const SEVERITY_LABEL: Record = {
+ PFLICHT: "Pflicht",
+ PRUEFEN: "Prüfen",
+};
+
+export const AREA_ORDER: readonly ReadinessArea[] = [
+ "STAMMDATEN",
+ "BEHOERDEN",
+ "TIERARZNEIMITTEL",
+ "ETIKETTEN",
+ "ERNTE",
+];
+
+export const AREA_LABEL: Record = {
+ STAMMDATEN: "Stammdaten",
+ BEHOERDEN: "Behörden",
+ TIERARZNEIMITTEL: "Tierarzneimittel",
+ ETIKETTEN: "Etiketten",
+ ERNTE: "Ernte",
+};
+
+export const STATE_LABEL: Record = {
+ OPEN: "Offen",
+ DONE: "Erledigt",
+ ACKNOWLEDGED: "Quittiert",
+};
+
+export interface ItemTone {
+ tone: NonNullable["variant"]>;
+ word: string;
+}
+
+/** Every open item is red (token destructive), whatever its severity. */
+export function itemTone(state: ReadinessItemState): ItemTone {
+ const word = STATE_LABEL[state];
+ if (state === "OPEN") return { tone: "destructive", word };
+ if (state === "ACKNOWLEDGED") return { tone: "outline", word };
+ return { tone: "secondary", word };
+}
diff --git a/ApiCore-Frontend/src/lib/compliance/types.ts b/ApiCore-Frontend/src/lib/compliance/types.ts
new file mode 100644
index 0000000..011843a
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/types.ts
@@ -0,0 +1,63 @@
+// Mirrors the backend's readiness report (ApiCore-Backend/src/compliance/readiness/readiness.types.ts).
+
+export type ReadinessSeverity = "PFLICHT" | "PRUEFEN";
+export type ReadinessArea =
+ | "STAMMDATEN"
+ | "BEHOERDEN"
+ | "TIERARZNEIMITTEL"
+ | "ETIKETTEN"
+ | "ERNTE";
+export type ReadinessItemState = "OPEN" | "DONE" | "ACKNOWLEDGED";
+export type ReadinessStatus = "READY" | "OPEN";
+
+export interface ReadinessSubject {
+ type: string;
+ id: string;
+ label: string;
+ missing?: string[];
+ /** e.g. the colony of a treatment (link target). */
+ parentId?: string;
+}
+
+export interface ReadinessAcknowledgement {
+ id: string;
+ note: string;
+ userName: string;
+ createdAt: string;
+}
+
+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: ReadinessItemState;
+ acknowledgement: ReadinessAcknowledgement | null;
+}
+
+export interface ReadinessReport {
+ status: ReadinessStatus;
+ generatedAt: string;
+ openCount: number;
+ items: ReadinessItem[];
+}
+
+export interface ReadinessSummary {
+ status: ReadinessStatus;
+ openCount: number;
+ firstItemKey: string | null;
+}
+
+export interface AcknowledgeInput {
+ itemKey: string;
+ subjectId: string;
+ fingerprint: string;
+ note: string;
+}
diff --git a/ApiCore-Frontend/src/lib/compliance/use-readiness.ts b/ApiCore-Frontend/src/lib/compliance/use-readiness.ts
new file mode 100644
index 0000000..e21cd9f
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/compliance/use-readiness.ts
@@ -0,0 +1,22 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { fetchReadiness, fetchReadinessSummary } from "./api";
+
+/** Every compliance query starts with this key, so one invalidation reloads all. */
+export const complianceKey = ["compliance"] as const;
+export const readinessKey = [...complianceKey, "readiness"] as const;
+export const readinessSummaryKey = [...complianceKey, "readiness", "summary"] as const;
+
+export function useReadiness() {
+ return useQuery({ queryKey: readinessKey, queryFn: fetchReadiness });
+}
+
+export function useReadinessSummary(enabled: boolean) {
+ return useQuery({
+ queryKey: readinessSummaryKey,
+ queryFn: fetchReadinessSummary,
+ enabled,
+ retry: false,
+ });
+}
diff --git a/ApiCore-Frontend/src/lib/documents/types.spec.ts b/ApiCore-Frontend/src/lib/documents/types.spec.ts
new file mode 100644
index 0000000..fce7ed6
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/documents/types.spec.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { DOCUMENT_ENTITY_TYPES } from "./types";
+
+describe("DOCUMENT_ENTITY_TYPES", () => {
+ it("knows the three compliance kinds besides the existing ones", () => {
+ expect(DOCUMENT_ENTITY_TYPES).toEqual([
+ "APIARY",
+ "COLONY",
+ "QUEEN",
+ "INSPECTION",
+ "HONEY_BATCH",
+ "MEDICINE_LOT",
+ "AUTHORITY_NOTICE",
+ "APIARY_RESTRICTION",
+ ]);
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/documents/types.ts b/ApiCore-Frontend/src/lib/documents/types.ts
index 007a360..f93df79 100644
--- a/ApiCore-Frontend/src/lib/documents/types.ts
+++ b/ApiCore-Frontend/src/lib/documents/types.ts
@@ -1,4 +1,15 @@
-export type DocumentEntityType = "APIARY" | "COLONY" | "QUEEN" | "INSPECTION" | "HONEY_BATCH";
+export const DOCUMENT_ENTITY_TYPES = [
+ "APIARY",
+ "COLONY",
+ "QUEEN",
+ "INSPECTION",
+ "HONEY_BATCH",
+ "MEDICINE_LOT",
+ "AUTHORITY_NOTICE",
+ "APIARY_RESTRICTION",
+] as const;
+
+export type DocumentEntityType = (typeof DOCUMENT_ENTITY_TYPES)[number];
export interface DocumentSummary {
id: string;
diff --git a/ApiCore-Frontend/src/lib/label-templates/api.ts b/ApiCore-Frontend/src/lib/label-templates/api.ts
index cc6b269..c636067 100644
--- a/ApiCore-Frontend/src/lib/label-templates/api.ts
+++ b/ApiCore-Frontend/src/lib/label-templates/api.ts
@@ -1,5 +1,4 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
-import { downloadBlob } from "@/lib/export/api";
import { CreateLabelTemplateInput, LabelTemplate, UpdateLabelTemplateInput } from "./types";
export function fetchLabelTemplates(): Promise {
@@ -27,15 +26,32 @@ export function deactivateLabelTemplate(id: string): Promise {
return apiFetch(`/label-templates/${encodeURIComponent(id)}`, { method: "DELETE" });
}
+export interface PrintLabelsOptions {
+ /** 1 = top left of the sheet; omitted prints from the start. */
+ startPosition?: number;
+ /** A test print stamped „MUSTER – nicht verkaufsfähig“; always allowed. */
+ proof?: boolean;
+}
+
+/**
+ * The label PDF. A 409 `LABEL_PARTICULARS_MISSING` throws an `ApiError` whose
+ * `body.gaps` lists what is missing (`labelParticularsMissing` reads it).
+ */
export async function printLabels(
templateId: string,
bottlingBatchId: string,
- startPosition?: number,
-): Promise {
- const query = startPosition && startPosition > 1 ? `?startPosition=${startPosition}` : "";
- const { blob, filename } = await apiFetchBlob(
+ options: PrintLabelsOptions = {},
+): Promise {
+ const params = new URLSearchParams();
+ if (options.startPosition && options.startPosition > 1) {
+ params.set("startPosition", String(options.startPosition));
+ }
+ if (options.proof) params.set("proof", "true");
+ const query = params.size > 0 ? `?${params.toString()}` : "";
+ const { blob } = await apiFetchBlob(
`/label-templates/${encodeURIComponent(templateId)}/print/${encodeURIComponent(bottlingBatchId)}${query}`,
"etiketten.pdf",
+ { cache: "no-store" },
);
- downloadBlob(blob, filename);
+ return blob;
}
diff --git a/ApiCore-Frontend/src/lib/label-templates/check.spec.ts b/ApiCore-Frontend/src/lib/label-templates/check.spec.ts
new file mode 100644
index 0000000..cd1105a
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/label-templates/check.spec.ts
@@ -0,0 +1,259 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { apiFetch, ApiError } from "@/lib/auth/api-client";
+import {
+ checkDraftTemplate,
+ checkTemplateForBottling,
+ labelCheckErrorText,
+ labelParticularsMissing,
+ useDebouncedLabelCheck,
+ type LabelCheckResult,
+} from "./check";
+import type { LabelElement } from "./types";
+
+vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
+ ...(await importOriginal()),
+ apiFetch: vi.fn(),
+}));
+
+const RESULT: LabelCheckResult = {
+ printable: true,
+ gaps: [],
+ warnings: [],
+ sample: true,
+ requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: null },
+};
+
+const TEXT: LabelElement = {
+ id: "t1",
+ type: "text",
+ x: 1,
+ y: 2,
+ width: 40,
+ height: 8,
+ text: "Honig",
+ placeholderKey: null,
+ fontSize: 8,
+ fontFamily: "sans",
+ bold: false,
+ italic: false,
+ color: "#000000",
+ align: "left",
+};
+const QR: LabelElement = { id: "q1", type: "qrCode", x: 0, y: 0, size: 10 };
+const LINE: LabelElement = {
+ id: "l1",
+ type: "line",
+ x: 0,
+ y: 0,
+ length: 10,
+ thickness: 1,
+ color: "#000000",
+ orientation: "horizontal",
+};
+
+const DRAFT = { elements: [TEXT, QR, LINE], widthMm: 90, heightMm: 35, minXHeightMm: 1.2 };
+
+function sentBody(): { elements: { id: string }[]; [key: string]: unknown } {
+ const init = vi.mocked(apiFetch).mock.calls.at(-1)![1] as RequestInit;
+ return JSON.parse(String(init.body));
+}
+
+beforeEach(() => {
+ vi.mocked(apiFetch).mockReset();
+ vi.mocked(apiFetch).mockResolvedValue(RESULT);
+});
+
+describe("checkDraftTemplate", () => {
+ it("posts the text elements only", async () => {
+ await checkDraftTemplate(DRAFT);
+
+ expect(apiFetch).toHaveBeenCalledWith(
+ "/label-templates/check",
+ expect.objectContaining({ method: "POST" }),
+ );
+ const body = sentBody();
+ expect(body.elements.map((element) => element.id)).toEqual(["t1"]);
+ expect(body).toMatchObject({ widthMm: 90, heightMm: 35, minXHeightMm: 1.2 });
+ expect(body).not.toHaveProperty("bottlingBatchId");
+ });
+
+ it("sends the bottling batch when one is given", async () => {
+ await checkDraftTemplate({ ...DRAFT, bottlingBatchId: "b-1" });
+ expect(sentBody()).toMatchObject({ bottlingBatchId: "b-1" });
+ });
+});
+
+describe("checkTemplateForBottling", () => {
+ it("reads the check of a saved template for one bottling", async () => {
+ await checkTemplateForBottling("tpl 1", "b-1");
+ expect(apiFetch).toHaveBeenCalledWith("/label-templates/tpl%201/check/b-1", {
+ cache: "no-store",
+ });
+ });
+});
+
+describe("labelParticularsMissing", () => {
+ const gaps = [{ code: "ORIGIN", title: "Herkunftsland fehlt", detail: "d", elementIds: [] }];
+
+ it("reads the gaps of a 409 LABEL_PARTICULARS_MISSING", () => {
+ const error = new ApiError(409, "Pflichtangaben fehlen.", {
+ code: "LABEL_PARTICULARS_MISSING",
+ message: "Pflichtangaben fehlen.",
+ gaps,
+ });
+ expect(labelParticularsMissing(error)).toEqual({ message: "Pflichtangaben fehlen.", gaps });
+ });
+
+ it("ignores other errors and malformed bodies", () => {
+ expect(labelParticularsMissing(new Error("x"))).toBeNull();
+ expect(labelParticularsMissing(new ApiError(409, "x", { code: "OTHER" }))).toBeNull();
+ expect(
+ labelParticularsMissing(new ApiError(500, "x", { code: "LABEL_PARTICULARS_MISSING", gaps })),
+ ).toBeNull();
+ expect(
+ labelParticularsMissing(
+ new ApiError(409, "x", { code: "LABEL_PARTICULARS_MISSING", gaps: "no" }),
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("labelCheckErrorText", () => {
+ it("explains a missing permission", () => {
+ expect(labelCheckErrorText(new ApiError(403, "Forbidden"), "f")).toMatch(/Berechtigung/);
+ });
+
+ it("gives the invalid compliance settings a clear German message", () => {
+ const error = new ApiError(409, "Compliance-Einstellungen fehlen oder sind ungültig.", {
+ code: "COMPLIANCE_SETTINGS_INVALID",
+ message: "Compliance-Einstellungen fehlen oder sind ungültig.",
+ });
+ expect(labelCheckErrorText(error, "f")).toMatch(/Compliance-Einstellungen/);
+ });
+
+ it("falls back for unknown errors", () => {
+ expect(labelCheckErrorText(new Error("boom"), "Fallback")).toBe("Fallback");
+ });
+});
+
+describe("useDebouncedLabelCheck", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it("waits 500 ms after the last change before it asks the server", async () => {
+ const { result, rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
+ initialProps: { draft: DRAFT },
+ });
+ expect(result.current.pending).toBe(true);
+
+ await act(async () => {
+ vi.advanceTimersByTime(300);
+ });
+ rerender({ draft: { ...DRAFT, widthMm: 91 } });
+ await act(async () => {
+ vi.advanceTimersByTime(499);
+ });
+ expect(apiFetch).not.toHaveBeenCalled();
+
+ await act(async () => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(apiFetch).toHaveBeenCalledTimes(1);
+ expect(sentBody()).toMatchObject({ widthMm: 91 });
+ expect(result.current.result).toEqual(RESULT);
+ expect(result.current.pending).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("does not ask again for an equal draft", async () => {
+ const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
+ initialProps: { draft: DRAFT },
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ rerender({ draft: { ...DRAFT, elements: [...DRAFT.elements] } });
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(apiFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores changes to elements that are not text", async () => {
+ const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
+ initialProps: { draft: DRAFT },
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ rerender({ draft: { ...DRAFT, elements: [TEXT, { ...QR, x: 9 }, LINE] } });
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(apiFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports a failed check as a German error", async () => {
+ vi.mocked(apiFetch).mockRejectedValue(new ApiError(403, "Forbidden"));
+ const { result } = renderHook(() => useDebouncedLabelCheck(DRAFT));
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(result.current.error).toMatch(/Berechtigung/);
+ expect(result.current.pending).toBe(false);
+ });
+
+ it("asks without caching and with an abort signal, and aborts a request that a newer draft replaces", async () => {
+ vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
+ const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
+ initialProps: { draft: DRAFT },
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ const init = vi.mocked(apiFetch).mock.calls[0][1] as RequestInit;
+ expect(init.cache).toBe("no-store");
+ expect(init.signal?.aborted).toBe(false);
+
+ rerender({ draft: { ...DRAFT, widthMm: 95 } });
+ expect(init.signal?.aborted).toBe(true);
+ });
+
+ it("aborts the request when the component goes away", async () => {
+ vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
+ const { unmount } = renderHook(() => useDebouncedLabelCheck(DRAFT));
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ const init = vi.mocked(apiFetch).mock.calls[0][1] as RequestInit;
+ unmount();
+ expect(init.signal?.aborted).toBe(true);
+ });
+
+ it("marks the old result stale when the draft becomes invalid", async () => {
+ const { result, rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
+ initialProps: { draft: DRAFT },
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(result.current.result).toEqual(RESULT);
+ expect(result.current.stale).toBe(false);
+
+ rerender({ draft: { ...DRAFT, minXHeightMm: Number.NaN } });
+ expect(result.current.stale).toBe(true);
+ expect(result.current.pending).toBe(false);
+
+ rerender({ draft: DRAFT });
+ expect(result.current.stale).toBe(false);
+ });
+
+ it("does not ask while the minimum x-height is out of range", async () => {
+ renderHook(() => useDebouncedLabelCheck({ ...DRAFT, minXHeightMm: 9 }));
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(apiFetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/label-templates/check.ts b/ApiCore-Frontend/src/lib/label-templates/check.ts
new file mode 100644
index 0000000..e260ed5
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/label-templates/check.ts
@@ -0,0 +1,237 @@
+import { useEffect, useState } from "react";
+import { apiFetch, ApiError } from "@/lib/auth/api-client";
+import { isNetworkError } from "@/lib/http/fetch-with-timeout";
+import { saveErrorText } from "@/lib/honey-batches/errors";
+import type { LabelElement, LabelTextElement } from "./types";
+
+/**
+ * The label check of the server. It holds every legal value (minimum x-height,
+ * digit heights), the frontend only shows what comes back and never decides.
+ *
+ * The answer of a check against a bottling contains the food business address:
+ * it is never put into a persisted query cache or the service worker cache
+ * (`/label-templates/:id/check/…` is in `NEVER_CACHED_API_PATHS`).
+ */
+
+export type LabelFindingCode =
+ | "LEGAL_NAME"
+ | "NET_QUANTITY"
+ | "BEST_BEFORE"
+ | "LOT"
+ | "ORIGIN"
+ | "FOOD_BUSINESS"
+ | "LOT_PREFIX"
+ | "X_HEIGHT"
+ | "NET_QUANTITY_HEIGHT"
+ | "TEXT_CLIPPED";
+
+/** The six particulars that block printing, in the order the panel lists them. */
+export const PARTICULAR_CODES = [
+ "LEGAL_NAME",
+ "NET_QUANTITY",
+ "BEST_BEFORE",
+ "LOT",
+ "ORIGIN",
+ "FOOD_BUSINESS",
+] as const satisfies readonly LabelFindingCode[];
+
+export interface LabelFinding {
+ code: LabelFindingCode;
+ title: string;
+ detail: string;
+ /** Text elements that carry (or would carry) the finding. */
+ elementIds: string[];
+}
+
+export interface LabelCheckResult {
+ /** No gap: the label may be printed. Warnings never block. */
+ printable: boolean;
+ gaps: LabelFinding[];
+ warnings: LabelFinding[];
+ /** Checked with example values (no bottling chosen). */
+ sample: boolean;
+ /** The legal values the check used. */
+ requirements: {
+ minXHeightMm: number;
+ netQuantityMinCapHeightMm: number | null;
+ };
+}
+
+export interface DraftInput {
+ elements: LabelElement[];
+ widthMm: number;
+ heightMm: number;
+ minXHeightMm: number;
+ bottlingBatchId?: string;
+}
+
+interface DraftBody {
+ elements: LabelTextElement[];
+ widthMm: number;
+ heightMm: number;
+ minXHeightMm: number;
+ bottlingBatchId?: string;
+}
+
+export const CHECK_DEBOUNCE_MS = 500;
+/**
+ * Mirrors the bounds of `CheckLabelTemplateDto` / `UpdateLabelTemplateDto`. A technical
+ * range that keeps invalid input from being sent, not a legal value: the minimum
+ * that applies comes with every check result.
+ */
+export const MIN_X_HEIGHT_RANGE_MM = { min: 0.5, max: 5 } as const;
+const SHEET_RANGE_MM = { min: 10, max: 1000 } as const;
+const MAX_CHECK_ELEMENTS = 100;
+const CHECK_FALLBACK = "Die Prüfung ist fehlgeschlagen.";
+
+function isTextElement(element: LabelElement): element is LabelTextElement {
+ return element.type === "text";
+}
+
+function isWithin(value: number, range: { min: number; max: number }): boolean {
+ return Number.isFinite(value) && value >= range.min && value <= range.max;
+}
+
+function toBody(input: DraftInput): DraftBody {
+ return {
+ elements: input.elements.filter(isTextElement),
+ widthMm: input.widthMm,
+ heightMm: input.heightMm,
+ minXHeightMm: input.minXHeightMm,
+ ...(input.bottlingBatchId ? { bottlingBatchId: input.bottlingBatchId } : {}),
+ };
+}
+
+function isCheckable(body: DraftBody): boolean {
+ return (
+ isWithin(body.minXHeightMm, MIN_X_HEIGHT_RANGE_MM) &&
+ isWithin(body.widthMm, SHEET_RANGE_MM) &&
+ isWithin(body.heightMm, SHEET_RANGE_MM) &&
+ body.elements.length <= MAX_CHECK_ELEMENTS
+ );
+}
+
+function postDraft(body: DraftBody, signal?: AbortSignal): Promise {
+ return apiFetch("/label-templates/check", {
+ method: "POST",
+ body: JSON.stringify(body),
+ cache: "no-store",
+ signal,
+ });
+}
+
+/** Checks an unsaved draft; only its text elements are sent. */
+export function checkDraftTemplate(
+ input: DraftInput,
+ signal?: AbortSignal,
+): Promise {
+ return postDraft(toBody(input), signal);
+}
+
+/** Checks a saved template against one bottling (real values, no sample). */
+export function checkTemplateForBottling(
+ templateId: string,
+ bottlingBatchId: string,
+): Promise {
+ return apiFetch(
+ `/label-templates/${encodeURIComponent(templateId)}/check/${encodeURIComponent(bottlingBatchId)}`,
+ { cache: "no-store" },
+ );
+}
+
+export interface LabelParticularsMissing {
+ message: string;
+ gaps: LabelFinding[];
+}
+
+function isFinding(value: unknown): value is LabelFinding {
+ if (typeof value !== "object" || value === null) return false;
+ const finding = value as Partial;
+ return typeof finding.code === "string" && typeof finding.title === "string";
+}
+
+/** A 409 `LABEL_PARTICULARS_MISSING` with its gaps, otherwise `null`. */
+export function labelParticularsMissing(error: unknown): LabelParticularsMissing | null {
+ if (!(error instanceof ApiError) || error.status !== 409) return null;
+ const body = error.body as { code?: unknown; message?: unknown; gaps?: unknown } | null;
+ if (body?.code !== "LABEL_PARTICULARS_MISSING") return null;
+ if (!Array.isArray(body.gaps) || !body.gaps.every(isFinding)) return null;
+ return {
+ message: typeof body.message === "string" ? body.message : error.message,
+ gaps: body.gaps,
+ };
+}
+
+function isComplianceSettingsInvalid(error: unknown): boolean {
+ if (!(error instanceof ApiError) || error.status !== 409) return false;
+ return (error.body as { code?: unknown } | null)?.code === "COMPLIANCE_SETTINGS_INVALID";
+}
+
+/** The ONE reader of a failed check or print: German text for 403, offline and invalid settings. */
+export function labelCheckErrorText(error: unknown, fallback: string): string {
+ if (isNetworkError(error)) return "Das geht nur mit Verbindung.";
+ if (error instanceof ApiError && error.status === 403) {
+ return "Dir fehlt die Berechtigung für Etiketten.";
+ }
+ if (isComplianceSettingsInvalid(error)) {
+ return "Die Compliance-Einstellungen fehlen oder sind ungültig. Bitte melde das der Administration.";
+ }
+ return saveErrorText(error, fallback);
+}
+
+interface CheckState {
+ /** The serialized draft this state answers. */
+ key: string | null;
+ result: LabelCheckResult | null;
+ error: string | null;
+}
+
+/**
+ * Checks the draft 500 ms after the last change. Only the text elements count:
+ * moving a QR code or a line does not trigger a request. While a value is out
+ * of range (the person is still typing) nothing is asked and the last answer stays.
+ */
+export function useDebouncedLabelCheck(
+ draft: DraftInput,
+ delayMs: number = CHECK_DEBOUNCE_MS,
+): {
+ result: LabelCheckResult | null;
+ pending: boolean;
+ error: string | null;
+ /** The draft is invalid right now, so `result` answers an older draft. */
+ stale: boolean;
+} {
+ const body = toBody(draft);
+ const payload = isCheckable(body) ? JSON.stringify(body) : null;
+ const [state, setState] = useState({ key: null, result: null, error: null });
+
+ useEffect(() => {
+ if (payload === null) return;
+ let cancelled = false;
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ postDraft(JSON.parse(payload) as DraftBody, controller.signal).then(
+ (result) => {
+ if (!cancelled) setState({ key: payload, result, error: null });
+ },
+ (cause: unknown) => {
+ if (!cancelled) {
+ setState({ key: payload, result: null, error: labelCheckErrorText(cause, CHECK_FALLBACK) });
+ }
+ },
+ );
+ }, delayMs);
+ return () => {
+ cancelled = true;
+ controller.abort();
+ clearTimeout(timer);
+ };
+ }, [payload, delayMs]);
+
+ return {
+ result: state.result,
+ pending: payload !== null && state.key !== payload,
+ error: state.error,
+ stale: payload === null && state.result !== null,
+ };
+}
diff --git a/ApiCore-Frontend/src/lib/label-templates/compliance-block.spec.ts b/ApiCore-Frontend/src/lib/label-templates/compliance-block.spec.ts
new file mode 100644
index 0000000..2e94292
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/label-templates/compliance-block.spec.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { buildComplianceBlock } from "./compliance-block";
+import type { LabelCheckResult } from "./check";
+
+const TEMPLATE = { widthMm: 90, heightMm: 60 };
+const NEW_KEYS = [
+ "legalName",
+ "originCountry",
+ "foodBusinessName",
+ "foodBusinessAddress",
+ "bestBeforeLine",
+ "lotLine",
+] as const;
+
+// Helvetica metrics the backend measures with (thousandths of the font size).
+const X_HEIGHT = 523;
+const CAP_HEIGHT = 718;
+const MM_PER_PT = 25.4 / 72;
+
+function requirements(
+ minXHeightMm: number,
+ netQuantityMinCapHeightMm: number | null,
+): LabelCheckResult["requirements"] {
+ return { minXHeightMm, netQuantityMinCapHeightMm };
+}
+
+describe("buildComplianceBlock", () => {
+ it("inserts one text element per new placeholder, sized to meet the required x-height", () => {
+ const block = buildComplianceBlock(TEMPLATE, requirements(1.2, null));
+
+ for (const key of NEW_KEYS) {
+ const matching = block.filter((element) => element.placeholderKey === key);
+ expect(matching).toHaveLength(1);
+ const xHeightMm = (matching[0].fontSize * X_HEIGHT * MM_PER_PT) / 1000;
+ expect(xHeightMm).toBeGreaterThanOrEqual(1.2 - 1e-9);
+ }
+ expect(block.every((element) => element.type === "text")).toBe(true);
+ expect(new Set(block.map((element) => element.id)).size).toBe(block.length);
+ });
+
+ it("scales the size with the required x-height instead of using a fixed number", () => {
+ const small = buildComplianceBlock(TEMPLATE, requirements(0.9, null));
+ const large = buildComplianceBlock(TEMPLATE, requirements(2.4, null));
+ const legalSize = (block: ReturnType) =>
+ block.find((element) => element.placeholderKey === "legalName")!.fontSize;
+
+ expect(legalSize(large)).toBeGreaterThan(legalSize(small));
+ });
+
+ it("uses the digit height requirement for the net quantity element when present", () => {
+ const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
+ const net = block.find((element) => element.placeholderKey === "jarSize");
+
+ expect(net).toBeDefined();
+ const capHeightMm = (net!.fontSize * CAP_HEIGHT * MM_PER_PT) / 1000;
+ expect(capHeightMm).toBeGreaterThanOrEqual(3 - 1e-9);
+ });
+
+ it("sizes the net quantity by the x-height when no digit rule applies", () => {
+ const block = buildComplianceBlock(TEMPLATE, requirements(1.2, null));
+ const net = block.find((element) => element.placeholderKey === "jarSize");
+
+ const xHeightMm = (net!.fontSize * X_HEIGHT * MM_PER_PT) / 1000;
+ expect(xHeightMm).toBeGreaterThanOrEqual(1.2 - 1e-9);
+ });
+
+ it("keeps every element inside the label width", () => {
+ const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
+ for (const element of block) {
+ expect(element.x).toBeGreaterThanOrEqual(0);
+ expect(element.x + element.width).toBeLessThanOrEqual(TEMPLATE.widthMm);
+ }
+ });
+
+ it("stacks the elements without overlap", () => {
+ const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
+ for (let index = 1; index < block.length; index += 1) {
+ expect(block[index].y).toBeGreaterThanOrEqual(block[index - 1].y + block[index - 1].height);
+ }
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/label-templates/compliance-block.ts b/ApiCore-Frontend/src/lib/label-templates/compliance-block.ts
new file mode 100644
index 0000000..5c6e839
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/label-templates/compliance-block.ts
@@ -0,0 +1,83 @@
+import type { LabelCheckResult } from "./check";
+import type { LabelTextElement, PlaceholderKey } from "./types";
+
+// Metrics of the built-in PDF font (Helvetica, thousandths of the font size),
+// the same ones the server measures with. They describe the font, not the law:
+// the legal minimums arrive with every check result.
+const X_HEIGHT_RATIO = 0.523;
+const CAP_HEIGHT_RATIO = 0.718;
+const MM_PER_PT = 25.4 / 72;
+const SIZE_STEP_PT = 0.5;
+const LINE_HEIGHT_FACTOR = 1.25;
+const EDGE_MM = 2;
+const ROW_GAP_MM = 0.5;
+const EPSILON = 1e-9;
+
+interface BlockRow {
+ key: PlaceholderKey;
+ /** Which height decides the size. */
+ basis: "xHeight" | "netQuantity";
+ lines: number;
+}
+
+// Legal name and net quantity first, then the dates and the origin, the
+// food business last: the order a buyer reads them on a jar.
+const BLOCK_ROWS: readonly BlockRow[] = [
+ { key: "legalName", basis: "xHeight", lines: 1 },
+ { key: "jarSize", basis: "netQuantity", lines: 1 },
+ { key: "bestBeforeLine", basis: "xHeight", lines: 1 },
+ { key: "lotLine", basis: "xHeight", lines: 1 },
+ { key: "originCountry", basis: "xHeight", lines: 1 },
+ { key: "foodBusinessName", basis: "xHeight", lines: 1 },
+ { key: "foodBusinessAddress", basis: "xHeight", lines: 2 },
+];
+
+/** Smallest font size (in half points) whose glyph height reaches `heightMm`. */
+function fontSizeFor(heightMm: number, ratio: number): number {
+ const points = heightMm / MM_PER_PT / ratio;
+ return Math.ceil(points / SIZE_STEP_PT - EPSILON) * SIZE_STEP_PT;
+}
+
+function sizeOf(row: BlockRow, requirements: LabelCheckResult["requirements"]): number {
+ const capHeightMm = requirements.netQuantityMinCapHeightMm;
+ if (row.basis === "netQuantity" && capHeightMm !== null) {
+ return fontSizeFor(capHeightMm, CAP_HEIGHT_RATIO);
+ }
+ return fontSizeFor(requirements.minXHeightMm, X_HEIGHT_RATIO);
+}
+
+/**
+ * One text element per particular the label needs, stacked top to bottom.
+ * The sizes follow `requirements` of the last check; no legal value is
+ * stored here. The elements carry only placeholders, so the printed text
+ * always comes from the master data.
+ */
+export function buildComplianceBlock(
+ template: { widthMm: number; heightMm: number },
+ requirements: LabelCheckResult["requirements"],
+): LabelTextElement[] {
+ const width = Math.max(template.widthMm - 2 * EDGE_MM, 1);
+ let top = EDGE_MM;
+ return BLOCK_ROWS.map((row) => {
+ const fontSize = sizeOf(row, requirements);
+ const height = fontSize * MM_PER_PT * LINE_HEIGHT_FACTOR * row.lines;
+ const element: LabelTextElement = {
+ id: crypto.randomUUID(),
+ type: "text",
+ x: EDGE_MM,
+ y: top,
+ width,
+ height,
+ text: "",
+ placeholderKey: row.key,
+ fontSize,
+ fontFamily: "sans",
+ bold: false,
+ italic: false,
+ color: "#000000",
+ align: "left",
+ };
+ top += height + ROW_GAP_MM;
+ return element;
+ });
+}
diff --git a/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.spec.ts b/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.spec.ts
new file mode 100644
index 0000000..e0fe0ad
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.spec.ts
@@ -0,0 +1,23 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { PLACEHOLDER_KEYS, PLACEHOLDER_LABELS } from "./placeholder-keys";
+
+describe("placeholder keys", () => {
+ it("the frontend list equals the backend fixture ApiCore-Backend/test/fixtures/placeholder-keys.json", () => {
+ // The monorepo sits next to the frontend; vitest runs from ApiCore-Frontend.
+ const fixture = path.resolve(
+ process.cwd(),
+ "../ApiCore-Backend/test/fixtures/placeholder-keys.json",
+ );
+ const backendKeys: unknown = JSON.parse(readFileSync(fixture, "utf8"));
+
+ expect([...PLACEHOLDER_KEYS]).toEqual(backendKeys);
+ });
+
+ it("has a German label for every key", () => {
+ for (const key of PLACEHOLDER_KEYS) {
+ expect(PLACEHOLDER_LABELS[key]).toBeTruthy();
+ }
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts b/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts
new file mode 100644
index 0000000..2137241
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/label-templates/placeholder-keys.ts
@@ -0,0 +1,37 @@
+/**
+ * Placeholder keys a text element can carry. The backend owns the list
+ * (`PLACEHOLDER_KEYS`); `placeholder-keys.spec.ts` compares this copy with the
+ * backend fixture so the two never drift apart.
+ */
+export const PLACEHOLDER_KEYS = [
+ "batchNumber",
+ "honeyType",
+ "lotNumber",
+ "bottlingDate",
+ "bestBeforeDate",
+ "jarSize",
+ "productName",
+ "legalName",
+ "originCountry",
+ "foodBusinessName",
+ "foodBusinessAddress",
+ "bestBeforeLine",
+ "lotLine",
+] as const;
+export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
+
+export const PLACEHOLDER_LABELS: Record = {
+ batchNumber: "Chargennummer",
+ honeyType: "Honigsorte",
+ lotNumber: "Los-/Chargennummer der Abfüllung",
+ bottlingDate: "Abfülldatum",
+ bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
+ jarSize: "Glasgröße / Füllmenge",
+ productName: "Produktname",
+ legalName: "Verkehrsbezeichnung (z. B. Blütenhonig)",
+ originCountry: "Herkunftsland",
+ foodBusinessName: "Lebensmittelunternehmer: Name",
+ foodBusinessAddress: "Lebensmittelunternehmer: Anschrift",
+ bestBeforeLine: "Zeile „Mindestens haltbar bis …“",
+ lotLine: "Zeile „L Losnummer“",
+};
diff --git a/ApiCore-Frontend/src/lib/label-templates/types.ts b/ApiCore-Frontend/src/lib/label-templates/types.ts
index 77c5be7..5854704 100644
--- a/ApiCore-Frontend/src/lib/label-templates/types.ts
+++ b/ApiCore-Frontend/src/lib/label-templates/types.ts
@@ -1,27 +1,11 @@
+import type { PlaceholderKey } from "./placeholder-keys";
+
export type LabelFontFamily = "sans" | "serif" | "mono";
export type LabelTextAlign = "left" | "center" | "right";
export type LabelLineOrientation = "horizontal" | "vertical";
-export const PLACEHOLDER_KEYS = [
- "batchNumber",
- "honeyType",
- "lotNumber",
- "bottlingDate",
- "bestBeforeDate",
- "jarSize",
- "productName",
-] as const;
-export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
-
-export const PLACEHOLDER_LABELS: Record = {
- batchNumber: "Chargennummer",
- honeyType: "Honigsorte",
- lotNumber: "Los-/Chargennummer der Abfüllung",
- bottlingDate: "Abfülldatum",
- bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
- jarSize: "Glasgröße",
- productName: "Produktname",
-};
+export { PLACEHOLDER_KEYS, PLACEHOLDER_LABELS } from "./placeholder-keys";
+export type { PlaceholderKey } from "./placeholder-keys";
interface LabelElementBase {
id: string;
@@ -75,6 +59,8 @@ export interface LabelTemplate {
elements: LabelElement[];
widthMm: number;
heightMm: number;
+ /** Smallest x-height (mm) the check demands of a text; 0.5 to 5. */
+ minXHeightMm: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
@@ -93,6 +79,7 @@ export interface CreateLabelTemplateInput {
elements: LabelElement[];
widthMm?: number;
heightMm?: number;
+ minXHeightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
@@ -108,6 +95,7 @@ export interface UpdateLabelTemplateInput {
elements?: LabelElement[];
widthMm?: number;
heightMm?: number;
+ minXHeightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
diff --git a/ApiCore-Frontend/src/lib/medicines/api.spec.ts b/ApiCore-Frontend/src/lib/medicines/api.spec.ts
new file mode 100644
index 0000000..63b082e
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/api.spec.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { apiFetch } from "@/lib/auth/api-client";
+import {
+ bookCorrection,
+ bookDisposal,
+ createLot,
+ createMedicine,
+ fetchLot,
+ fetchMedicine,
+ fetchMedicines,
+ fetchMedicineSuggestions,
+ fetchSelectableLots,
+ setMedicineActive,
+ updateLot,
+ updateMedicine,
+} from "./api";
+
+vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(async () => ({})) }));
+
+function lastCall() {
+ const [path, init] = vi.mocked(apiFetch).mock.calls.at(-1) ?? [];
+ return { path, init: init as RequestInit | undefined };
+}
+
+describe("medicines api", () => {
+ beforeEach(() => vi.mocked(apiFetch).mockClear());
+
+ it("lists all medicines or only the active/inactive ones", async () => {
+ await fetchMedicines();
+ expect(lastCall().path).toBe("/veterinary-medicines");
+ await fetchMedicines(false);
+ expect(lastCall().path).toBe("/veterinary-medicines?active=false");
+ });
+
+ it("reads suggestions and one medicine with its lots", async () => {
+ await fetchMedicineSuggestions();
+ expect(lastCall().path).toBe("/veterinary-medicines/suggestions");
+ await fetchMedicine("m 1");
+ expect(lastCall().path).toBe("/veterinary-medicines/m%201");
+ });
+
+ it("creates with an Idempotency-Key and patches without one", async () => {
+ await createMedicine({ name: "A", unit: "ML" }, { idempotencyKey: "k1" });
+ expect(lastCall().path).toBe("/veterinary-medicines");
+ expect(lastCall().init?.method).toBe("POST");
+ expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k1" });
+ await updateMedicine("m1", { notes: null });
+ expect(lastCall().init?.method).toBe("PATCH");
+ expect(lastCall().init?.body).toBe(JSON.stringify({ notes: null }));
+ });
+
+ it("switches a medicine on and off through the lifecycle routes", async () => {
+ await setMedicineActive("m1", false);
+ expect(lastCall().path).toBe("/veterinary-medicines/m1/deactivate");
+ await setMedicineActive("m1", true);
+ expect(lastCall().path).toBe("/veterinary-medicines/m1/reactivate");
+ });
+
+ it("asks for selectable lots, keeping the chosen one with includeLotId", async () => {
+ await fetchSelectableLots("m1");
+ expect(lastCall().path).toBe("/veterinary-medicine-lots?medicineId=m1&selectable=true");
+ await fetchSelectableLots("m1", "l9");
+ expect(lastCall().path).toBe(
+ "/veterinary-medicine-lots?medicineId=m1&selectable=true&includeLotId=l9",
+ );
+ });
+
+ it("books a lot with an Idempotency-Key and posts the inventory and the disposal", async () => {
+ await fetchLot("l1");
+ expect(lastCall().path).toBe("/veterinary-medicine-lots/l1");
+ await createLot(
+ { medicineId: "m1", purchasedOn: "2026-08-01", quantity: 5 },
+ { idempotencyKey: "k2" },
+ );
+ expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k2" });
+ await updateLot("l1", { supplier: null });
+ expect(lastCall().init?.method).toBe("PATCH");
+ await bookCorrection("l1", 3, { idempotencyKey: "k3" });
+ expect(lastCall().path).toBe("/veterinary-medicine-lots/l1/movements");
+ expect(lastCall().init?.body).toBe(JSON.stringify({ type: "CORRECTION", actualRemaining: 3 }));
+ expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k3" });
+ await bookDisposal("l1", 2, "Abgelaufen", { idempotencyKey: "k4" });
+ expect(lastCall().init?.body).toBe(
+ JSON.stringify({ type: "DISPOSAL", quantity: 2, reason: "Abgelaufen" }),
+ );
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/medicines/api.ts b/ApiCore-Frontend/src/lib/medicines/api.ts
new file mode 100644
index 0000000..cd90a93
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/api.ts
@@ -0,0 +1,96 @@
+import { apiFetch } from "@/lib/auth/api-client";
+import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
+import type {
+ LotDetailView,
+ LotInput,
+ LotView,
+ MedicineInput,
+ MedicineSuggestion,
+ MedicineView,
+} from "./types";
+
+const MEDICINES = "/veterinary-medicines";
+const LOTS = "/veterinary-medicine-lots";
+
+function path(base: string, id: string, rest = ""): string {
+ return `${base}/${encodeURIComponent(id)}${rest}`;
+}
+
+function post(url: string, body: unknown, options?: CreateOptions): Promise {
+ return apiFetch(url, {
+ method: "POST",
+ headers: idempotencyHeaders(options),
+ body: JSON.stringify(body),
+ });
+}
+
+function patch(url: string, body: unknown): Promise {
+ return apiFetch(url, { method: "PATCH", body: JSON.stringify(body) });
+}
+
+/** All medicines, or only the active (`true`) / inactive (`false`) ones. */
+export function fetchMedicines(active?: boolean): Promise {
+ return apiFetch(active === undefined ? MEDICINES : `${MEDICINES}?active=${active}`);
+}
+
+/** Own product names from treatments that the catalogue does not know yet. */
+export function fetchMedicineSuggestions(): Promise {
+ return apiFetch(`${MEDICINES}/suggestions`);
+}
+
+export function fetchMedicine(id: string): Promise {
+ return apiFetch(path(MEDICINES, id));
+}
+
+export function createMedicine(input: MedicineInput, options?: CreateOptions): Promise {
+ return post(MEDICINES, input, options);
+}
+
+export function updateMedicine(id: string, input: Partial): Promise {
+ return patch(path(MEDICINES, id), input);
+}
+
+export function setMedicineActive(id: string, active: boolean): Promise {
+ return post(path(MEDICINES, id, active ? "/reactivate" : "/deactivate"), {});
+}
+
+/**
+ * Lots a treatment may take from (rest above 0, active medicine, oldest expiry
+ * first). `includeLotId` keeps the lot of a treatment that is being edited on
+ * the list even when it is empty or its medicine was switched off.
+ */
+export function fetchSelectableLots(medicineId: string, includeLotId?: string): Promise {
+ const params = new URLSearchParams({ medicineId, selectable: "true" });
+ if (includeLotId) params.set("includeLotId", includeLotId);
+ return apiFetch(`${LOTS}?${params.toString()}`);
+}
+
+export function fetchLot(id: string): Promise {
+ return apiFetch(path(LOTS, id));
+}
+
+export function createLot(input: LotInput, options?: CreateOptions): Promise {
+ return post(LOTS, input, options);
+}
+
+export function updateLot(id: string, input: Partial): Promise {
+ return patch(path(LOTS, id), input);
+}
+
+/** Inventory: the rest counted in the box; the server books the difference. */
+export function bookCorrection(
+ lotId: string,
+ actualRemaining: number,
+ options?: CreateOptions,
+): Promise {
+ return post(path(LOTS, lotId, "/movements"), { type: "CORRECTION", actualRemaining }, options);
+}
+
+export function bookDisposal(
+ lotId: string,
+ quantity: number,
+ reason: string,
+ options?: CreateOptions,
+): Promise {
+ return post(path(LOTS, lotId, "/movements"), { type: "DISPOSAL", quantity, reason }, options);
+}
diff --git a/ApiCore-Frontend/src/lib/medicines/book.spec.ts b/ApiCore-Frontend/src/lib/medicines/book.spec.ts
new file mode 100644
index 0000000..891be71
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/book.spec.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { buildBook } from "./book";
+import { lotDetail } from "./fixtures.spec-helper";
+
+const movement = (id: string, createdAt: string, quantity: number, type = "TREATMENT" as const) => ({
+ id,
+ type,
+ quantity,
+ treatmentId: null,
+ reason: null,
+ userId: null,
+ createdAt,
+});
+
+describe("buildBook", () => {
+ it("lists the newest booking first and ends at the receipt", () => {
+ const rows = buildBook(
+ lotDetail({
+ quantity: 20,
+ movements: [movement("b", "2026-08-12T08:00:00.000Z", -3), movement("a", "2026-08-10T08:00:00.000Z", -8)],
+ }),
+ );
+
+ expect(rows.map((row) => row.label)).toEqual(["Behandlung", "Behandlung", "Eingang"]);
+ expect(rows.map((row) => row.balance)).toEqual([9, 12, 20]);
+ });
+
+ it("keeps the running rest exact to the thousandth", () => {
+ const rows = buildBook(
+ lotDetail({
+ quantity: 1,
+ movements: [movement("a", "2026-08-10T08:00:00.000Z", -0.1), movement("b", "2026-08-11T08:00:00.000Z", -0.2)],
+ }),
+ );
+
+ expect(rows[0].balance).toBe(0.7);
+ });
+
+ it("lets a rest fall below zero and keeps the sign", () => {
+ const rows = buildBook(lotDetail({ quantity: 2, movements: [movement("a", "2026-08-10T08:00:00.000Z", -5)] }));
+
+ expect(rows[0].balance).toBe(-3);
+ });
+
+ it("reads the day of a booking as the Berlin calendar day", () => {
+ const rows = buildBook(lotDetail({ movements: [movement("a", "2026-08-10T22:30:00.000Z", -1)] }));
+
+ expect(rows[0].day).toBe("2026-08-11");
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/medicines/book.ts b/ApiCore-Frontend/src/lib/medicines/book.ts
new file mode 100644
index 0000000..9437303
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/book.ts
@@ -0,0 +1,50 @@
+import { berlinCalendarDay } from "@/lib/dates";
+import { MOVEMENT_TYPE_LABELS, roundToThousandths } from "./format";
+import type { LotDetailView } from "./types";
+
+export interface BookRow {
+ key: string;
+ /** Calendar day "YYYY-MM-DD". */
+ day: string;
+ label: string;
+ /** Signed. */
+ quantity: number;
+ /** Rest of the lot right after this booking. */
+ balance: number;
+ reason: string | null;
+}
+
+/**
+ * The movement book of one lot, newest first, each row with the rest it left
+ * behind. The first entry is the receipt of the pack (the initial quantity);
+ * the server never changes or deletes a movement, so the running rest always
+ * ends at `lot.remaining`.
+ */
+export function buildBook(lot: LotDetailView): BookRow[] {
+ const movements = [...lot.movements].sort(
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
+ );
+ const rows: BookRow[] = [
+ {
+ key: "receipt",
+ day: lot.purchasedOn,
+ label: "Eingang",
+ quantity: lot.quantity,
+ balance: lot.quantity,
+ reason: null,
+ },
+ ];
+ let balance = lot.quantity;
+ for (const movement of movements) {
+ balance = roundToThousandths(balance + movement.quantity);
+ rows.push({
+ key: movement.id,
+ day: berlinCalendarDay(movement.createdAt),
+ label: MOVEMENT_TYPE_LABELS[movement.type],
+ quantity: movement.quantity,
+ balance,
+ reason: movement.reason,
+ });
+ }
+ return rows.reverse();
+}
diff --git a/ApiCore-Frontend/src/lib/medicines/fixtures.spec-helper.ts b/ApiCore-Frontend/src/lib/medicines/fixtures.spec-helper.ts
new file mode 100644
index 0000000..182c2ea
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/fixtures.spec-helper.ts
@@ -0,0 +1,79 @@
+import type { LotDetailView, LotView, MedicineSuggestion, MedicineView } from "./types";
+
+export function medicine(overrides: Partial = {}): MedicineView {
+ return {
+ id: "m1",
+ name: "Apivar",
+ activeIngredient: "Amitraz",
+ authorisationNumber: null,
+ prescriptionOnly: false,
+ withdrawalPeriodDays: 0,
+ unit: "PIECE",
+ dosePerColony: null,
+ doseNote: null,
+ applicationMethod: null,
+ minTemperatureC: null,
+ maxTemperatureC: null,
+ defaultSupplier: null,
+ notes: null,
+ active: true,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ totalRemaining: 12,
+ nextExpiry: "2028-01-31",
+ lotCount: 1,
+ ...overrides,
+ };
+}
+
+export function lot(overrides: Partial = {}): LotView {
+ return {
+ id: "l1",
+ medicineId: "m1",
+ medicineName: "Apivar",
+ unit: "PIECE",
+ batchNumber: "CH-42",
+ purchasedOn: "2026-08-01",
+ quantity: 20,
+ remaining: 12,
+ expiresOn: "2028-01-31",
+ supplier: "Tierarzt Muster",
+ purchaseProof: "Rechnung 4711",
+ prescribingVet: null,
+ notes: null,
+ movementCount: 1,
+ ...overrides,
+ };
+}
+
+export function lotDetail(overrides: Partial = {}): LotDetailView {
+ return {
+ ...lot(),
+ movements: [
+ {
+ id: "mv1",
+ type: "TREATMENT",
+ quantity: -8,
+ treatmentId: "t1",
+ reason: null,
+ userId: "u1",
+ createdAt: "2026-08-10T08:00:00.000Z",
+ },
+ ],
+ ...overrides,
+ };
+}
+
+export function suggestion(overrides: Partial = {}): MedicineSuggestion {
+ return {
+ name: "Thymovar",
+ treatmentCount: 3,
+ latest: {
+ activeIngredient: "Thymol",
+ withdrawalPeriodDays: 7,
+ method: "Streifen",
+ amount: "2 Stück",
+ },
+ ...overrides,
+ };
+}
diff --git a/ApiCore-Frontend/src/lib/medicines/format.spec.ts b/ApiCore-Frontend/src/lib/medicines/format.spec.ts
new file mode 100644
index 0000000..daf65d9
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/format.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import {
+ decimalMessage,
+ formatDay,
+ formatQuantity,
+ formatSignedQuantity,
+ isAmbiguousDecimal,
+ parseDecimal,
+ THOUSANDS_SEPARATOR_MESSAGE,
+} from "./format";
+
+describe("medicine formatting", () => {
+ it("writes quantities with the unit and German decimals", () => {
+ expect(formatQuantity(12.5, "ML")).toBe("12,5 ml");
+ expect(formatQuantity(3, "G")).toBe("3 g");
+ expect(formatQuantity(2, "PIECE")).toBe("2 Stück");
+ });
+
+ it("writes a signed change with a real minus sign", () => {
+ expect(formatSignedQuantity(-2, "ML")).toBe("−2 ml");
+ expect(formatSignedQuantity(1.5, "G")).toBe("+1,5 g");
+ expect(formatSignedQuantity(0, "G")).toBe("0 g");
+ });
+
+ it("writes a calendar day as given, without a time zone shift", () => {
+ expect(formatDay("2028-01-31")).toBe("31.01.2028");
+ expect(formatDay(null)).toBe("—");
+ });
+});
+
+describe("parseDecimal", () => {
+ it.each([
+ ["1,5", 1.5],
+ ["1.5", 1.5],
+ ["0.001", 0.001],
+ ["1,000", 1],
+ ["12", 12],
+ [" 2,5 ", 2.5],
+ ])("reads %s as %s", (text, value) => {
+ expect(parseDecimal(text)).toBe(value);
+ });
+
+ it.each(["1.000", "12.345", "1.234,5", "1,234.5", "1.2.3", "1,2,3"])(
+ "refuses the ambiguous %s",
+ (text) => {
+ expect(isAmbiguousDecimal(text)).toBe(true);
+ expect(parseDecimal(text)).toBeNaN();
+ expect(decimalMessage(text, "x")).toBe(THOUSANDS_SEPARATOR_MESSAGE);
+ },
+ );
+
+ it("is NaN for empty or non-numeric text and keeps the plain message there", () => {
+ expect(parseDecimal("")).toBeNaN();
+ expect(parseDecimal("abc")).toBeNaN();
+ expect(decimalMessage("abc", "plain")).toBe("plain");
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/medicines/format.ts b/ApiCore-Frontend/src/lib/medicines/format.ts
new file mode 100644
index 0000000..4aeaad0
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/format.ts
@@ -0,0 +1,73 @@
+import type { MedicineUnit, MovementType } from "./types";
+
+export const MEDICINE_UNITS: readonly MedicineUnit[] = ["ML", "G", "PIECE"];
+
+export const MEDICINE_UNIT_LABELS: Record = {
+ ML: "ml",
+ G: "g",
+ PIECE: "Stück",
+};
+
+export const MOVEMENT_TYPE_LABELS: Record = {
+ TREATMENT: "Behandlung",
+ CORRECTION: "Inventur",
+ DISPOSAL: "Entsorgung",
+};
+
+const MAX_FRACTION_DIGITS = 3;
+const MINUS = "−";
+
+function number(value: number): string {
+ return value.toLocaleString("de-DE", { maximumFractionDigits: MAX_FRACTION_DIGITS });
+}
+
+/** "12,5 ml": at most thousandths, German decimals. */
+export function formatQuantity(value: number, unit: MedicineUnit): string {
+ return `${number(value).replace("-", MINUS)} ${MEDICINE_UNIT_LABELS[unit]}`;
+}
+
+/** "+1,5 g" / "−2 ml" / "0 g": the sign is part of the information. */
+export function formatSignedQuantity(value: number, unit: MedicineUnit): string {
+ if (value > 0) return `+${formatQuantity(value, unit)}`;
+ return formatQuantity(value, unit);
+}
+
+/** A calendar day "YYYY-MM-DD" as "TT.MM.JJJJ", read as written (no time zone). */
+export function formatDay(day: string | null): string {
+ if (!day) return "—";
+ const [year, month, date] = day.split("-");
+ return `${date}.${month}.${year}`;
+}
+
+/** Thousandths, as the server rounds, so the shown delta equals the booked one. */
+export function roundToThousandths(value: number): number {
+ return Math.round(value * 1000) / 1000 + 0;
+}
+
+export const THOUSANDS_SEPARATOR_MESSAGE = "Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5";
+
+/**
+ * Locale rule: a comma is always the German decimal separator ("1,000" is 1).
+ * A dot is a decimal point only when it is the single separator and does not
+ * look like a thousands dot, so "1.5" and "0.001" pass, while "1.000", "12.345"
+ * and any text with more than one separator are ambiguous and refused (a
+ * silently wrong quantity in a medicine book is worse than a question).
+ */
+export function isAmbiguousDecimal(text: string): boolean {
+ const trimmed = text.trim().replace(/\s/g, "");
+ const separators = trimmed.match(/[.,]/g)?.length ?? 0;
+ if (separators > 1) return true;
+ return /^\d*[1-9]\d*\.\d{3}$/.test(trimmed);
+}
+
+/** Accepts "2,5" as well as "2.5"; anything else, or an ambiguous thousands dot, is NaN. */
+export function parseDecimal(text: string): number {
+ if (isAmbiguousDecimal(text)) return Number.NaN;
+ const trimmed = text.trim().replace(",", ".");
+ return trimmed === "" ? Number.NaN : Number(trimmed);
+}
+
+/** The message for a typed number that parses to nothing: the thousands hint or the plain one. */
+export function decimalMessage(text: string, plain: string): string {
+ return isAmbiguousDecimal(text) ? THOUSANDS_SEPARATOR_MESSAGE : plain;
+}
diff --git a/ApiCore-Frontend/src/lib/medicines/prefill.spec.ts b/ApiCore-Frontend/src/lib/medicines/prefill.spec.ts
new file mode 100644
index 0000000..c30aaed
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/prefill.spec.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { prefillFromSuggestion, unitFromAmount } from "./prefill";
+import { suggestion } from "./fixtures.spec-helper";
+
+describe("unitFromAmount", () => {
+ it.each([
+ ["30 ml", "ML"],
+ ["2 Stück", "PIECE"],
+ ["2 Streifen", "PIECE"],
+ ["5 g", "G"],
+ ["5 Gramm", "G"],
+ ])("reads the unit of %s", (amount, unit) => {
+ expect(unitFromAmount(amount)).toBe(unit);
+ });
+
+ it("names no unit when the text names none", () => {
+ expect(unitFromAmount("eine Dosis")).toBeUndefined();
+ expect(unitFromAmount(null)).toBeUndefined();
+ });
+});
+
+describe("prefillFromSuggestion", () => {
+ it("carries the values of the latest treatment", () => {
+ expect(prefillFromSuggestion(suggestion())).toEqual({
+ name: "Thymovar",
+ unit: "PIECE",
+ activeIngredient: "Thymol",
+ withdrawalPeriodDays: 7,
+ applicationMethod: "Streifen",
+ doseNote: "Menge der letzten Behandlung: 2 Stück",
+ });
+ });
+
+ it("leaves the dose note empty when the treatment had no amount", () => {
+ const result = prefillFromSuggestion(
+ suggestion({ latest: { activeIngredient: null, withdrawalPeriodDays: null, method: null, amount: null } }),
+ );
+ expect(result.doseNote).toBeNull();
+ expect(result.unit).toBeUndefined();
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/medicines/prefill.ts b/ApiCore-Frontend/src/lib/medicines/prefill.ts
new file mode 100644
index 0000000..024e2c6
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/prefill.ts
@@ -0,0 +1,42 @@
+import type { MedicineSuggestion, MedicineUnit } from "./types";
+
+/** What the form starts with when a medicine is taken over from a treatment. */
+export interface MedicineFormPrefill {
+ name?: string;
+ unit?: MedicineUnit;
+ activeIngredient?: string | null;
+ withdrawalPeriodDays?: number | null;
+ applicationMethod?: string | null;
+ doseNote?: string | null;
+}
+
+const MAX_DOSE_NOTE_LENGTH = 400;
+
+const UNIT_HINTS: readonly [RegExp, MedicineUnit][] = [
+ [/\bml\b/i, "ML"],
+ [/\b(stück|stueck|stk|streifen)\b/i, "PIECE"],
+ [/\b(g|gramm)\b/i, "G"],
+];
+
+/** The unit the amount text of the latest treatment names, or nothing when it names none. */
+export function unitFromAmount(amount: string | null): MedicineUnit | undefined {
+ if (!amount) return undefined;
+ return UNIT_HINTS.find(([pattern]) => pattern.test(amount))?.[1];
+}
+
+/**
+ * The values of the latest treatment as a proposal for the form. They are
+ * shown, never saved by themselves: the beekeeper checks them against the
+ * package leaflet first.
+ */
+export function prefillFromSuggestion(suggestion: MedicineSuggestion): MedicineFormPrefill {
+ const { latest } = suggestion;
+ return {
+ name: suggestion.name,
+ unit: unitFromAmount(latest.amount),
+ activeIngredient: latest.activeIngredient,
+ withdrawalPeriodDays: latest.withdrawalPeriodDays,
+ applicationMethod: latest.method,
+ doseNote: latest.amount ? `Menge der letzten Behandlung: ${latest.amount}`.slice(0, MAX_DOSE_NOTE_LENGTH) : null,
+ };
+}
diff --git a/ApiCore-Frontend/src/lib/medicines/types.ts b/ApiCore-Frontend/src/lib/medicines/types.ts
new file mode 100644
index 0000000..ab6c3ed
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/types.ts
@@ -0,0 +1,101 @@
+export type MedicineUnit = "ML" | "G" | "PIECE";
+export type MovementType = "TREATMENT" | "CORRECTION" | "DISPOSAL";
+
+/** Days without a time of day travel as "YYYY-MM-DD". */
+export interface MedicineView {
+ id: string;
+ name: string;
+ activeIngredient: string | null;
+ authorisationNumber: string | null;
+ prescriptionOnly: boolean;
+ /** Days of withdrawal for honey, 0 is a value. */
+ withdrawalPeriodDays: number | null;
+ unit: MedicineUnit;
+ dosePerColony: number | null;
+ doseNote: string | null;
+ applicationMethod: string | null;
+ minTemperatureC: number | null;
+ maxTemperatureC: number | null;
+ defaultSupplier: string | null;
+ notes: string | null;
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+ /** Sum of the rest of all lots; a lot below zero counts negative. */
+ totalRemaining: number;
+ /** Earliest expiry of a lot that still has a rest. */
+ nextExpiry: string | null;
+ lotCount: number;
+}
+
+export interface LotView {
+ id: string;
+ medicineId: string;
+ medicineName: string;
+ unit: MedicineUnit;
+ batchNumber: string | null;
+ purchasedOn: string;
+ quantity: number;
+ /** Quantity plus all movements; may be negative. */
+ remaining: number;
+ expiresOn: string | null;
+ supplier: string | null;
+ purchaseProof: string | null;
+ prescribingVet: string | null;
+ notes: string | null;
+ movementCount: number;
+}
+
+export interface MovementView {
+ id: string;
+ type: MovementType;
+ /** Signed: negative takes medicine out of the lot. */
+ quantity: number;
+ treatmentId: string | null;
+ reason: string | null;
+ userId: string | null;
+ createdAt: string;
+}
+
+export interface LotDetailView extends LotView {
+ movements: MovementView[];
+}
+
+export interface MedicineSuggestion {
+ name: string;
+ treatmentCount: number;
+ latest: {
+ activeIngredient: string | null;
+ withdrawalPeriodDays: number | null;
+ method: string | null;
+ amount: string | null;
+ };
+}
+
+export interface MedicineInput {
+ name: string;
+ unit: MedicineUnit;
+ activeIngredient?: string | null;
+ authorisationNumber?: string | null;
+ prescriptionOnly?: boolean;
+ withdrawalPeriodDays?: number | null;
+ dosePerColony?: number | null;
+ doseNote?: string | null;
+ applicationMethod?: string | null;
+ minTemperatureC?: number | null;
+ maxTemperatureC?: number | null;
+ defaultSupplier?: string | null;
+ notes?: string | null;
+}
+
+export interface LotInput {
+ medicineId: string;
+ batchNumber?: string | null;
+ purchasedOn: string;
+ quantity: number;
+ expiresOn?: string | null;
+ supplier?: string | null;
+ purchaseProof?: string | null;
+ prescribingVet?: string | null;
+ notes?: string | null;
+}
diff --git a/ApiCore-Frontend/src/lib/medicines/use-attempt-key.spec.ts b/ApiCore-Frontend/src/lib/medicines/use-attempt-key.spec.ts
new file mode 100644
index 0000000..499a1d4
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/use-attempt-key.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { ApiError } from "@/lib/auth/api-client";
+import { useAttemptKey } from "./use-attempt-key";
+
+describe("useAttemptKey", () => {
+ it("keeps the key for an unchanged retry after a network error", () => {
+ const { result } = renderHook(() => useAttemptKey());
+ const first = result.current.keyFor({ quantity: 1 });
+ result.current.failed(new TypeError("Failed to fetch"));
+
+ expect(result.current.keyFor({ quantity: 1 })).toBe(first);
+ });
+
+ it("renews the key when the body changes", () => {
+ const { result } = renderHook(() => useAttemptKey());
+ const first = result.current.keyFor({ quantity: 1 });
+ result.current.failed(new TypeError("Failed to fetch"));
+
+ expect(result.current.keyFor({ quantity: 2 })).not.toBe(first);
+ });
+
+ it("renews the key after an answer from the server, even for the same body", () => {
+ const { result } = renderHook(() => useAttemptKey());
+ const first = result.current.keyFor({ quantity: 1 });
+ result.current.failed(new ApiError(409, "Gerade geändert.", {}));
+
+ expect(result.current.keyFor({ quantity: 1 })).not.toBe(first);
+ });
+
+ it("renews the key after a success", () => {
+ const { result } = renderHook(() => useAttemptKey());
+ const first = result.current.keyFor({ quantity: 1 });
+ result.current.done();
+
+ expect(result.current.keyFor({ quantity: 1 })).not.toBe(first);
+ });
+});
diff --git a/ApiCore-Frontend/src/lib/medicines/use-attempt-key.ts b/ApiCore-Frontend/src/lib/medicines/use-attempt-key.ts
new file mode 100644
index 0000000..aab7711
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/medicines/use-attempt-key.ts
@@ -0,0 +1,39 @@
+import { useRef } from "react";
+import { isNetworkError } from "@/lib/http/fetch-with-timeout";
+import { newIdempotencyKey } from "@/lib/offline/idempotency";
+
+/**
+ * The Idempotency-Key of one booking form. The key is kept only for an
+ * unchanged retry after a network error (the server may have saved the first
+ * try). Any answer from the server that is not a success, and any change of
+ * the request body, starts a new key: a changed booking must never be taken
+ * for its earlier attempt.
+ */
+export function useAttemptKey(): {
+ keyFor: (body: unknown) => string;
+ failed: (error: unknown) => void;
+ done: () => void;
+} {
+ const key = useRef(newIdempotencyKey());
+ const lastBody = useRef(null);
+ return {
+ keyFor(body) {
+ const text = JSON.stringify(body);
+ if (lastBody.current !== null && lastBody.current !== text) {
+ key.current = newIdempotencyKey();
+ }
+ lastBody.current = text;
+ return key.current;
+ },
+ failed(error) {
+ if (!isNetworkError(error)) {
+ key.current = newIdempotencyKey();
+ lastBody.current = null;
+ }
+ },
+ done() {
+ key.current = newIdempotencyKey();
+ lastBody.current = null;
+ },
+ };
+}
diff --git a/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts b/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts
index d157a2b..655690d 100644
--- a/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts
+++ b/ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts
@@ -18,6 +18,7 @@ interface SwRoutes {
isShellRoute: (pathname: string) => boolean;
extractAssetUrls: (html: string) => string[];
isCacheableApiPath: (pathname: string) => boolean;
+ isKeepableApiAnswer: (answer: { ok: boolean; contentType: string | null; cacheControl: string | null }) => boolean;
shouldCacheApi: (url: URL, apiOrigin: string | null) => boolean;
apiOriginFrom: (config: unknown) => string | null;
isRoutePayload: (url: URL) => boolean;
@@ -224,6 +225,100 @@ describe("service worker routes", () => {
expect(cachesApi("/api/v1/exporters")).toBe(true);
});
+ it.each([
+ "/api/v1/compliance/readiness",
+ "/api/v1/compliance",
+ "/api/v1/Compliance/readiness/summary",
+ "/api/v1/compliance/readiness/report.pdf",
+ "/api/v1/authority-notices",
+ "/api/v1/authority-notices/",
+ "/api/v1/authority-notices/n-1",
+ "/api/v1/apiary-restrictions",
+ "/api/v1/apiary-restrictions/r-1",
+ "/api/v1/apiaries/a-1/restrictions",
+ "/api/v1/apiaries/a-1/restrictions/",
+ "/api/v1/veterinary-medicines",
+ "/api/v1/veterinary-medicines/m-1",
+ "/api/v1/veterinary-medicine-lots",
+ "/api/v1/Veterinary-Medicine-Lots/l-1",
+ "/api/v1/label-templates/t-1/check/b-1",
+ "/api/v1/label-templates/t-1/check/b-1/",
+ "/api/v1/label-templates/check",
+ "/api/v1/label-templates/t-1/print/b-1",
+ "/api/v1/label-templates/t-1/print",
+ "/api/v1/compliance/readiness?x=1",
+ ])("never caches %s", (path) => {
+ expect(cachesApi(path)).toBe(false);
+ });
+
+ it("never caches the documents of a medicine lot, a notice or a restriction", () => {
+ for (const entityType of ["MEDICINE_LOT", "AUTHORITY_NOTICE", "APIARY_RESTRICTION", "medicine_lot"]) {
+ expect(cachesApi(`/api/v1/documents?entityType=${entityType}&entityId=x-1`)).toBe(false);
+ expect(cachesApi(`/api/v1/documents/?entityId=x-1&entityType=${entityType}`)).toBe(false);
+ }
+ });
+
+ it("keeps caching the document lists of inspections, so their photos stay available offline", () => {
+ expect(cachesApi("/api/v1/documents?entityType=INSPECTION&entityId=i-1")).toBe(true);
+ expect(cachesApi("/api/v1/documents?entityType=COLONY&entityId=c-1")).toBe(true);
+ });
+
+ it("never caches a single document record, whose kind the path does not tell", () => {
+ expect(cachesApi("/api/v1/documents/d-1")).toBe(false);
+ expect(cachesApi("/api/v1/documents/d-1/")).toBe(false);
+ expect(cachesApi("/api/v1/Documents/d-1")).toBe(false);
+ // the download is a blob and is never kept anyway; its route is not touched
+ expect(cachesApi("/api/v1/documents/d-1/download")).toBe(true);
+ });
+
+ it("keeps caching the unrelated routes it cached before", () => {
+ for (const path of [
+ "/api/v1/tasks",
+ "/api/v1/colonies/c-1",
+ "/api/v1/products",
+ "/api/v1/label-templates",
+ "/api/v1/label-templates/t-1",
+ "/api/v1/compliances",
+ "/api/v1/veterinary-medicines-overview",
+ "/api/v1/apiaries/a-1/weather",
+ ]) {
+ expect(cachesApi(path)).toBe(true);
+ }
+ });
+
+ it("keeps a cacheable path whose answer carries the real header Cache-Control: no-store", () => {
+ // The backend marks every answer no-store; the offline packs depend on keeping them.
+ const answer = { ok: true, contentType: "application/json; charset=utf-8", cacheControl: "no-store" };
+ for (const path of ["/api/v1/colonies/c-1", "/api/v1/tasks", "/api/v1/apiaries/a-1/weather"]) {
+ expect(cachesApi(path) && routes.isKeepableApiAnswer(answer)).toBe(true);
+ }
+ });
+
+ it("does not keep the never-cached paths, whatever the header says", () => {
+ const answer = { ok: true, contentType: "application/json", cacheControl: "no-store" };
+ expect(routes.isKeepableApiAnswer(answer)).toBe(true);
+ for (const path of [
+ "/api/v1/compliance/readiness",
+ "/api/v1/authority-notices/n-1",
+ "/api/v1/veterinary-medicine-lots/l-1",
+ "/api/v1/apiaries/a-1/restrictions",
+ "/api/v1/documents/d-1",
+ "/api/v1/documents?entityType=MEDICINE_LOT&entityId=l-1",
+ "/api/v1/apiaries/a-1/offline-pack",
+ "/api/v1/colonies",
+ ]) {
+ expect(cachesApi(path)).toBe(false);
+ }
+ });
+
+ it("keeps only successful JSON answers", () => {
+ const json = { ok: true, contentType: "application/json; charset=utf-8", cacheControl: null };
+ expect(routes.isKeepableApiAnswer(json)).toBe(true);
+ expect(routes.isKeepableApiAnswer({ ...json, ok: false })).toBe(false);
+ expect(routes.isKeepableApiAnswer({ ...json, contentType: "application/pdf" })).toBe(false);
+ expect(routes.isKeepableApiAnswer({ ...json, contentType: null })).toBe(false);
+ });
+
it("caches only answers of the configured backend", () => {
expect(cachesApi("/api/v1/tasks", "https://elsewhere.example")).toBe(false);
expect(routes.shouldCacheApi(new URL("/api/v1/tasks", API), null)).toBe(false);
diff --git a/ApiCore-Frontend/src/lib/products/types.ts b/ApiCore-Frontend/src/lib/products/types.ts
index 2591702..86b9efa 100644
--- a/ApiCore-Frontend/src/lib/products/types.ts
+++ b/ApiCore-Frontend/src/lib/products/types.ts
@@ -13,6 +13,8 @@ export interface Product {
packagingSize: number | null;
/** Months the honey stays good; the bottling form derives its best-before day from it. */
shelfLifeMonths: number | null;
+ /** Name on the label, e.g. „Blütenhonig“. Up to 200 characters. */
+ legalName: string | null;
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
@@ -30,6 +32,7 @@ export interface CreateProductInput {
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
+ legalName?: string | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
@@ -44,6 +47,7 @@ export interface UpdateProductInput {
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
+ legalName?: string | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
diff --git a/ApiCore-Frontend/src/lib/site-settings/types.ts b/ApiCore-Frontend/src/lib/site-settings/types.ts
index aa5ea8b..bd47a72 100644
--- a/ApiCore-Frontend/src/lib/site-settings/types.ts
+++ b/ApiCore-Frontend/src/lib/site-settings/types.ts
@@ -6,6 +6,10 @@ export interface SiteSettings {
websiteUrl: string | null;
holderName: string | null;
holderAddress: string | null;
+ /** Name on the label: the person who puts the honey on the market. Up to 200 characters. */
+ foodBusinessName: string | null;
+ /** Address on the label. Up to 300 characters. Never on public pages. */
+ foodBusinessAddress: string | null;
originCountry: string;
updatedAt: string;
}
@@ -17,5 +21,8 @@ export interface UpdateSiteSettingsInput {
websiteUrl?: string;
holderName?: string;
holderAddress?: string;
+ /** `null` clears the value. */
+ foodBusinessName?: string | null;
+ foodBusinessAddress?: string | null;
originCountry?: string;
}
diff --git a/ApiCore-Frontend/src/lib/system/labels.spec.ts b/ApiCore-Frontend/src/lib/system/labels.spec.ts
index 259513c..3491440 100644
--- a/ApiCore-Frontend/src/lib/system/labels.spec.ts
+++ b/ApiCore-Frontend/src/lib/system/labels.spec.ts
@@ -73,4 +73,13 @@ describe("DOMAIN_EVENT_LABELS completeness", () => {
expect(types.length).toBeGreaterThan(20);
expect(types.filter((type) => !(type in DOMAIN_EVENT_LABELS))).toEqual([]);
});
+
+ it("DOMAIN_EVENT_LABELS names ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached", () => {
+ expect(DOMAIN_EVENT_LABELS).toMatchObject({
+ ColonyCreated: "Volk angelegt",
+ ColonyMoved: "Volk umgesetzt",
+ ApiaryChanged: "Stand geändert",
+ TskReferenceDayReached: "TSK-Stichtag erreicht",
+ });
+ });
});