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}
- + {canPrint ? ( +
+
+ + +
+ {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 ( + + + +
+ + Punkt quittieren + + {item.title}. Schreib kurz auf, warum das so in Ordnung ist. Ändert sich der Punkt, ist er wieder offen. + + + + {({ id, describedBy: fieldDescribedBy, invalid }) => ( +