feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
- 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) <[email protected]>
This commit is contained in:
1 parent
b912d29166
commit
b4746a2b17
149 files changed
+11869
-320
No files matched your search
+65
-7
@@ -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/<id>`, `/colonies/<id>`) 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-<Schlüssel>-<Subjekt>`). 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`).
|
||||
@@ -63,7 +63,7 @@ function dto(patch: Record<string, string> = {}) {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,11 @@ export const MIN_NOTE_LENGTH = 10;
|
||||
export const MAX_NOTE_LENGTH = 2000;
|
||||
const NO_PERMISSIONS: ReadonlySet<string> = 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<WithdrawalCheckService, 'conflictsForHarvests'>,
|
||||
today: string,
|
||||
): Promise<ReadinessHarvest[]> {
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof buildPlaceholderValues>[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<ReadinessLabelTarget> {
|
||||
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<T extends { id: string; jarCount: number }>(
|
||||
db: DbClient,
|
||||
bottlings: readonly T[],
|
||||
): Promise<T | undefined> {
|
||||
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<ReadinessLabelTemplate[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { DbClient } from '../../../prisma/db-client';
|
||||
import { loadLots } from './lots.loader';
|
||||
|
||||
function lotRow(patch: Record<string, unknown> = {}) {
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
@@ -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<ReadinessLots> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ReadinessTreatments> {
|
||||
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<ColonyMove[]> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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<WithdrawalCheckService, 'conflictsForHarvests'>;
|
||||
}
|
||||
|
||||
/** 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<ReadinessContext> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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> = {}): 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)}`);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<ReadinessArea, string>> = {
|
||||
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<Record<ReadinessSeverity, string>> = {
|
||||
PFLICHT: 'Pflicht',
|
||||
PRUEFEN: 'Prüfen',
|
||||
};
|
||||
|
||||
const STATE_WORDS: Readonly<Record<ReadinessItem['state'], string>> = {
|
||||
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<Buffer> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
): Promise<ReadinessReport> {
|
||||
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<string>,
|
||||
): 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<ReadinessContext> {
|
||||
return loadReadinessContext(this.prisma, now, {
|
||||
withdrawal: this.withdrawal,
|
||||
});
|
||||
}
|
||||
|
||||
private async evaluateIn(
|
||||
ctx: ReadinessContext,
|
||||
permissions: ReadonlySet<string>,
|
||||
): Promise<ReadinessReport> {
|
||||
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<ReadinessSummary> {
|
||||
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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
herdStatus,
|
||||
notice,
|
||||
readinessContext,
|
||||
} from './rule-spec-helpers';
|
||||
} from './rule.spec-helper';
|
||||
|
||||
function deviation(patch: Partial<HerdDeviation> = {}): HerdDeviation {
|
||||
return {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<ReadinessContext, Map<string, LabelCheckResult>>();
|
||||
|
||||
/**
|
||||
* 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<string, LabelCheckResult>();
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
@@ -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<typeof readinessLot>[]) {
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
})),
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'])),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<Record<TreatmentLogGap, string>> = {
|
||||
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<TreatmentLogGap> = new Set([
|
||||
'SUPPLIER',
|
||||
'PROOF',
|
||||
'VET',
|
||||
]);
|
||||
|
||||
/** The treatments of the last 6 calendar years, the book's lookback. */
|
||||
export function treatmentsInLookback(
|
||||
ctx: Pick<ReadinessContext, 'treatments' | 'today'>,
|
||||
): 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<TreatmentLogSource, 'id' | 'colonyId' | 'colonyCode' | 'date'>,
|
||||
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<ReadinessLot, 'id' | 'medicineName' | 'purchasedOn'>,
|
||||
missing?: string[],
|
||||
): ReadinessSubject {
|
||||
return {
|
||||
type: 'LOT',
|
||||
id: lot.id,
|
||||
label: `${lot.medicineName}, Packung vom ${formatDayDe(lot.purchasedOn, true)}`,
|
||||
...(missing ? { missing } : {}),
|
||||
};
|
||||
}
|
||||
+51
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
})),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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> = {}): 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> = {},
|
||||
): 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> = {}): 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<ApiaryRestriction> & { 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> = {}): HerdStatus {
|
||||
return {
|
||||
settings: complianceSettings(),
|
||||
baseline: null,
|
||||
current: emptySnapshot(),
|
||||
deviation: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function readinessContext(
|
||||
patch: Partial<ReadinessContext> = {},
|
||||
): ReadinessContext {
|
||||
return {
|
||||
now: new Date('2027-03-15T10:00:00.000Z'),
|
||||
today: '2027-03-15',
|
||||
site: siteSettings(),
|
||||
settings: complianceSettings(),
|
||||
herd: herdStatus(),
|
||||
notices: [],
|
||||
restrictions: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -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> = {}): 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> = {},
|
||||
): 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> = {}): 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<ApiaryRestriction> & { 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> = {}): HerdStatus {
|
||||
return {
|
||||
settings: complianceSettings(),
|
||||
baseline: null,
|
||||
current: emptySnapshot(),
|
||||
deviation: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function readinessContext(
|
||||
patch: Partial<ReadinessContext> = {},
|
||||
): 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> = {},
|
||||
): 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<TreatmentLogSource['lot']>> = {},
|
||||
): NonNullable<TreatmentLogSource['lot']> {
|
||||
return {
|
||||
id: 'l1',
|
||||
batchNumber: 'CH-1',
|
||||
supplier: 'Lieferant',
|
||||
purchaseProof: 'Beleg 1',
|
||||
prescribingVet: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function colonyMove(patch: Partial<ColonyMove> = {}): 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> = {}): 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> = {},
|
||||
): ReadinessMedicine {
|
||||
return { id: 'm1', name: 'Mittel Eins', withdrawalPeriodDays: 0, ...patch };
|
||||
}
|
||||
|
||||
export function textElement(
|
||||
patch: Partial<LabelTextElement> = {},
|
||||
): 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> = {},
|
||||
): 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> = {},
|
||||
): ReadinessHarvest {
|
||||
return { id: 'h1', day: '2027-03-01', conflicts: [], ...patch };
|
||||
}
|
||||
|
||||
export function conflict(
|
||||
patch: Partial<WithdrawalConflict> = {},
|
||||
): WithdrawalConflict {
|
||||
return {
|
||||
harvestId: 'h1',
|
||||
harvestedOn: '2027-03-01',
|
||||
colonyId: 'c1',
|
||||
treatmentId: 't1',
|
||||
product: 'Mittel Eins',
|
||||
untilDay: '2027-03-10',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { treatmentExpiredLotRule } from './treatment-expired-lot.rule';
|
||||
import {
|
||||
readinessContext,
|
||||
readinessLot,
|
||||
treatmentLotRef,
|
||||
treatmentSource,
|
||||
} from './rule.spec-helper';
|
||||
|
||||
function run(treatments: ReturnType<typeof treatmentSource>[]) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { treatmentLocationUncertainRule } from './treatment-location-uncertain.rule';
|
||||
import {
|
||||
colonyMove,
|
||||
readinessContext,
|
||||
treatmentSource,
|
||||
} from './rule.spec-helper';
|
||||
|
||||
type Source = ReturnType<typeof treatmentSource>;
|
||||
|
||||
describe('treatment-location-uncertain rule', () => {
|
||||
// No apiary stored with the treatment, and the colony moved later.
|
||||
const uncertain = (patch: Partial<Source> = {}) =>
|
||||
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<typeof colonyMove>[] = [],
|
||||
) =>
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<typeof treatmentSource>[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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, Set<TreatmentLogGap>>();
|
||||
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<TreatmentLogGap>();
|
||||
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<number, TreatmentLogSource[]>();
|
||||
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,
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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<typeof readinessContext>[0] = {}) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) => <h1>{title}</h1>,
|
||||
DetailHeader: ({
|
||||
title,
|
||||
moreActions,
|
||||
}: {
|
||||
title: string;
|
||||
moreActions?: { label: string; disabled?: boolean }[];
|
||||
}) => (
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
<ul>
|
||||
{moreActions?.map((action) => (
|
||||
<li key={action.label} data-disabled={action.disabled ? "true" : undefined}>
|
||||
{action.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</header>
|
||||
),
|
||||
DetailQueryFallback: () => <p>Lädt</p>,
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApiaryDetailPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApiaryDetailPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <DetailQueryFallback query={apiaryQuery} what="Standdaten" {...BACK} />;
|
||||
}
|
||||
|
||||
const canReadMedicines = hasPermission("veterinary-medicines:read");
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
@@ -59,11 +63,14 @@ export default function ApiaryDetailPage() {
|
||||
href: `/harvests/new?apiaryId=${apiary.id}`,
|
||||
},
|
||||
{
|
||||
label: treatmentLogMutation.isPending
|
||||
? "Bestandsbuch wird erstellt…"
|
||||
: `Bestandsbuch ${CURRENT_YEAR} (PDF)`,
|
||||
// The book shows supplier, proof and vet from the lot (needs the medicines right).
|
||||
label: !canReadMedicines
|
||||
? `Bestandsbuch ${CURRENT_YEAR} (PDF) – nur mit dem Recht Tierarzneimittel lesen`
|
||||
: treatmentLogMutation.isPending
|
||||
? "Bestandsbuch wird erstellt…"
|
||||
: `Bestandsbuch ${CURRENT_YEAR} (PDF)`,
|
||||
icon: FileText,
|
||||
disabled: treatmentLogMutation.isPending,
|
||||
disabled: !canReadMedicines || treatmentLogMutation.isPending,
|
||||
onSelect: () => treatmentLogMutation.mutate(),
|
||||
},
|
||||
{
|
||||
@@ -77,6 +84,11 @@ export default function ApiaryDetailPage() {
|
||||
]}
|
||||
{...BACK}
|
||||
/>
|
||||
{!canReadMedicines ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.
|
||||
</p>
|
||||
) : null}
|
||||
<ApiarySeason apiary={{ id: apiary.id, name: apiary.name }} />
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function BottlingBatchDetailPage() {
|
||||
unit="Gläser"
|
||||
canBook={hasPermission("bottling:withdraw")}
|
||||
/>
|
||||
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
|
||||
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} productId={bottlingBatch.productId} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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<typeof import("@/lib/label-templates/check")>()),
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<PrintLabelsCard bottlingBatchId="b-1" productId={props.productId === undefined ? "p-9" : props.productId} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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<Blob>((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/);
|
||||
});
|
||||
});
|
||||
@@ -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<string, readonly FixTarget[]> = {
|
||||
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<HTMLElement | null>;
|
||||
}) {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
tabIndex={-1}
|
||||
aria-labelledby={headingId}
|
||||
className="flex flex-col gap-2 rounded-xl border border-destructive/50 p-3 focus:outline-2 focus:outline-ring"
|
||||
>
|
||||
<h3 id={headingId} className="flex items-center gap-2 text-sm font-semibold text-destructive">
|
||||
<ShieldAlert aria-hidden className="size-4" />
|
||||
Pflichtangaben fehlen
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{gaps.map((gap, index) => (
|
||||
<li key={`${gap.code}-${index}`} className="flex items-start gap-2 text-sm">
|
||||
<CircleX aria-hidden className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<span className="flex flex-col">
|
||||
<span>
|
||||
<span className="font-semibold text-destructive">Fehlt:</span> {gapName(gap)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{gap.detail || gap.title}</span>
|
||||
<span className="flex flex-wrap gap-x-4">
|
||||
{fixLinks(gap, templateId, productId).map((link) => (
|
||||
<Link key={link.href} href={link.href} className={LINK_CLASS}>
|
||||
{link.label}
|
||||
<span className="sr-only"> für {gapName(gap)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<ServerGaps | null>(null);
|
||||
const [printError, setPrintError] = useState<string | null>(null);
|
||||
const gapsRef = useRef<HTMLElement>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tag className="size-4.5" />
|
||||
Etiketten drucken
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dir fehlt die Berechtigung für Etiketten. Wende dich an die Administration.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -59,6 +271,37 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
|
||||
getLabel={(template) => template.name}
|
||||
placeholder="Vorlage wählen"
|
||||
/>
|
||||
<p aria-live="polite" className="text-sm font-medium">
|
||||
{summary}
|
||||
</p>
|
||||
{check.isError ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{labelCheckErrorText(check.error, CHECK_FALLBACK)}
|
||||
</p>
|
||||
) : null}
|
||||
{gaps.length > 0 ? (
|
||||
<GapList
|
||||
gaps={gaps}
|
||||
templateId={effectiveTemplateId}
|
||||
productId={productId}
|
||||
sectionRef={gapsRef}
|
||||
/>
|
||||
) : null}
|
||||
{warnings.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">Hinweise (sie sperren den Druck nicht)</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{warnings.map((warning, index) => (
|
||||
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
|
||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<span>
|
||||
<span className="font-medium">Hinweis:</span> {warning.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="print-start-position">
|
||||
Startposition auf dem Bogen (1 = oben links)
|
||||
@@ -76,14 +319,53 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
|
||||
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!effectiveTemplateId || mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
{mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
|
||||
</Button>
|
||||
{canPrint ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
aria-disabled={mainInactive ? true : undefined}
|
||||
aria-describedby={mainDescribedBy}
|
||||
className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (!mainInactive) mutation.mutate(false);
|
||||
}}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
{mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
aria-disabled={idle ? true : undefined}
|
||||
aria-describedby={offlineDescribedBy}
|
||||
className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (!idle) mutation.mutate(true);
|
||||
}}
|
||||
>
|
||||
Probedruck (MUSTER)
|
||||
</Button>
|
||||
</div>
|
||||
{blockReason ? (
|
||||
<p id={reasonId} className="text-sm text-muted-foreground">
|
||||
{blockReason}
|
||||
</p>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
{printError ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{printError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dir fehlt die Berechtigung zum Drucken.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<AcknowledgementActions item={item} onDone={onDone} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string | null>(null);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const noteRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Quittieren
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Punkt quittieren</DialogTitle>
|
||||
<DialogDescription>
|
||||
{item.title}. Schreib kurz auf, warum das so in Ordnung ist. Ändert sich der Punkt, ist er wieder offen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field label="Notiz" required hint={`Mindestens ${MIN_NOTE_LENGTH} Zeichen.`} error={noteError}>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
ref={noteRef}
|
||||
value={note}
|
||||
maxLength={MAX_NOTE_LENGTH}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
aria-invalid={invalid}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{serverError ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{serverError}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => handleOpenChange(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" size="touch" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Quittieren bestätigen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface RevokeButtonProps {
|
||||
acknowledgementId: string;
|
||||
disabled: boolean;
|
||||
describedBy: string | undefined;
|
||||
onError: (message: string | null) => void;
|
||||
onDone: (message: string) => void;
|
||||
}
|
||||
|
||||
function RevokeButton({ acknowledgementId, disabled, describedBy, onError, onDone }: RevokeButtonProps) {
|
||||
const reload = useReloadCompliance();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => revokeAcknowledgement(acknowledgementId),
|
||||
onSuccess: async () => {
|
||||
onError(null);
|
||||
await reload();
|
||||
onDone(REVOKED_MESSAGE);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError(readinessConflictText(error) ?? complianceErrorText(error, "Das Zurücknehmen hat nicht geklappt."));
|
||||
if (isConflict(error)) void reload();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Zurücknehmen"
|
||||
triggerSize="touch"
|
||||
triggerDisabled={disabled}
|
||||
triggerDescribedBy={describedBy}
|
||||
title="Quittung zurücknehmen?"
|
||||
description="Der Punkt ist danach wieder offen."
|
||||
confirmLabel="Zurücknehmen bestätigen"
|
||||
destructive={false}
|
||||
isPending={mutation.isPending}
|
||||
onConfirm={() => mutation.mutate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quittieren for an open item that allows it, Zurücknehmen for an acknowledged
|
||||
* one. Both need the server, so offline they are off with the visible reason.
|
||||
*/
|
||||
export function AcknowledgementActions({
|
||||
item,
|
||||
onDone,
|
||||
}: {
|
||||
item: ReadinessItem;
|
||||
/** Called with the text to announce once the change is saved and the report reloaded. */
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const [revokeError, setRevokeError] = useState<string | null>(null);
|
||||
const canAcknowledge = item.state === "OPEN" && item.acknowledgeable;
|
||||
const acknowledgementId = item.state === "ACKNOWLEDGED" ? item.acknowledgement?.id : undefined;
|
||||
|
||||
if (!canAcknowledge && !acknowledgementId) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{canAcknowledge ? (
|
||||
<AcknowledgeDialog item={item} disabled={isOffline} describedBy={describedBy} onDone={onDone} />
|
||||
) : null}
|
||||
{acknowledgementId ? (
|
||||
<RevokeButton
|
||||
acknowledgementId={acknowledgementId}
|
||||
disabled={isOffline}
|
||||
describedBy={describedBy}
|
||||
onError={setRevokeError}
|
||||
onDone={onDone}
|
||||
/>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
<div aria-live="assertive">
|
||||
{revokeError ? <p className="text-sm font-medium text-destructive">{revokeError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import CompliancePage from "./page";
|
||||
import { downloadReadinessReport, fetchReadiness } from "@/lib/compliance/api";
|
||||
import { itemFixture, reportFixture } from "@/lib/compliance/fixtures";
|
||||
import { exportTreatmentLogPdf } from "@/lib/export/api";
|
||||
|
||||
let permissions = ["compliance:read", "compliance:manage"];
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
|
||||
}));
|
||||
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
|
||||
vi.mock("@/lib/compliance/api", () => ({
|
||||
fetchReadiness: vi.fn(),
|
||||
downloadReadinessReport: vi.fn(),
|
||||
acknowledge: vi.fn(),
|
||||
revokeAcknowledgement: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/export/api", () => ({ exportTreatmentLogPdf: vi.fn() }));
|
||||
|
||||
const open = (overrides = {}) => itemFixture({ state: "OPEN", ...overrides });
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompliancePage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CompliancePage", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["compliance:read", "compliance:manage"];
|
||||
vi.mocked(fetchReadiness).mockReset();
|
||||
vi.mocked(exportTreatmentLogPdf).mockReset();
|
||||
vi.mocked(downloadReadinessReport).mockReset();
|
||||
});
|
||||
|
||||
it("shows Prüfbereit in green when nothing is open and offers Bestandsbuch drucken as the main action", async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([itemFixture({ state: "DONE", title: "Angaben vollständig" })]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Prüfbereit")).toBeInTheDocument();
|
||||
const year = new Date().getFullYear();
|
||||
const main = screen.getByRole("button", { name: `Bestandsbuch ${year} drucken` });
|
||||
await userEvent.click(main);
|
||||
expect(exportTreatmentLogPdf).toHaveBeenCalledWith(year);
|
||||
expect(screen.queryByRole("link", { name: "Nächsten Punkt lösen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "3 offen" in red with the symbol and the word Offen for every open item', async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([
|
||||
open({ key: "a", title: "Punkt A" }),
|
||||
open({ key: "b", title: "Punkt B" }),
|
||||
open({ key: "c", title: "Punkt C" }),
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const badge = await screen.findByText("3 offen");
|
||||
expect(badge.closest("[data-slot=badge]")).toHaveClass("text-destructive");
|
||||
expect(badge.parentElement?.querySelector("svg")).not.toBeNull();
|
||||
const items = screen.getAllByRole("listitem").filter((li) => li.hasAttribute("data-item-key"));
|
||||
expect(items).toHaveLength(3);
|
||||
for (const item of items) {
|
||||
expect(within(item).getByText("Offen")).toBeInTheDocument();
|
||||
expect(item.querySelector("svg")).not.toBeNull();
|
||||
expect(item).toHaveClass("border-destructive/40");
|
||||
}
|
||||
});
|
||||
|
||||
it("the main action jumps to the first open PFLICHT item", async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([
|
||||
open({ key: "vet-office", severity: "PRUEFEN", area: "BEHOERDEN", title: "Veterinäramt fehlt" }),
|
||||
open({ key: "holder-data", severity: "PFLICHT", subjects: [] }),
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const main = await screen.findByRole("link", { name: "Nächsten Punkt lösen" });
|
||||
expect(main).toHaveAttribute("href", "/settings/automation?tab=social-media");
|
||||
});
|
||||
|
||||
it("shows the severity as a word, Pflicht or Prüfen, next to the title", async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([
|
||||
open({ key: "holder-data", severity: "PFLICHT", title: "Bienenhalter fehlt" }),
|
||||
open({ key: "vet-office", severity: "PRUEFEN", area: "BEHOERDEN", title: "Veterinäramt fehlt" }),
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const holder = (await screen.findByRole("heading", { name: "Bienenhalter fehlt" })).closest("li");
|
||||
const vet = screen.getByRole("heading", { name: "Veterinäramt fehlt" }).closest("li");
|
||||
expect(within(holder as HTMLElement).getByText("Pflicht")).toBeInTheDocument();
|
||||
expect(within(vet as HTMLElement).getByText("Prüfen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists affected records collapsed with a Beheben link each", async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([
|
||||
open({
|
||||
key: "treatment-log-gaps",
|
||||
area: "TIERARZNEIMITTEL",
|
||||
title: "Lücken im Bestandsbuch",
|
||||
subjects: [
|
||||
{ type: "treatment", id: "t1", parentId: "c1", label: "Behandlung 1", missing: ["Beleg"] },
|
||||
{ type: "lot", id: "l1", label: "Packung 1" },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const summary = await screen.findByText("2 betroffen");
|
||||
expect(summary.closest("details")).not.toHaveAttribute("open");
|
||||
const links = screen.getAllByRole("link", { name: /Beheben/, hidden: true });
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/colonies/c1",
|
||||
"/medicines/lots/l1",
|
||||
]);
|
||||
expect(screen.getByText(/Beleg/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Erledigt (n) collapsed", async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([
|
||||
open({ key: "a" }),
|
||||
itemFixture({ key: "b", state: "DONE", title: "Fertig B" }),
|
||||
itemFixture({ key: "c", state: "DONE", title: "Fertig C" }),
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const summary = await screen.findByText("Erledigt (2)");
|
||||
expect(summary.closest("details")).not.toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("shows the more-count when subjects are cut off", async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([
|
||||
open({
|
||||
key: "harvest-withdrawal",
|
||||
area: "ERNTE",
|
||||
subjects: [{ type: "harvest", id: "h1", label: "Ernte 1" }],
|
||||
more: 7,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("und 7 weitere")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says why nothing can be shown when the report fails to load", async () => {
|
||||
vi.mocked(fetchReadiness).mockRejectedValue(new Error("boom"));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Erneut versuchen/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to showing the item when the page that fixes it does not exist yet", async () => {
|
||||
vi.mocked(fetchReadiness).mockResolvedValue(
|
||||
reportFixture([open({ key: "vet-office", area: "BEHOERDEN", severity: "PFLICHT", subjectId: "global" })]),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const main = await screen.findByRole("link", { name: "Nächsten Punkt ansehen" });
|
||||
expect(main).toHaveAttribute("href", "#item-vet-office-global");
|
||||
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Printer,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { DetailHeader, type MoreAction } from "@/components/detail-header";
|
||||
import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { downloadReadinessReport } from "@/lib/compliance/api";
|
||||
import { complianceErrorText } from "@/lib/compliance/errors";
|
||||
import { fixLinkFor } from "@/lib/compliance/fix-links";
|
||||
import { AREA_LABEL, AREA_ORDER } from "@/lib/compliance/labels";
|
||||
import type { ReadinessItem, ReadinessReport } from "@/lib/compliance/types";
|
||||
import { useReadiness } from "@/lib/compliance/use-readiness";
|
||||
import { exportTreatmentLogPdf } from "@/lib/export/api";
|
||||
import { ReadinessItemCard, cardId } from "./readiness-item-card";
|
||||
|
||||
/** The first open item that is a duty; without one, the first open item. */
|
||||
function nextItemToSolve(report: ReadinessReport): ReadinessItem | null {
|
||||
const open = report.items.filter((item) => item.state === "OPEN");
|
||||
return open.find((item) => item.severity === "PFLICHT") ?? open[0] ?? null;
|
||||
}
|
||||
|
||||
function StatusBadge({ report }: { report: ReadinessReport }) {
|
||||
if (report.status === "READY") {
|
||||
return (
|
||||
<Badge variant="default" className="h-6 gap-1.5 px-2.5 text-sm">
|
||||
<CheckCircle2 aria-hidden />
|
||||
Prüfbereit
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="destructive" className="h-6 gap-1.5 px-2.5 text-sm">
|
||||
<XCircle aria-hidden />
|
||||
{report.openCount} offen
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AreaSection({ area, items, canManage }: { area: (typeof AREA_ORDER)[number]; items: ReadinessItem[]; canManage: boolean }) {
|
||||
const headingId = `area-${area}`;
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||
<h2 id={headingId} className="font-heading text-lg font-semibold tracking-tight">
|
||||
{AREA_LABEL[area]}
|
||||
</h2>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Hier ist nichts offen.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<ReadinessItemCard key={`${item.key}-${item.subjectId}`} item={item} canManage={canManage} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompliancePage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const query = useReadiness();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const treatmentLog = useMutation({
|
||||
mutationFn: () => exportTreatmentLogPdf(year),
|
||||
onMutate: () => setActionError(null),
|
||||
onError: (error) => setActionError(complianceErrorText(error, "Das Bestandsbuch konnte nicht geladen werden.")),
|
||||
});
|
||||
const report = useMutation({
|
||||
mutationFn: downloadReadinessReport,
|
||||
onMutate: () => setActionError(null),
|
||||
onError: (error) => setActionError(complianceErrorText(error, "Der Bericht konnte nicht geladen werden.")),
|
||||
});
|
||||
|
||||
const data = query.data;
|
||||
const grouped = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
return {
|
||||
byArea: AREA_ORDER.map((area) => ({
|
||||
area,
|
||||
items: items.filter((item) => item.area === area && item.state !== "DONE"),
|
||||
})),
|
||||
done: items.filter((item) => item.state === "DONE"),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const header = (
|
||||
<DetailHeader
|
||||
icon={ShieldCheck}
|
||||
title="Prüfbereitschaft"
|
||||
backHref="/dashboard"
|
||||
backLabel="Start"
|
||||
status={data ? <StatusBadge report={data} /> : undefined}
|
||||
primaryAction={data ? primaryAction(data) : undefined}
|
||||
moreActions={moreActions()}
|
||||
/>
|
||||
);
|
||||
|
||||
function primaryAction(current: ReadinessReport) {
|
||||
const next = current.status === "OPEN" ? nextItemToSolve(current) : null;
|
||||
if (next) {
|
||||
const fix = fixLinkFor(next.key, next.subjects[0] ?? null);
|
||||
// Without a page to fix it on, the button shows the item on this page.
|
||||
return (
|
||||
<Button size="touch" render={<Link href={fix.available ? fix.href : `#${cardId(next)}`} />}>
|
||||
<TriangleAlert aria-hidden />
|
||||
{fix.available ? "Nächsten Punkt lösen" : "Nächsten Punkt ansehen"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="touch"
|
||||
disabled={isOffline || treatmentLog.isPending}
|
||||
aria-describedby={describedBy}
|
||||
onClick={() => treatmentLog.mutate()}
|
||||
>
|
||||
<Printer aria-hidden />
|
||||
{`Bestandsbuch ${year} drucken`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function moreActions(): MoreAction[] {
|
||||
const actions: MoreAction[] = [
|
||||
{ label: "Bericht (PDF)", icon: FileText, onSelect: () => report.mutate(), disabled: isOffline || report.isPending },
|
||||
];
|
||||
if (data?.status === "OPEN") {
|
||||
actions.push({
|
||||
label: `Bestandsbuch ${year} drucken`,
|
||||
icon: Printer,
|
||||
onSelect: () => treatmentLog.mutate(),
|
||||
disabled: isOffline || treatmentLog.isPending,
|
||||
});
|
||||
}
|
||||
actions.push(
|
||||
{ label: "Anzeigen ans Veterinäramt", icon: BookOpen, href: "/compliance/notices" },
|
||||
{ label: "Sperrbezirke", icon: TriangleAlert, href: "/compliance/restrictions" },
|
||||
{ label: "Einstellungen", icon: Settings, href: "/compliance/settings" },
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
|
||||
{header}
|
||||
<StandNote updatedAt={query.dataUpdatedAt} isStale={query.isError && data !== undefined} />
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
<div aria-live="assertive">
|
||||
{actionError ? <p className="text-sm font-medium text-destructive">{actionError}</p> : null}
|
||||
</div>
|
||||
<div aria-live="polite" className="flex flex-col gap-6">
|
||||
{query.isLoading ? <LoadingState what="Die Prüfpunkte" /> : null}
|
||||
{!query.isLoading && data === undefined ? (
|
||||
<ErrorState what="Die Prüfpunkte" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
|
||||
) : null}
|
||||
{data ? (
|
||||
<>
|
||||
{grouped.byArea.map(({ area, items }) => (
|
||||
<AreaSection key={area} area={area} items={items} canManage={hasPermission("compliance:manage")} />
|
||||
))}
|
||||
{grouped.done.length > 0 ? (
|
||||
<details className="rounded-xl border border-border/60 bg-card">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center px-3 font-heading text-base font-semibold">
|
||||
Erledigt ({grouped.done.length})
|
||||
</summary>
|
||||
<ul className="flex flex-col gap-2 p-3 pt-0">
|
||||
{grouped.done.map((item) => (
|
||||
<ReadinessItemCard key={`${item.key}-${item.subjectId}`} item={item} canManage={false} />
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReadinessItemCard } from "./readiness-item-card";
|
||||
import { acknowledge } from "@/lib/compliance/api";
|
||||
import { itemFixture } from "@/lib/compliance/fixtures";
|
||||
import type { ReadinessItem } from "@/lib/compliance/types";
|
||||
|
||||
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
|
||||
vi.mock("@/lib/compliance/api", () => ({ acknowledge: vi.fn(), revokeAcknowledgement: vi.fn() }));
|
||||
|
||||
function renderCard(item: ReadinessItem, canManage = true) {
|
||||
const client = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ul>
|
||||
<ReadinessItemCard item={item} canManage={canManage} />
|
||||
</ul>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ReadinessItemCard", () => {
|
||||
it("shows the open item red with symbol, word, severity and the legal basis small", () => {
|
||||
renderCard(itemFixture({ legalBasis: "Art. 108 VO (EU) 2019/6" }));
|
||||
|
||||
const card = screen.getByRole("listitem");
|
||||
expect(card).toHaveClass("border-destructive/40");
|
||||
expect(screen.getByText("Offen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pflicht")).toBeInTheDocument();
|
||||
expect(screen.getByText("Art. 108 VO (EU) 2019/6")).toHaveClass("text-xs");
|
||||
expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/automation?tab=social-media",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows only the count when the caller may not see the subjects", () => {
|
||||
renderCard(itemFixture({ key: "restriction-active", subjects: [], more: 3 }));
|
||||
|
||||
expect(screen.getByText("3 betroffen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an acknowledged item calm, with who wrote which note", () => {
|
||||
renderCard(
|
||||
itemFixture({
|
||||
state: "ACKNOWLEDGED",
|
||||
acknowledgeable: true,
|
||||
acknowledgement: {
|
||||
id: "a1",
|
||||
note: "Mit dem Amt abgesprochen.",
|
||||
userName: "Nick",
|
||||
createdAt: "2026-10-01T10:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const card = screen.getByRole("listitem");
|
||||
expect(card).not.toHaveClass("border-destructive/40");
|
||||
expect(screen.getByText("Quittiert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Mit dem Amt abgesprochen\./)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Zurücknehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the acknowledge actions without compliance:manage", () => {
|
||||
renderCard(itemFixture({ acknowledgeable: true }), false);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Quittieren" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves focus to the heading and announces Punkt quittiert after acknowledging", async () => {
|
||||
vi.mocked(acknowledge).mockResolvedValue(undefined);
|
||||
renderCard(itemFixture({ acknowledgeable: true, title: "Wichtiger Punkt" }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
|
||||
await userEvent.type(await screen.findByLabelText(/Notiz/), "Mit dem Amt besprochen.");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Quittieren bestätigen" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Punkt quittiert"));
|
||||
expect(screen.getByRole("heading", { name: "Wichtiger Punkt" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("hides Beheben while the target page does not exist", () => {
|
||||
renderCard(itemFixture({ key: "vet-office", area: "BEHOERDEN" }));
|
||||
|
||||
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowRight, CheckCircle2, ClipboardCheck, XCircle, type LucideIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { fixLinkFor } from "@/lib/compliance/fix-links";
|
||||
import { SEVERITY_LABEL, itemTone } from "@/lib/compliance/labels";
|
||||
import type { ReadinessItem, ReadinessItemState, ReadinessSubject } from "@/lib/compliance/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AcknowledgementActions } from "./acknowledge-dialog";
|
||||
|
||||
const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
|
||||
OPEN: XCircle,
|
||||
DONE: CheckCircle2,
|
||||
ACKNOWLEDGED: ClipboardCheck,
|
||||
};
|
||||
|
||||
const FIX_LINK_CLASS =
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
|
||||
const { href, label, available } = fixLinkFor(itemKey, subject);
|
||||
// Hidden while the target page does not exist (see ROUTE_AVAILABLE).
|
||||
if (!available) return null;
|
||||
return (
|
||||
<Link href={href} className={FIX_LINK_CLASS}>
|
||||
{label}
|
||||
<span className="sr-only">: {context}</span>
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SubjectList({ item }: { item: ReadinessItem }) {
|
||||
const total = item.subjects.length + item.more;
|
||||
if (item.subjects.length === 0) {
|
||||
// Without the right to see names only the count is left.
|
||||
return item.more > 0 ? <p className="text-sm">{item.more} betroffen</p> : null;
|
||||
}
|
||||
return (
|
||||
<details className="rounded-lg border border-border/60 bg-card">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center px-3 text-sm font-medium">
|
||||
{total} betroffen
|
||||
</summary>
|
||||
<ul className="flex flex-col divide-y divide-border/60 px-3 pb-1">
|
||||
{item.subjects.map((subject) => (
|
||||
<li key={`${subject.type}-${subject.id}`} className="flex flex-wrap items-center justify-between gap-2 py-1 text-sm">
|
||||
<span className="min-w-0 break-words">
|
||||
{subject.label}
|
||||
{subject.missing && subject.missing.length > 0 ? (
|
||||
<span className="text-muted-foreground"> – es fehlt: {subject.missing.join(", ")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<FixLink itemKey={item.key} subject={subject} context={subject.label} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{item.more > 0 ? <p className="px-3 pb-2 text-sm text-muted-foreground">und {item.more} weitere</p> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anchor of a card, so the main action can point at an item whose page is not there yet. */
|
||||
export function cardId(item: Pick<ReadinessItem, "key" | "subjectId">): string {
|
||||
return `item-${item.key}-${item.subjectId}`;
|
||||
}
|
||||
|
||||
function formatDay(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE");
|
||||
}
|
||||
|
||||
interface ReadinessItemCardProps {
|
||||
item: ReadinessItem;
|
||||
/** compliance:manage - acknowledging and revoking. */
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
|
||||
const { tone, word } = itemTone(item.state);
|
||||
const StateIcon = STATE_ICON[item.state];
|
||||
const isOpen = item.state === "OPEN";
|
||||
const { acknowledgement } = item;
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
// After saving, the action buttons may be gone: focus moves to the heading.
|
||||
function handleDone(message: string) {
|
||||
setAnnouncement(message);
|
||||
headingRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
id={cardId(item)}
|
||||
data-item-key={item.key}
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-xl border p-3",
|
||||
isOpen ? "border-destructive/40 bg-destructive/5" : "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={tone} className="h-6 gap-1.5 px-2.5 text-sm">
|
||||
<StateIcon aria-hidden />
|
||||
{word}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="h-6 px-2.5 text-sm">
|
||||
{SEVERITY_LABEL[item.severity]}
|
||||
</Badge>
|
||||
<h3
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="min-w-0 font-heading text-base font-semibold break-words outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm">{item.detail}</p>
|
||||
{item.legalBasis ? <p className="text-xs text-muted-foreground">{item.legalBasis}</p> : null}
|
||||
{acknowledgement ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{acknowledgement.userName}, {formatDay(acknowledgement.createdAt)}: {acknowledgement.note}
|
||||
</p>
|
||||
) : null}
|
||||
{isOpen ? <SubjectList item={item} /> : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isOpen && item.subjects.length === 0 ? (
|
||||
<FixLink itemKey={item.key} subject={null} context={item.title} />
|
||||
) : null}
|
||||
{canManage ? <AcknowledgementActions item={item} onDone={handleDone} /> : null}
|
||||
</div>
|
||||
<p role="status" className="sr-only">
|
||||
{announcement}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,9 @@ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
|
||||
fetchAutopilotToday: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/compliance/api", () => ({
|
||||
fetchReadinessSummary: vi.fn().mockResolvedValue({ status: "READY", openCount: 0, firstItemKey: null }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
@@ -26,6 +26,7 @@ import { tasksForToday } from "@/lib/tasks/today";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HoneycombPattern } from "@/components/honeycomb-pattern";
|
||||
import { StandNote } from "@/components/offline/stand-note";
|
||||
import { ComplianceLine } from "@/components/today/compliance-line";
|
||||
import { TodayPanel } from "@/components/today/today-panel";
|
||||
|
||||
function alertHref(alert: DashboardAlert): string | null {
|
||||
@@ -191,6 +192,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ComplianceLine />
|
||||
|
||||
<TodayPanel
|
||||
tasks={tasksQuery.data}
|
||||
isLoading={tasksQuery.isLoading || autopilotQuery.isLoading}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LotFormDialog } from "./lot-form-dialog";
|
||||
import { createLot, updateLot } from "@/lib/medicines/api";
|
||||
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
|
||||
vi.mock("@/lib/medicines/api", () => ({ createLot: vi.fn(), updateLot: vi.fn() }));
|
||||
|
||||
function renderDialog(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("LotFormDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createLot).mockReset();
|
||||
vi.mocked(updateLot).mockReset();
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
});
|
||||
|
||||
it("books a lot with an Idempotency-Key and prefills the default supplier", async () => {
|
||||
vi.mocked(createLot).mockResolvedValue(lot());
|
||||
renderDialog(<LotFormDialog medicine={medicine({ defaultSupplier: "Tierarzt Muster" })} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
|
||||
expect(await screen.findByLabelText(/Lieferant/)).toHaveValue("Tierarzt Muster");
|
||||
await userEvent.type(screen.getByLabelText(/Menge/), "20");
|
||||
await userEvent.type(screen.getByLabelText(/Chargennummer/), "CH-42");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
|
||||
|
||||
expect(createLot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
medicineId: "m1",
|
||||
quantity: 20,
|
||||
batchNumber: "CH-42",
|
||||
supplier: "Tierarzt Muster",
|
||||
purchasedOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
}),
|
||||
{ idempotencyKey: expect.any(String) },
|
||||
);
|
||||
});
|
||||
|
||||
it("labels the quantity with the unit of the medicine", async () => {
|
||||
renderDialog(<LotFormDialog medicine={medicine({ unit: "ML" })} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
|
||||
expect(await screen.findByLabelText(/^Menge \(ml\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("needs a positive quantity", async () => {
|
||||
renderDialog(<LotFormDialog medicine={medicine()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Erfassen" }));
|
||||
|
||||
expect(await screen.findByText("Bitte eine Menge über 0 eingeben.")).toBeInTheDocument();
|
||||
expect(createLot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the server message for a unit mismatch or an inactive medicine", async () => {
|
||||
vi.mocked(createLot).mockRejectedValue(new ApiError(400, "Das Mittel ist deaktiviert.", { message: "Das Mittel ist deaktiviert." }));
|
||||
renderDialog(<LotFormDialog medicine={medicine()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
await userEvent.type(await screen.findByLabelText(/Menge/), "5");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Das Mittel ist deaktiviert.");
|
||||
});
|
||||
|
||||
it("edits a lot and clears an emptied optional field with null", async () => {
|
||||
vi.mocked(updateLot).mockResolvedValue(lot());
|
||||
renderDialog(<LotFormDialog medicine={medicine()} lot={lot()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
|
||||
await userEvent.clear(await screen.findByLabelText(/Lieferant/));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(updateLot).toHaveBeenCalledWith(
|
||||
"l1",
|
||||
expect.objectContaining({ supplier: null, quantity: 20, batchNumber: "CH-42" }),
|
||||
);
|
||||
expect(updateLot).toHaveBeenCalledWith("l1", expect.not.objectContaining({ medicineId: expect.anything() }));
|
||||
});
|
||||
|
||||
it("is off offline, with the visible reason", async () => {
|
||||
renderDialog(<LotFormDialog medicine={medicine()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
await screen.findByRole("button", { name: "Erfassen" });
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
|
||||
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Erfassen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows the message of a 409 in the dialog", async () => {
|
||||
vi.mocked(createLot).mockRejectedValue(new ApiError(409, "Die Packung wurde gerade geändert. Bitte neu laden.", {}));
|
||||
renderDialog(<LotFormDialog medicine={medicine()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
await userEvent.type(await screen.findByLabelText(/Menge/), "5");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Die Packung wurde gerade geändert. Bitte neu laden.");
|
||||
});
|
||||
|
||||
it("refuses an ambiguous thousands dot in the quantity", async () => {
|
||||
renderDialog(<LotFormDialog medicine={medicine()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
await userEvent.type(await screen.findByLabelText(/Menge/), "1.000");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erfassen" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5"),
|
||||
).toBeInTheDocument();
|
||||
expect(createLot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renews the Idempotency-Key after a 409 and when the values change after a network error", async () => {
|
||||
vi.mocked(createLot)
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
||||
.mockRejectedValueOnce(new ApiError(409, "Gerade geändert.", {}))
|
||||
.mockResolvedValue(lot());
|
||||
renderDialog(<LotFormDialog medicine={medicine()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Packung erfassen" }));
|
||||
const quantity = await screen.findByLabelText(/Menge/);
|
||||
await userEvent.type(quantity, "5");
|
||||
const submit = screen.getByRole("button", { name: "Erfassen" });
|
||||
|
||||
await userEvent.click(submit); // network error
|
||||
await userEvent.click(submit); // unchanged retry: same key, network error again
|
||||
await userEvent.type(quantity, "0"); // changed body
|
||||
await userEvent.click(submit); // 409
|
||||
await userEvent.click(submit); // unchanged retry after a server answer
|
||||
|
||||
const keys = vi.mocked(createLot).mock.calls.map((call) => call[1]?.idempotencyKey);
|
||||
expect(keys[1]).toBe(keys[0]);
|
||||
expect(keys[2]).not.toBe(keys[1]);
|
||||
expect(keys[3]).not.toBe(keys[2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createLot, updateLot } from "@/lib/medicines/api";
|
||||
import { decimalMessage, MEDICINE_UNIT_LABELS, parseDecimal } from "@/lib/medicines/format";
|
||||
import type { LotInput, LotView, MedicineView } from "@/lib/medicines/types";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
|
||||
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type LotMedicine = Pick<MedicineView, "id" | "name" | "unit"> &
|
||||
Partial<Pick<MedicineView, "defaultSupplier">>;
|
||||
|
||||
interface LotFormDialogProps {
|
||||
medicine: LotMedicine;
|
||||
/** Edit this lot; without it the dialog records a new one. */
|
||||
lot?: LotView;
|
||||
/** Switched off from outside, e.g. offline or an inactive medicine; `describedBy` names the reason. */
|
||||
disabled?: boolean;
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
interface LotFormValues {
|
||||
batchNumber: string;
|
||||
purchasedOn: string;
|
||||
quantity: string;
|
||||
expiresOn: string;
|
||||
supplier: string;
|
||||
purchaseProof: string;
|
||||
prescribingVet: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
type LotFormErrors = Partial<Record<keyof LotFormValues, string>>;
|
||||
|
||||
const INPUT_CLASS = "h-11 sm:h-11";
|
||||
|
||||
function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
|
||||
if (lot) {
|
||||
return {
|
||||
batchNumber: lot.batchNumber ?? "",
|
||||
purchasedOn: lot.purchasedOn,
|
||||
quantity: String(lot.quantity),
|
||||
expiresOn: lot.expiresOn ?? "",
|
||||
supplier: lot.supplier ?? "",
|
||||
purchaseProof: lot.purchaseProof ?? "",
|
||||
prescribingVet: lot.prescribingVet ?? "",
|
||||
notes: lot.notes ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
batchNumber: "",
|
||||
purchasedOn: todayLocalIsoDate(),
|
||||
quantity: "",
|
||||
expiresOn: "",
|
||||
supplier: medicine.defaultSupplier ?? "",
|
||||
purchaseProof: "",
|
||||
prescribingVet: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function errorsOf(values: LotFormValues): LotFormErrors {
|
||||
const errors: LotFormErrors = {};
|
||||
if (values.purchasedOn === "") errors.purchasedOn = "Bitte das Kaufdatum angeben.";
|
||||
const quantity = parseDecimal(values.quantity);
|
||||
if (!Number.isFinite(quantity) || quantity <= 0) errors.quantity = decimalMessage(values.quantity, "Bitte eine Menge über 0 eingeben.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
function toPatch(values: LotFormValues): Omit<LotInput, "medicineId"> {
|
||||
return {
|
||||
batchNumber: emptyToNull(values.batchNumber),
|
||||
purchasedOn: values.purchasedOn,
|
||||
quantity: parseDecimal(values.quantity),
|
||||
expiresOn: emptyToNull(values.expiresOn),
|
||||
supplier: emptyToNull(values.supplier),
|
||||
purchaseProof: emptyToNull(values.purchaseProof),
|
||||
prescribingVet: emptyToNull(values.prescribingVet),
|
||||
notes: emptyToNull(values.notes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record or correct one bought pack. Batch number, supplier, proof and vet are
|
||||
* entered here once and apply to every treatment taken from the pack.
|
||||
*/
|
||||
export function LotFormDialog({ medicine, lot, disabled = false, describedBy }: LotFormDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isEdit = lot !== undefined;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={isEdit ? "outline" : "default"}
|
||||
size="touch"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{isEdit ? "Bearbeiten" : "Packung erfassen"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Packung bearbeiten" : "Packung erfassen"}</DialogTitle>
|
||||
<DialogDescription>{medicine.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<LotForm medicine={medicine} lot={lot} onDone={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LotForm({ medicine, lot, onDone }: { medicine: LotMedicine; lot?: LotView; onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = lot !== undefined;
|
||||
const unitLabel = MEDICINE_UNIT_LABELS[medicine.unit];
|
||||
const [values, setValues] = useState(() => initialValues(medicine, lot));
|
||||
const [localErrors, setLocalErrors] = useState<LotFormErrors>({});
|
||||
const [saveError, setSaveError] = useState<SaveError | null>(null);
|
||||
const attempt = useAttemptKey();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
|
||||
const set = (key: keyof LotFormValues, value: string) =>
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
const errorOf = (key: keyof LotFormValues) => localErrors[key] ?? saveError?.fields[key] ?? null;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (lot) return updateLot(lot.id, toPatch(values));
|
||||
const input = { medicineId: medicine.id, ...toPatch(values) };
|
||||
return createLot(input, { idempotencyKey: attempt.keyFor(input) });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
|
||||
toast.success(isEdit ? "Packung gespeichert" : "Packung erfasst");
|
||||
attempt.done();
|
||||
onDone();
|
||||
},
|
||||
onError: (error) => {
|
||||
attempt.failed(error);
|
||||
setSaveError(
|
||||
describeSaveError(error, isEdit ? "Die Packung wurde nicht gespeichert." : "Die Packung wurde nicht erfasst."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSaveError(null);
|
||||
const errors = errorsOf(values);
|
||||
setLocalErrors(errors);
|
||||
if (Object.keys(errors).length > 0 || isOffline) return;
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<Field label="Chargennummer" id="lot-batch" error={errorOf("batchNumber")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.batchNumber}
|
||||
maxLength={200}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("batchNumber", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Gekauft am" required id="lot-purchased" error={errorOf("purchasedOn")}>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
className={INPUT_CLASS}
|
||||
value={values.purchasedOn}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("purchasedOn", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={`Menge (${unitLabel})`} required id="lot-quantity" error={errorOf("quantity")}>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
inputMode="decimal"
|
||||
className={INPUT_CLASS}
|
||||
value={values.quantity}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("quantity", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Haltbar bis" id="lot-expires" error={errorOf("expiresOn")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
className={INPUT_CLASS}
|
||||
value={values.expiresOn}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("expiresOn", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Lieferant" id="lot-supplier" hint="Name und Anschrift" error={errorOf("supplier")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.supplier}
|
||||
maxLength={300}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("supplier", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Beleg" id="lot-proof" hint="z. B. Rechnung 4711" error={errorOf("purchaseProof")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.purchaseProof}
|
||||
maxLength={300}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("purchaseProof", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Verschreibender Tierarzt" id="lot-vet" error={errorOf("prescribingVet")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.prescribingVet}
|
||||
maxLength={300}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("prescribingVet", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<FormSection title="Weitere Angaben" collapsible defaultOpen={values.notes !== ""} fieldCount={1}>
|
||||
<Field label="Notizen" id="lot-notes" error={errorOf("notes")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.notes}
|
||||
maxLength={2000}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
{saveError?.general ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{saveError.general}
|
||||
</p>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="touch"
|
||||
disabled={mutation.isPending || isOffline}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{mutation.isPending ? "Speichert…" : isEdit ? "Speichern" : "Erfassen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import MedicineDetailPage from "./page";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchMedicine } from "@/lib/medicines/api";
|
||||
import { lot, medicine } from "@/lib/medicines/fixtures.spec-helper";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "m1" }) }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
|
||||
vi.mock("@/lib/medicines/api", () => ({
|
||||
fetchMedicine: vi.fn(),
|
||||
createLot: vi.fn(),
|
||||
updateLot: vi.fn(),
|
||||
updateMedicine: vi.fn(),
|
||||
setMedicineActive: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderPage(permissions: string[]) {
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
hasPermission: (permission: string) => permissions.includes(permission),
|
||||
} as never);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MedicineDetailPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const ALL = ["veterinary-medicines:read", "veterinary-medicines:create", "veterinary-medicines:update"];
|
||||
|
||||
describe("MedicineDetailPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchMedicine).mockReset();
|
||||
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine(), lots: [lot()] });
|
||||
});
|
||||
|
||||
it("the main action in the header is Packung erfassen", async () => {
|
||||
const { container } = renderPage(ALL);
|
||||
|
||||
await screen.findByRole("heading", { name: "Apivar" });
|
||||
const header = container.querySelector("header") as HTMLElement;
|
||||
expect(within(header).getByRole("button", { name: /Packung erfassen/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /Packung erfassen/ })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lists the lots with batch number, rest and expiry as links to the lot", async () => {
|
||||
renderPage(ALL);
|
||||
|
||||
const link = await screen.findByRole("link", { name: /CH-42/ });
|
||||
expect(link).toHaveAttribute("href", "/medicines/lots/l1");
|
||||
expect(link).toHaveTextContent("12 Stück");
|
||||
expect(link).toHaveTextContent("31.01.2028");
|
||||
});
|
||||
|
||||
it("shows a negative lot rest in red with the word Negativ", async () => {
|
||||
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine(), lots: [lot({ remaining: -3 })] });
|
||||
renderPage(ALL);
|
||||
|
||||
const negative = await screen.findByText(/Negativ/);
|
||||
expect(negative.closest("span.text-destructive")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides create buttons without veterinary-medicines:create", async () => {
|
||||
renderPage(["veterinary-medicines:read", "veterinary-medicines:update"]);
|
||||
|
||||
await screen.findByRole("heading", { name: "Apivar" });
|
||||
expect(screen.queryByRole("button", { name: /Packung erfassen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides editing without veterinary-medicines:update", async () => {
|
||||
renderPage(["veterinary-medicines:read"]);
|
||||
|
||||
await screen.findByRole("heading", { name: "Apivar" });
|
||||
expect(screen.queryByRole("button", { name: "Bearbeiten" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Deaktivieren/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives an inactive medicine no new lots and says why", async () => {
|
||||
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine({ active: false }), lots: [] });
|
||||
renderPage(ALL);
|
||||
|
||||
const button = await screen.findByRole("button", { name: /Packung erfassen/ });
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAccessibleDescription("Das Mittel ist deaktiviert.");
|
||||
expect(screen.getByRole("button", { name: "Aktivieren" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state of a medicine without lots", async () => {
|
||||
vi.mocked(fetchMedicine).mockResolvedValue({ ...medicine({ lotCount: 0, totalRemaining: 0 }), lots: [] });
|
||||
renderPage(ALL);
|
||||
|
||||
expect(await screen.findByText("Noch keine Packung")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no data and no retry without veterinary-medicines:read", async () => {
|
||||
renderPage([]);
|
||||
|
||||
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||
expect(fetchMedicine).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Package, Pill } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchMedicine, setMedicineActive } from "@/lib/medicines/api";
|
||||
import { formatDay, formatQuantity, MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
|
||||
import type { LotView, MedicineView } from "@/lib/medicines/types";
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { RestText } from "@/components/medicines/rest-text";
|
||||
import { NoReadAccess } from "@/components/medicines/no-read-access";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MedicineFormDialog } from "../medicine-form-dialog";
|
||||
import { LotFormDialog } from "./lot-form-dialog";
|
||||
|
||||
const BACK = { backHref: "/medicines", backLabel: "Alle Tierarzneimittel" };
|
||||
const INACTIVE_REASON = "Das Mittel ist deaktiviert.";
|
||||
|
||||
export default function MedicineDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { isOffline, hintId } = useOnlineOnly();
|
||||
const inactiveId = useId();
|
||||
const query = useQuery({
|
||||
queryKey: ["medicines", params.id],
|
||||
queryFn: () => fetchMedicine(params.id),
|
||||
enabled: hasPermission("veterinary-medicines:read"),
|
||||
});
|
||||
const medicine = query.data;
|
||||
|
||||
const activeMutation = useMutation({
|
||||
mutationFn: (active: boolean) => setMedicineActive(params.id, active),
|
||||
onSuccess: (_result, active) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
|
||||
toast.success(active ? "Mittel aktiviert" : "Mittel deaktiviert");
|
||||
},
|
||||
onError: (error) => toast.error(saveErrorText(error, "Das Mittel wurde nicht geändert.")),
|
||||
});
|
||||
|
||||
if (!hasPermission("veterinary-medicines:read")) return <NoReadAccess />;
|
||||
|
||||
if (query.isLoading || query.isError || !medicine) {
|
||||
return <DetailQueryFallback query={query} what="Daten des Mittels" {...BACK} />;
|
||||
}
|
||||
|
||||
const canCreate = hasPermission("veterinary-medicines:create");
|
||||
const canUpdate = hasPermission("veterinary-medicines:update");
|
||||
const newLotReason = [isOffline ? hintId : null, medicine.active ? null : inactiveId]
|
||||
.filter((id): id is string => id !== null)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Pill}
|
||||
title={medicine.name}
|
||||
subtitle={[medicine.activeIngredient, MEDICINE_UNIT_LABELS[medicine.unit]].filter(Boolean).join(" · ")}
|
||||
status={
|
||||
<DetailStatus
|
||||
status={medicine.active ? "ACTIVE" : "INACTIVE"}
|
||||
label={medicine.active ? "Aktiv" : "Deaktiviert"}
|
||||
/>
|
||||
}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<LotFormDialog
|
||||
medicine={medicine}
|
||||
disabled={isOffline || !medicine.active}
|
||||
describedBy={newLotReason || undefined}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
{...BACK}
|
||||
/>
|
||||
{!medicine.active ? (
|
||||
<p id={inactiveId} className="text-sm text-muted-foreground">
|
||||
{INACTIVE_REASON}
|
||||
</p>
|
||||
) : null}
|
||||
{canUpdate || canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
{canUpdate ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MedicineFormDialog medicine={medicine} disabled={isOffline} describedBy={isOffline ? hintId : undefined} />
|
||||
{medicine.active ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="touch"
|
||||
triggerDisabled={isOffline || activeMutation.isPending}
|
||||
triggerDescribedBy={isOffline ? hintId : undefined}
|
||||
title="Mittel deaktivieren?"
|
||||
description="Das Mittel nimmt keine neuen Packungen mehr an. Packungen, Bewegungsbuch und Behandlungen bleiben unverändert."
|
||||
confirmLabel="Deaktivieren"
|
||||
onConfirm={() => activeMutation.mutate(false)}
|
||||
isPending={activeMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={isOffline || activeMutation.isPending}
|
||||
aria-describedby={isOffline ? hintId : undefined}
|
||||
onClick={() => activeMutation.mutate(true)}
|
||||
>
|
||||
Aktivieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MedicineFacts medicine={medicine} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section aria-labelledby="medicine-lots-heading" className="flex flex-col gap-3 lg:col-span-2">
|
||||
<h2 id="medicine-lots-heading" className="text-lg font-semibold tracking-tight">
|
||||
Packungen
|
||||
</h2>
|
||||
{medicine.lots.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Noch keine Packung"
|
||||
description="Erfasse die gekaufte Packung mit Chargennummer und Beleg. Behandlungen buchen dann aus ihr ab."
|
||||
/>
|
||||
) : (
|
||||
<LotGrid lots={medicine.lots} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicineFacts({ medicine }: { medicine: MedicineView }) {
|
||||
const facts: { label: string; value: ReactNode }[] = [
|
||||
{ label: "Rest", value: <RestText value={medicine.totalRemaining} unit={medicine.unit} /> },
|
||||
{ label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) },
|
||||
{
|
||||
label: "Wartezeit Honig",
|
||||
value: medicine.withdrawalPeriodDays === null ? "—" : `${medicine.withdrawalPeriodDays} Tage`,
|
||||
},
|
||||
{ label: "Verschreibungspflichtig", value: medicine.prescriptionOnly ? "Ja" : "Nein" },
|
||||
{ label: "Zulassungsnummer", value: medicine.authorisationNumber ?? "—" },
|
||||
{
|
||||
label: "Dosis je Volk",
|
||||
value: medicine.dosePerColony === null ? "—" : formatQuantity(medicine.dosePerColony, medicine.unit),
|
||||
},
|
||||
{ label: "Dosis-Hinweis", value: medicine.doseNote ?? "—" },
|
||||
{ label: "Anwendung", value: medicine.applicationMethod ?? "—" },
|
||||
{ label: "Lagertemperatur", value: temperatureWindow(medicine) },
|
||||
{ label: "Üblicher Lieferant", value: medicine.defaultSupplier ?? "—" },
|
||||
{ label: "Notizen", value: medicine.notes ?? "—" },
|
||||
];
|
||||
return (
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt className="text-xs text-muted-foreground">{fact.label}</dt>
|
||||
<dd className="font-medium break-words">{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function temperatureWindow(medicine: MedicineView): string {
|
||||
const { minTemperatureC: min, maxTemperatureC: max } = medicine;
|
||||
if (min === null && max === null) return "—";
|
||||
if (min !== null && max !== null) return `${min} bis ${max} °C`;
|
||||
return min !== null ? `ab ${min} °C` : `bis ${max} °C`;
|
||||
}
|
||||
|
||||
function LotGrid({ lots }: { lots: LotView[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{lots.map((lot) => (
|
||||
<EntityCard
|
||||
key={lot.id}
|
||||
href={`/medicines/lots/${lot.id}`}
|
||||
icon={Package}
|
||||
title={lot.batchNumber ?? "Ohne Chargennummer"}
|
||||
subtitle={`Gekauft am ${formatDay(lot.purchasedOn)}`}
|
||||
fields={[
|
||||
{ label: "Rest", value: <RestText value={lot.remaining} unit={lot.unit} /> },
|
||||
{ label: "Haltbar bis", value: formatDay(lot.expiresOn) },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { DisposalDialog } from "./disposal-dialog";
|
||||
import { bookDisposal } from "@/lib/medicines/api";
|
||||
import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
|
||||
vi.mock("@/lib/medicines/api", () => ({ bookDisposal: vi.fn() }));
|
||||
|
||||
function renderDialog() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<DisposalDialog lotId="l1" remaining={12} unit="ML" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function fill(quantity: string, reason: string) {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Entsorgung" }));
|
||||
if (quantity) await userEvent.type(await screen.findByLabelText(/^Menge/), quantity);
|
||||
if (reason) await userEvent.type(await screen.findByLabelText(/^Grund/), reason);
|
||||
}
|
||||
|
||||
describe("DisposalDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(bookDisposal).mockReset();
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
});
|
||||
|
||||
it("Entsorgung needs a reason", async () => {
|
||||
renderDialog();
|
||||
await fill("2", "");
|
||||
|
||||
expect(screen.getByText("Bitte einen Grund angeben.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
|
||||
expect(bookDisposal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a reason of blanks is no reason", async () => {
|
||||
renderDialog();
|
||||
await fill("2", " ");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("needs a positive quantity", async () => {
|
||||
renderDialog();
|
||||
await fill("", "Abgelaufen");
|
||||
|
||||
expect(screen.getByText("Bitte eine Menge eingeben.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("books quantity and trimmed reason with an Idempotency-Key", async () => {
|
||||
vi.mocked(bookDisposal).mockResolvedValue(lotDetail());
|
||||
renderDialog();
|
||||
await fill("2,5", " Abgelaufen ");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Entsorgung buchen" }));
|
||||
|
||||
expect(bookDisposal).toHaveBeenCalledWith("l1", 2.5, "Abgelaufen", {
|
||||
idempotencyKey: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("lets more than the rest through (a negative rest is allowed) but says so", async () => {
|
||||
renderDialog();
|
||||
await fill("20", "Verschüttet");
|
||||
|
||||
expect(screen.getByText(/mehr als der Rest/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows the server message of a failed booking", async () => {
|
||||
vi.mocked(bookDisposal).mockRejectedValue(new ApiError(400, "Die Menge ist zu klein", { message: "Die Menge ist zu klein" }));
|
||||
renderDialog();
|
||||
await fill("0,0001", "Test");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Entsorgung buchen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Die Menge ist zu klein");
|
||||
});
|
||||
|
||||
it("marks the negative-rest warning with a symbol, the word Negativ and red text", async () => {
|
||||
renderDialog();
|
||||
await fill("20", "Verschüttet");
|
||||
|
||||
const warning = screen.getByText(/mehr als der Rest/i);
|
||||
expect(warning.closest("span.text-destructive")).not.toBeNull();
|
||||
expect(warning).toHaveTextContent("Negativ");
|
||||
});
|
||||
|
||||
it("refuses an ambiguous thousands dot", async () => {
|
||||
renderDialog();
|
||||
await fill("1.000", "Abgelaufen");
|
||||
|
||||
expect(screen.getByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Entsorgung buchen" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { AlertTriangle, Trash2 } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { bookDisposal } from "@/lib/medicines/api";
|
||||
import { decimalMessage, formatQuantity, MEDICINE_UNIT_LABELS, parseDecimal } from "@/lib/medicines/format";
|
||||
import type { MedicineUnit } from "@/lib/medicines/types";
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface DisposalDialogProps {
|
||||
lotId: string;
|
||||
remaining: number;
|
||||
unit: MedicineUnit;
|
||||
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
|
||||
disabled?: boolean;
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
/** „Entsorgung“: expired or spilled medicine leaves the lot, always with a reason. */
|
||||
export function DisposalDialog({
|
||||
lotId,
|
||||
remaining,
|
||||
unit,
|
||||
disabled = false,
|
||||
describedBy: triggerDescribedBy,
|
||||
}: DisposalDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="touch" disabled={disabled} aria-describedby={triggerDescribedBy}>
|
||||
<Trash2 aria-hidden />
|
||||
Entsorgung
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Entsorgung</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DisposalForm lotId={lotId} remaining={remaining} unit={unit} onDone={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface DisposalFormProps {
|
||||
lotId: string;
|
||||
remaining: number;
|
||||
unit: MedicineUnit;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
function problemWith(quantityText: string, reason: string): string | null {
|
||||
const quantity = parseDecimal(quantityText);
|
||||
if (quantityText.trim() === "" || !Number.isFinite(quantity)) {
|
||||
return decimalMessage(quantityText, "Bitte eine Menge eingeben.");
|
||||
}
|
||||
if (quantity <= 0) return "Die Menge muss größer als 0 sein.";
|
||||
if (reason.trim() === "") return "Bitte einen Grund angeben.";
|
||||
return null;
|
||||
}
|
||||
|
||||
function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [quantityText, setQuantityText] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const attempt = useAttemptKey();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
|
||||
const problem = problemWith(quantityText, reason);
|
||||
const quantity = parseDecimal(quantityText);
|
||||
const exceedsRest = problem === null && quantity > remaining;
|
||||
const note = problem ?? "";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => bookDisposal(lotId, quantity, reason.trim(), {
|
||||
idempotencyKey: attempt.keyFor({ quantity, reason: reason.trim() }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
|
||||
toast.success(`${formatQuantity(quantity, unit)} entsorgt`);
|
||||
attempt.done();
|
||||
onDone();
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
attempt.failed(mutationError);
|
||||
setError(saveErrorText(mutationError, "Die Entsorgung wurde nicht gebucht."));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (problem !== null || isOffline) return;
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<p className="text-sm text-muted-foreground">Noch da: {formatQuantity(remaining, unit)}.</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="disposal-quantity">Menge ({MEDICINE_UNIT_LABELS[unit]})</Label>
|
||||
<Input
|
||||
id="disposal-quantity"
|
||||
inputMode="decimal"
|
||||
className="h-11 sm:h-11"
|
||||
value={quantityText}
|
||||
onChange={(event) => setQuantityText(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="disposal-reason">Grund</Label>
|
||||
<Input
|
||||
id="disposal-reason"
|
||||
className="h-11 sm:h-11"
|
||||
value={reason}
|
||||
maxLength={500}
|
||||
placeholder="z. B. abgelaufen, verschüttet"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{note}
|
||||
{exceedsRest ? (
|
||||
<span className="inline-flex items-start gap-1 font-semibold text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
Negativ: Das ist mehr als der Rest ({formatQuantity(remaining, unit)}). Der Rest wird negativ.
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="touch"
|
||||
disabled={mutation.isPending || problem !== null || isOffline}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{mutation.isPending ? "Bucht…" : "Entsorgung buchen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InventoryDialog } from "./inventory-dialog";
|
||||
import { bookCorrection } from "@/lib/medicines/api";
|
||||
import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
|
||||
vi.mock("@/lib/medicines/api", () => ({ bookCorrection: vi.fn() }));
|
||||
|
||||
function renderDialog(remaining = 12) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<InventoryDialog lotId="l1" remaining={remaining} unit="PIECE" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function open() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Inventur" }));
|
||||
}
|
||||
|
||||
describe("InventoryDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(bookCorrection).mockReset();
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
});
|
||||
|
||||
it("asks for the actual rest and shows the delta before saving", async () => {
|
||||
renderDialog();
|
||||
await open();
|
||||
|
||||
const input = await screen.findByLabelText("Tatsächlicher Rest (Stück)");
|
||||
expect(input).toHaveValue("12");
|
||||
expect(screen.getByText("Keine Abweichung")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "9");
|
||||
expect(screen.getByText("Differenz: −3 Stück")).toBeInTheDocument();
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "13,5");
|
||||
expect(screen.getByText("Differenz: +1,5 Stück")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("books the counted rest with an Idempotency-Key", async () => {
|
||||
vi.mocked(bookCorrection).mockResolvedValue(lotDetail());
|
||||
renderDialog();
|
||||
await open();
|
||||
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "9");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Inventur buchen" }));
|
||||
|
||||
expect(bookCorrection).toHaveBeenCalledWith("l1", 9, { idempotencyKey: expect.any(String) });
|
||||
});
|
||||
|
||||
it("does not accept a negative count", async () => {
|
||||
renderDialog();
|
||||
await open();
|
||||
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "-1");
|
||||
|
||||
expect(screen.getByText("Der Rest kann nicht negativ gezählt werden.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows a failed booking in the dialog", async () => {
|
||||
vi.mocked(bookCorrection).mockRejectedValue(new ApiError(409, "Gerade geändert.", {}));
|
||||
renderDialog();
|
||||
await open();
|
||||
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "9");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Inventur buchen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
|
||||
});
|
||||
|
||||
it("is off offline, with the visible reason", async () => {
|
||||
renderDialog();
|
||||
await open();
|
||||
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "9");
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
|
||||
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("points the submit button at the status line", async () => {
|
||||
renderDialog();
|
||||
await open();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Inventur buchen" })).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
expect.stringContaining("inventory-status"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an ambiguous thousands dot", async () => {
|
||||
renderDialog();
|
||||
await open();
|
||||
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "1.000");
|
||||
|
||||
expect(screen.getByText("Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Inventur buchen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps the key for an unchanged retry after a network error, renews it after a 409 or a change", async () => {
|
||||
vi.mocked(bookCorrection)
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
||||
.mockRejectedValueOnce(new ApiError(409, "Die Packung wurde gerade geändert.", {}))
|
||||
.mockResolvedValue(lotDetail());
|
||||
renderDialog();
|
||||
await open();
|
||||
const input = await screen.findByLabelText(/Tatsächlicher Rest/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "9");
|
||||
const submit = screen.getByRole("button", { name: "Inventur buchen" });
|
||||
|
||||
await userEvent.click(submit); // network error
|
||||
await userEvent.click(submit); // same body: same key
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "8"); // changed body
|
||||
await userEvent.click(submit); // 409
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Die Packung wurde gerade geändert.");
|
||||
await userEvent.click(submit); // same body after a server answer
|
||||
|
||||
const keys = vi.mocked(bookCorrection).mock.calls.map((call) => call[2]?.idempotencyKey);
|
||||
expect(keys[1]).toBe(keys[0]);
|
||||
expect(keys[2]).not.toBe(keys[1]);
|
||||
expect(keys[3]).not.toBe(keys[2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { bookCorrection } from "@/lib/medicines/api";
|
||||
import {
|
||||
decimalMessage,
|
||||
formatQuantity,
|
||||
formatSignedQuantity,
|
||||
MEDICINE_UNIT_LABELS,
|
||||
parseDecimal,
|
||||
roundToThousandths,
|
||||
} from "@/lib/medicines/format";
|
||||
import type { MedicineUnit } from "@/lib/medicines/types";
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface InventoryDialogProps {
|
||||
lotId: string;
|
||||
/** The booked rest. */
|
||||
remaining: number;
|
||||
unit: MedicineUnit;
|
||||
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
|
||||
disabled?: boolean;
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
/** „Inventur“: the beekeeper types what is left in the box; the server books the difference. */
|
||||
export function InventoryDialog({
|
||||
lotId,
|
||||
remaining,
|
||||
unit,
|
||||
disabled = false,
|
||||
describedBy: triggerDescribedBy,
|
||||
}: InventoryDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="touch" disabled={disabled} aria-describedby={triggerDescribedBy}>
|
||||
<ClipboardList aria-hidden />
|
||||
Inventur
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Inventur</DialogTitle>
|
||||
</DialogHeader>
|
||||
<InventoryForm lotId={lotId} remaining={remaining} unit={unit} onDone={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface InventoryFormProps {
|
||||
lotId: string;
|
||||
remaining: number;
|
||||
unit: MedicineUnit;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
function InventoryForm({ lotId, remaining, unit, onDone }: InventoryFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
// Fixed when the dialog opens, so a reload behind it cannot move the booking.
|
||||
const [baseline] = useState(remaining);
|
||||
const [counted, setCounted] = useState(baseline < 0 ? "" : String(baseline).replace(".", ","));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const attempt = useAttemptKey();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
|
||||
const value = parseDecimal(counted);
|
||||
const isNumber = Number.isFinite(value);
|
||||
const isNegative = isNumber && value < 0;
|
||||
const delta = isNumber && !isNegative ? roundToThousandths(value - baseline) : 0;
|
||||
const status = !isNumber
|
||||
? decimalMessage(counted, "Bitte den gezählten Rest eingeben.")
|
||||
: isNegative
|
||||
? "Der Rest kann nicht negativ gezählt werden."
|
||||
: delta === 0
|
||||
? "Keine Abweichung"
|
||||
: `Differenz: ${formatSignedQuantity(delta, unit)}`;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => bookCorrection(lotId, value, { idempotencyKey: attempt.keyFor({ actualRemaining: value }) }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicine-lots"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
|
||||
toast.success(`Inventur gebucht: ${formatSignedQuantity(delta, unit)}`);
|
||||
attempt.done();
|
||||
onDone();
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
attempt.failed(mutationError);
|
||||
setError(saveErrorText(mutationError, "Die Inventur wurde nicht gebucht."));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!isNumber || isNegative || delta === 0 || isOffline) return;
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gebucht sind {formatQuantity(baseline, unit)}. Zähl, was in der Packung ist – gebucht wird die
|
||||
Differenz.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="inventory-counted">Tatsächlicher Rest ({MEDICINE_UNIT_LABELS[unit]})</Label>
|
||||
<Input
|
||||
id="inventory-counted"
|
||||
inputMode="decimal"
|
||||
className="h-11 sm:h-11"
|
||||
value={counted}
|
||||
aria-describedby="inventory-status"
|
||||
onChange={(event) => setCounted(event.target.value)}
|
||||
/>
|
||||
<p id="inventory-status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="touch"
|
||||
disabled={mutation.isPending || !isNumber || isNegative || delta === 0 || isOffline}
|
||||
aria-describedby={[describedBy, "inventory-status"].filter(Boolean).join(" ")}
|
||||
>
|
||||
{mutation.isPending ? "Bucht…" : "Inventur buchen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import LotDetailPage from "./page";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchLot } from "@/lib/medicines/api";
|
||||
import { lotDetail } from "@/lib/medicines/fixtures.spec-helper";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "l1" }) }));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
|
||||
vi.mock("@/lib/medicines/api", () => ({
|
||||
fetchLot: vi.fn(),
|
||||
updateLot: vi.fn(),
|
||||
bookCorrection: vi.fn(),
|
||||
bookDisposal: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/document-upload-card", () => ({
|
||||
DocumentUploadCard: ({
|
||||
entityType,
|
||||
entityId,
|
||||
canWrite,
|
||||
}: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
canWrite?: boolean;
|
||||
}) => (
|
||||
<p>
|
||||
Upload {entityType} {entityId} {canWrite ? "schreiben" : "nur lesen"}
|
||||
</p>
|
||||
),
|
||||
}));
|
||||
|
||||
function renderPage(permissions: string[]) {
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
hasPermission: (permission: string) => permissions.includes(permission),
|
||||
} as never);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<LotDetailPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const ALL = ["veterinary-medicines:read", "veterinary-medicines:update"];
|
||||
|
||||
describe("LotDetailPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchLot).mockReset();
|
||||
vi.mocked(fetchLot).mockResolvedValue(lotDetail());
|
||||
});
|
||||
|
||||
it("shows the rest, the movement book and the receipt upload card", async () => {
|
||||
renderPage(ALL);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: /CH-42/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Rest")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("12 Stück").length).toBeGreaterThan(0);
|
||||
const book = screen.getByRole("table", { name: "Bewegungsbuch" });
|
||||
expect(within(book).getByText("Behandlung")).toBeInTheDocument();
|
||||
expect(within(book).getByText("−8 Stück")).toBeInTheDocument();
|
||||
expect(within(book).getByText("Eingang")).toBeInTheDocument();
|
||||
expect(screen.getByText("Upload MEDICINE_LOT l1 schreiben")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the running rest after every booking", async () => {
|
||||
renderPage(ALL);
|
||||
|
||||
const book = await screen.findByRole("table", { name: "Bewegungsbuch" });
|
||||
const rows = within(book).getAllByRole("row");
|
||||
expect(within(rows[1]).getByText("12 Stück")).toBeInTheDocument();
|
||||
expect(within(rows[2]).getByText("20 Stück")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a negative rest is shown in red with the word Negativ", async () => {
|
||||
vi.mocked(fetchLot).mockResolvedValue(lotDetail({ remaining: -3 }));
|
||||
renderPage(ALL);
|
||||
|
||||
const negative = await screen.findByText(/Negativ/);
|
||||
expect(negative.closest("span.text-destructive")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("the main action in the header is Inventur", async () => {
|
||||
const { container } = renderPage(ALL);
|
||||
|
||||
await screen.findByRole("heading", { name: /CH-42/ });
|
||||
const header = container.querySelector("header") as HTMLElement;
|
||||
expect(within(header).getByRole("button", { name: /Inventur/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /Inventur/ })).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: /Entsorgung/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides inventory, disposal and editing without veterinary-medicines:update", async () => {
|
||||
renderPage(["veterinary-medicines:read"]);
|
||||
|
||||
await screen.findByRole("heading", { name: /CH-42/ });
|
||||
expect(screen.queryByRole("button", { name: /Inventur/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Entsorgung/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Bearbeiten" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the empty book instead of showing a bare table", async () => {
|
||||
vi.mocked(fetchLot).mockResolvedValue(lotDetail({ movements: [], remaining: 20 }));
|
||||
renderPage(ALL);
|
||||
|
||||
expect(await screen.findByText("Noch keine Bewegung außer dem Eingang.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets a reader see and download the receipts but not change them", async () => {
|
||||
renderPage(["veterinary-medicines:read"]);
|
||||
|
||||
expect(await screen.findByText("Upload MEDICINE_LOT l1 nur lesen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no data and no retry without veterinary-medicines:read", async () => {
|
||||
renderPage([]);
|
||||
|
||||
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||
expect(fetchLot).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Upload MEDICINE_LOT l1 nur lesen")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the running rest does not end at the rest the server reports", async () => {
|
||||
vi.mocked(fetchLot).mockResolvedValue(lotDetail({ remaining: 99 }));
|
||||
renderPage(ALL);
|
||||
|
||||
expect(await screen.findByText("Stand weicht vom Server ab – bitte neu laden")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet when book and server agree", async () => {
|
||||
renderPage(ALL);
|
||||
|
||||
await screen.findByRole("table", { name: "Bewegungsbuch" });
|
||||
expect(screen.queryByText(/weicht vom Server ab/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Package } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { buildBook } from "@/lib/medicines/book";
|
||||
import { fetchLot } from "@/lib/medicines/api";
|
||||
import { formatDay, formatQuantity, formatSignedQuantity } from "@/lib/medicines/format";
|
||||
import type { LotDetailView } from "@/lib/medicines/types";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
import { RestText } from "@/components/medicines/rest-text";
|
||||
import { NoReadAccess } from "@/components/medicines/no-read-access";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { LotFormDialog } from "../../[id]/lot-form-dialog";
|
||||
import { DisposalDialog } from "./disposal-dialog";
|
||||
import { InventoryDialog } from "./inventory-dialog";
|
||||
|
||||
export default function LotDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { hasPermission } = useAuth();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const query = useQuery({
|
||||
queryKey: ["medicine-lots", params.id],
|
||||
queryFn: () => fetchLot(params.id),
|
||||
enabled: hasPermission("veterinary-medicines:read"),
|
||||
});
|
||||
const lot = query.data;
|
||||
|
||||
if (!hasPermission("veterinary-medicines:read")) return <NoReadAccess />;
|
||||
|
||||
if (query.isLoading || query.isError || !lot) {
|
||||
return (
|
||||
<DetailQueryFallback
|
||||
query={query}
|
||||
what="Daten der Packung"
|
||||
backHref="/medicines"
|
||||
backLabel="Alle Tierarzneimittel"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canUpdate = hasPermission("veterinary-medicines:update");
|
||||
const offlineReason = isOffline ? describedBy : undefined;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Package}
|
||||
title={`Packung ${lot.batchNumber ?? formatDay(lot.purchasedOn)}`}
|
||||
subtitle={
|
||||
<>
|
||||
<Link href={`/medicines/${lot.medicineId}`} className="underline underline-offset-2">
|
||||
{lot.medicineName}
|
||||
</Link>
|
||||
{` · gekauft am ${formatDay(lot.purchasedOn)}`}
|
||||
</>
|
||||
}
|
||||
primaryAction={
|
||||
canUpdate ? (
|
||||
<InventoryDialog
|
||||
lotId={lot.id}
|
||||
remaining={lot.remaining}
|
||||
unit={lot.unit}
|
||||
disabled={isOffline}
|
||||
describedBy={offlineReason}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
backHref={`/medicines/${lot.medicineId}`}
|
||||
backLabel={`Zurück zu ${lot.medicineName}`}
|
||||
/>
|
||||
{canUpdate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
{canUpdate ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LotFormDialog
|
||||
medicine={{ id: lot.medicineId, name: lot.medicineName, unit: lot.unit }}
|
||||
lot={lot}
|
||||
disabled={isOffline}
|
||||
describedBy={offlineReason}
|
||||
/>
|
||||
<DisposalDialog
|
||||
lotId={lot.id}
|
||||
remaining={lot.remaining}
|
||||
unit={lot.unit}
|
||||
disabled={isOffline}
|
||||
describedBy={offlineReason}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LotFacts lot={lot} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DocumentUploadCard entityType="MEDICINE_LOT" entityId={lot.id} title="Beleg" canWrite={canUpdate} />
|
||||
</div>
|
||||
|
||||
<MovementBook lot={lot} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function LotFacts({ lot }: { lot: LotDetailView }) {
|
||||
const facts: { label: string; value: ReactNode }[] = [
|
||||
{ label: "Rest", value: <RestText value={lot.remaining} unit={lot.unit} /> },
|
||||
{ label: "Anfangsmenge", value: formatQuantity(lot.quantity, lot.unit) },
|
||||
{ label: "Haltbar bis", value: formatDay(lot.expiresOn) },
|
||||
{ label: "Chargennummer", value: lot.batchNumber ?? "—" },
|
||||
{ label: "Lieferant", value: lot.supplier ?? "—" },
|
||||
{ label: "Beleg", value: lot.purchaseProof ?? "—" },
|
||||
{ label: "Verschreibender Tierarzt", value: lot.prescribingVet ?? "—" },
|
||||
{ label: "Notizen", value: lot.notes ?? "—" },
|
||||
];
|
||||
return (
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt className="text-xs text-muted-foreground">{fact.label}</dt>
|
||||
<dd className="font-medium break-words">{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function MovementBook({ lot }: { lot: LotDetailView }) {
|
||||
const rows = buildBook(lot);
|
||||
return (
|
||||
<section aria-labelledby="movement-book-heading" className="flex flex-col gap-3 lg:col-span-2">
|
||||
<h2 id="movement-book-heading" className="text-lg font-semibold tracking-tight">
|
||||
Bewegungen
|
||||
</h2>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table aria-label="Bewegungsbuch">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead className="text-right">Menge</TableHead>
|
||||
<TableHead className="text-right">Stand danach</TableHead>
|
||||
<TableHead>Grund</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell>{formatDay(row.day)}</TableCell>
|
||||
<TableCell>{row.label}</TableCell>
|
||||
<TableCell className="text-right">{formatSignedQuantity(row.quantity, lot.unit)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<RestText value={row.balance} unit={lot.unit} />
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{row.reason ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{rows[0].balance !== lot.remaining ? (
|
||||
<p className="text-sm text-destructive">Stand weicht vom Server ab – bitte neu laden</p>
|
||||
) : null}
|
||||
{lot.movements.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Noch keine Bewegung außer dem Eingang.</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MedicineFormDialog } from "./medicine-form-dialog";
|
||||
import { createMedicine, updateMedicine } from "@/lib/medicines/api";
|
||||
import { medicine } from "@/lib/medicines/fixtures.spec-helper";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
|
||||
vi.mock("@/lib/medicines/api", () => ({ createMedicine: vi.fn(), updateMedicine: vi.fn() }));
|
||||
|
||||
function renderDialog(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("MedicineFormDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createMedicine).mockReset();
|
||||
vi.mocked(updateMedicine).mockReset();
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
});
|
||||
|
||||
it("creates a medicine with an Idempotency-Key and the typed values", async () => {
|
||||
vi.mocked(createMedicine).mockResolvedValue(medicine());
|
||||
renderDialog(<MedicineFormDialog />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
|
||||
|
||||
await userEvent.type(await screen.findByLabelText(/^Name/), "Apivar");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Stück" }));
|
||||
await userEvent.type(screen.getByLabelText(/Wartezeit/), "0");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
|
||||
|
||||
expect(createMedicine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Apivar",
|
||||
unit: "PIECE",
|
||||
withdrawalPeriodDays: 0,
|
||||
prescriptionOnly: false,
|
||||
}),
|
||||
{ idempotencyKey: expect.any(String) },
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a name and a unit before it sends anything", async () => {
|
||||
renderDialog(<MedicineFormDialog />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Anlegen" }));
|
||||
|
||||
expect(await screen.findByText("Bitte einen Namen eingeben.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bitte eine Einheit wählen.")).toBeInTheDocument();
|
||||
expect(createMedicine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the rare fields folded away until they are asked for", async () => {
|
||||
renderDialog(<MedicineFormDialog />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
|
||||
|
||||
expect(screen.queryByLabelText(/Niedrigste Lagertemperatur/)).not.toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Weitere Angaben/ }));
|
||||
expect(screen.getByLabelText(/Niedrigste Lagertemperatur/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Dosis-Hinweis/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Notizen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the unit field once lots exist and shows the server message", async () => {
|
||||
renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 2 })} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Einheit ist festgelegt, sobald Packungen erfasst sind"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: "Stück" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the unit while no lot exists, and never sends it once lots exist", async () => {
|
||||
vi.mocked(updateMedicine).mockResolvedValue(medicine());
|
||||
const { unmount } = renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 0 })} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
|
||||
expect(await screen.findByRole("radio", { name: "Stück" })).toBeChecked();
|
||||
unmount();
|
||||
|
||||
renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 2 })} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Speichern" }));
|
||||
expect(updateMedicine).toHaveBeenCalledWith(
|
||||
"m1",
|
||||
expect.not.objectContaining({ unit: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears an emptied optional field with null", async () => {
|
||||
vi.mocked(updateMedicine).mockResolvedValue(medicine());
|
||||
renderDialog(<MedicineFormDialog medicine={medicine({ lotCount: 1 })} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
|
||||
await userEvent.clear(await screen.findByLabelText(/Wirkstoff/));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(updateMedicine).toHaveBeenCalledWith(
|
||||
"m1",
|
||||
expect.objectContaining({ activeIngredient: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the German message of a failed save in the dialog", async () => {
|
||||
vi.mocked(createMedicine).mockRejectedValue(new ApiError(409, "Gerade geändert.", {}));
|
||||
renderDialog(
|
||||
<MedicineFormDialog prefill={{ name: "Apivar", unit: "PIECE" }} triggerLabel="Übernehmen" />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Anlegen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
|
||||
});
|
||||
|
||||
it("is off offline, with the visible reason", async () => {
|
||||
renderDialog(<MedicineFormDialog />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mittel anlegen" }));
|
||||
await screen.findByRole("button", { name: "Anlegen" });
|
||||
act(() => reportRequestOutcome("network-error"));
|
||||
|
||||
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Anlegen" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createMedicine, updateMedicine } from "@/lib/medicines/api";
|
||||
import { MEDICINE_UNITS, MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
|
||||
import type { MedicineFormPrefill } from "@/lib/medicines/prefill";
|
||||
import type { MedicineUnit, MedicineView } from "@/lib/medicines/types";
|
||||
import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
|
||||
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
initialMedicineValues,
|
||||
medicineFormErrors,
|
||||
toMedicineInput,
|
||||
type MedicineFormValues,
|
||||
} from "./medicine-form-values";
|
||||
|
||||
export const UNIT_LOCKED_TEXT = "Einheit ist festgelegt, sobald Packungen erfasst sind";
|
||||
|
||||
interface MedicineFormDialogProps {
|
||||
/** Edit this medicine; without it the dialog creates one. */
|
||||
medicine?: MedicineView;
|
||||
/** Values proposed from a treatment (create only). */
|
||||
prefill?: MedicineFormPrefill;
|
||||
triggerLabel?: string;
|
||||
triggerAriaLabel?: string;
|
||||
triggerVariant?: "default" | "outline";
|
||||
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
|
||||
disabled?: boolean;
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
/** Create or edit one catalogue entry; every value is typed in, the code ships none. */
|
||||
export function MedicineFormDialog({
|
||||
medicine,
|
||||
prefill,
|
||||
triggerLabel,
|
||||
triggerAriaLabel,
|
||||
triggerVariant,
|
||||
disabled = false,
|
||||
describedBy,
|
||||
}: MedicineFormDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isEdit = medicine !== undefined;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={triggerVariant ?? (isEdit ? "outline" : "default")}
|
||||
size="touch"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
aria-label={triggerAriaLabel}
|
||||
>
|
||||
{triggerLabel ?? (isEdit ? "Bearbeiten" : "Mittel anlegen")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Mittel bearbeiten" : "Neues Tierarzneimittel"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<MedicineForm medicine={medicine} prefill={prefill} onDone={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface MedicineFormProps {
|
||||
medicine?: MedicineView;
|
||||
prefill?: MedicineFormPrefill;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
const INPUT_CLASS = "h-11 sm:h-11";
|
||||
|
||||
function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = medicine !== undefined;
|
||||
const unitLocked = isEdit && medicine.lotCount > 0;
|
||||
const [values, setValues] = useState<MedicineFormValues>(() => initialMedicineValues(medicine, prefill));
|
||||
const [localErrors, setLocalErrors] = useState<Partial<Record<keyof MedicineFormValues, string>>>({});
|
||||
const [saveError, setSaveError] = useState<SaveError | null>(null);
|
||||
const attempt = useAttemptKey();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
|
||||
const set = <K extends keyof MedicineFormValues>(key: K, value: MedicineFormValues[K]) =>
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
const errorOf = (key: keyof MedicineFormValues) => localErrors[key] ?? saveError?.fields[key] ?? null;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const input = toMedicineInput(values, unitLocked);
|
||||
return medicine
|
||||
? updateMedicine(medicine.id, input)
|
||||
: createMedicine(input as Parameters<typeof createMedicine>[0], { idempotencyKey: attempt.keyFor(input) });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicines"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["medicine-suggestions"] });
|
||||
toast.success(isEdit ? "Mittel gespeichert" : "Mittel angelegt");
|
||||
attempt.done();
|
||||
onDone();
|
||||
},
|
||||
onError: (error) => {
|
||||
attempt.failed(error);
|
||||
setSaveError(
|
||||
describeSaveError(error, isEdit ? "Das Mittel wurde nicht gespeichert." : "Das Mittel wurde nicht angelegt."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSaveError(null);
|
||||
const errors = medicineFormErrors(values, unitLocked);
|
||||
setLocalErrors(errors);
|
||||
if (Object.keys(errors).length > 0 || isOffline) return;
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
const hasRareValues = [
|
||||
values.authorisationNumber,
|
||||
values.dosePerColony,
|
||||
values.doseNote,
|
||||
values.minTemperatureC,
|
||||
values.maxTemperatureC,
|
||||
values.notes,
|
||||
].some((value) => value.trim() !== "");
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
{prefill && !isEdit ? (
|
||||
<p className="rounded-lg bg-muted px-3 py-2 text-sm">
|
||||
Vorbelegt aus deiner letzten Behandlung. Prüfe die Werte gegen die Packungsbeilage, bevor du
|
||||
speicherst.
|
||||
</p>
|
||||
) : null}
|
||||
<Field label="Name (Handelsname)" required error={errorOf("name")} id="medicine-name">
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.name}
|
||||
maxLength={200}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("name", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{unitLocked ? (
|
||||
<div className="flex flex-col gap-0.5 text-sm">
|
||||
<p className="font-medium">Einheit: {MEDICINE_UNIT_LABELS[medicine.unit]}</p>
|
||||
<p className="text-muted-foreground">{UNIT_LOCKED_TEXT}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Field label="Einheit" required group error={errorOf("unit")} id="medicine-unit">
|
||||
{({ id, labelId, describedBy: fieldDescribedBy }) => (
|
||||
<ChoiceGroup<MedicineUnit | "">
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
describedBy={fieldDescribedBy}
|
||||
options={choiceOptions(MEDICINE_UNIT_LABELS, MEDICINE_UNITS)}
|
||||
value={values.unit}
|
||||
onChange={(unit) => set("unit", unit)}
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Wirkstoff" id="medicine-ingredient" error={errorOf("activeIngredient")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.activeIngredient}
|
||||
maxLength={200}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("activeIngredient", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Wartezeit Honig (Tage)"
|
||||
id="medicine-withdrawal"
|
||||
hint="0 ist ein Wert: keine Wartezeit. Leer lassen, wenn du sie nicht kennst."
|
||||
error={errorOf("withdrawalPeriodDays")}
|
||||
>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
value={values.withdrawalPeriodDays}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("withdrawalPeriodDays", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ToggleChip
|
||||
label="Verschreibungspflichtig"
|
||||
checked={values.prescriptionOnly}
|
||||
onChange={(checked) => set("prescriptionOnly", checked)}
|
||||
/>
|
||||
<Field label="Anwendung" id="medicine-method" error={errorOf("applicationMethod")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.applicationMethod}
|
||||
maxLength={200}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("applicationMethod", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Üblicher Lieferant" id="medicine-supplier" error={errorOf("defaultSupplier")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.defaultSupplier}
|
||||
maxLength={300}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("defaultSupplier", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<FormSection title="Weitere Angaben" collapsible defaultOpen={hasRareValues} fieldCount={6}>
|
||||
<Field label="Zulassungsnummer" id="medicine-authorisation" error={errorOf("authorisationNumber")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={values.authorisationNumber}
|
||||
maxLength={200}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("authorisationNumber", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Dosis je Volk" id="medicine-dose" error={errorOf("dosePerColony")}>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
inputMode="decimal"
|
||||
className={INPUT_CLASS}
|
||||
value={values.dosePerColony}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("dosePerColony", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Dosis-Hinweis" id="medicine-dose-note" error={errorOf("doseNote")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.doseNote}
|
||||
maxLength={500}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("doseNote", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Niedrigste Lagertemperatur (°C)" id="medicine-min-temp" error={errorOf("minTemperatureC")}>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
inputMode="decimal"
|
||||
className={INPUT_CLASS}
|
||||
value={values.minTemperatureC}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("minTemperatureC", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Höchste Lagertemperatur (°C)" id="medicine-max-temp" error={errorOf("maxTemperatureC")}>
|
||||
{({ id, describedBy: fieldDescribedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
inputMode="decimal"
|
||||
className={INPUT_CLASS}
|
||||
value={values.maxTemperatureC}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("maxTemperatureC", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen" id="medicine-notes" error={errorOf("notes")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.notes}
|
||||
maxLength={2000}
|
||||
aria-describedby={fieldDescribedBy}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
{saveError?.general ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{saveError.general}
|
||||
</p>
|
||||
) : null}
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="touch"
|
||||
disabled={mutation.isPending || isOffline}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{mutation.isPending ? "Speichert…" : isEdit ? "Speichern" : "Anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
|
||||
import type { MedicineFormPrefill } from "@/lib/medicines/prefill";
|
||||
import type { MedicineInput, MedicineUnit, MedicineView } from "@/lib/medicines/types";
|
||||
|
||||
export interface MedicineFormValues {
|
||||
name: string;
|
||||
unit: MedicineUnit | "";
|
||||
activeIngredient: string;
|
||||
authorisationNumber: string;
|
||||
prescriptionOnly: boolean;
|
||||
withdrawalPeriodDays: string;
|
||||
dosePerColony: string;
|
||||
doseNote: string;
|
||||
applicationMethod: string;
|
||||
minTemperatureC: string;
|
||||
maxTemperatureC: string;
|
||||
defaultSupplier: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const text = (value: string | null | undefined): string => value ?? "";
|
||||
const num = (value: number | null | undefined): string => (value === null || value === undefined ? "" : String(value));
|
||||
|
||||
export function initialMedicineValues(
|
||||
medicine?: MedicineView,
|
||||
prefill?: MedicineFormPrefill,
|
||||
): MedicineFormValues {
|
||||
const source: MedicineFormPrefill = medicine ?? prefill ?? {};
|
||||
const full: Partial<MedicineView> = medicine ?? {};
|
||||
return {
|
||||
name: text(source.name),
|
||||
unit: source.unit ?? "",
|
||||
activeIngredient: text(source.activeIngredient),
|
||||
authorisationNumber: text(full.authorisationNumber),
|
||||
prescriptionOnly: full.prescriptionOnly ?? false,
|
||||
withdrawalPeriodDays: num(source.withdrawalPeriodDays),
|
||||
dosePerColony: num(full.dosePerColony),
|
||||
doseNote: text(source.doseNote),
|
||||
applicationMethod: text(source.applicationMethod),
|
||||
minTemperatureC: num(full.minTemperatureC),
|
||||
maxTemperatureC: num(full.maxTemperatureC),
|
||||
defaultSupplier: text(full.defaultSupplier),
|
||||
notes: text(full.notes),
|
||||
};
|
||||
}
|
||||
|
||||
export type MedicineFormErrors = Partial<Record<keyof MedicineFormValues, string>>;
|
||||
|
||||
const NOT_A_NUMBER = "Bitte eine Zahl eingeben.";
|
||||
|
||||
function decimalProblem(value: string): string | null {
|
||||
return value.trim() !== "" && !Number.isFinite(parseDecimal(value)) ? decimalMessage(value, NOT_A_NUMBER) : null;
|
||||
}
|
||||
|
||||
/** What keeps the form from being sent, per field; empty = fine. */
|
||||
export function medicineFormErrors(values: MedicineFormValues, unitLocked: boolean): MedicineFormErrors {
|
||||
const errors: MedicineFormErrors = {};
|
||||
if (values.name.trim() === "") errors.name = "Bitte einen Namen eingeben.";
|
||||
if (!unitLocked && values.unit === "") errors.unit = "Bitte eine Einheit wählen.";
|
||||
|
||||
const days = values.withdrawalPeriodDays.trim();
|
||||
if (days !== "" && !(Number.isInteger(Number(days)) && Number(days) >= 0)) {
|
||||
errors.withdrawalPeriodDays = "Bitte ganze Tage ab 0 eingeben.";
|
||||
}
|
||||
const dose = decimalProblem(values.dosePerColony);
|
||||
if (dose) errors.dosePerColony = dose;
|
||||
else if (values.dosePerColony.trim() !== "" && parseDecimal(values.dosePerColony) <= 0) {
|
||||
errors.dosePerColony = "Die Dosis muss größer als 0 sein.";
|
||||
}
|
||||
const min = decimalProblem(values.minTemperatureC);
|
||||
if (min) errors.minTemperatureC = min;
|
||||
const max = decimalProblem(values.maxTemperatureC);
|
||||
if (max) errors.maxTemperatureC = max;
|
||||
if (!min && !max && values.minTemperatureC.trim() !== "" && values.maxTemperatureC.trim() !== "") {
|
||||
if (parseDecimal(values.minTemperatureC) > parseDecimal(values.maxTemperatureC)) {
|
||||
errors.minTemperatureC = "Die niedrigste Temperatur liegt über der höchsten.";
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** An emptied optional field goes out as null, so it can be cleared (owner rule). */
|
||||
export function toMedicineInput(values: MedicineFormValues, unitLocked: boolean): Partial<MedicineInput> {
|
||||
const base = {
|
||||
name: values.name.trim(),
|
||||
activeIngredient: emptyToNull(values.activeIngredient),
|
||||
authorisationNumber: emptyToNull(values.authorisationNumber),
|
||||
prescriptionOnly: values.prescriptionOnly,
|
||||
withdrawalPeriodDays: emptyToNull(values.withdrawalPeriodDays, Number),
|
||||
dosePerColony: emptyToNull(values.dosePerColony, parseDecimal),
|
||||
doseNote: emptyToNull(values.doseNote),
|
||||
applicationMethod: emptyToNull(values.applicationMethod),
|
||||
minTemperatureC: emptyToNull(values.minTemperatureC, parseDecimal),
|
||||
maxTemperatureC: emptyToNull(values.maxTemperatureC, parseDecimal),
|
||||
defaultSupplier: emptyToNull(values.defaultSupplier),
|
||||
notes: emptyToNull(values.notes),
|
||||
};
|
||||
return unitLocked || values.unit === "" ? base : { ...base, unit: values.unit };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Pill } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchMedicineSuggestions } from "@/lib/medicines/api";
|
||||
import { prefillFromSuggestion } from "@/lib/medicines/prefill";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MedicineFormDialog } from "./medicine-form-dialog";
|
||||
|
||||
interface MedicineSuggestionsProps {
|
||||
canCreate: boolean;
|
||||
disabled?: boolean;
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue is empty: offer the product names from the beekeeper's own
|
||||
* treatments instead of a blank page. Nothing is created by looking at them;
|
||||
* „Übernehmen“ opens the form with the values of the latest treatment, which
|
||||
* the beekeeper checks before saving.
|
||||
*/
|
||||
export function MedicineSuggestions({ canCreate, disabled, describedBy }: MedicineSuggestionsProps) {
|
||||
const query = useQuery({ queryKey: ["medicine-suggestions"], queryFn: fetchMedicineSuggestions });
|
||||
const suggestions = query.data ?? [];
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title="Noch keine Tierarzneimittel"
|
||||
description={
|
||||
canCreate
|
||||
? "Lege dein erstes Mittel mit „Mittel anlegen“ an. Danach kannst du Packungen erfassen und Behandlungen daraus buchen."
|
||||
: "Hier erscheinen die Mittel, sobald jemand sie anlegt."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aus deinen Behandlungen übernehmen?</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Diese Mittel hast du schon angewendet. Du prüfst die Werte, bevor etwas gespeichert wird.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<li
|
||||
key={suggestion.name}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium break-words">{suggestion.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{suggestion.treatmentCount === 1
|
||||
? "1 Behandlung"
|
||||
: `${suggestion.treatmentCount} Behandlungen`}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate ? (
|
||||
<MedicineFormDialog
|
||||
prefill={prefillFromSuggestion(suggestion)}
|
||||
triggerLabel="Übernehmen"
|
||||
triggerAriaLabel={`Übernehmen: ${suggestion.name}`}
|
||||
triggerVariant="outline"
|
||||
disabled={disabled}
|
||||
describedBy={describedBy}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import MedicinesPage from "./page";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { createMedicine, fetchMedicines, fetchMedicineSuggestions } from "@/lib/medicines/api";
|
||||
import { medicine, suggestion } from "@/lib/medicines/fixtures.spec-helper";
|
||||
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
|
||||
vi.mock("@/lib/medicines/api", () => ({
|
||||
fetchMedicines: vi.fn(),
|
||||
fetchMedicineSuggestions: vi.fn(),
|
||||
createMedicine: vi.fn(),
|
||||
updateMedicine: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderPage(permissions = ["veterinary-medicines:read", "veterinary-medicines:create"]) {
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
hasPermission: (permission: string) => permissions.includes(permission),
|
||||
} as never);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MedicinesPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MedicinesPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchMedicines).mockReset();
|
||||
vi.mocked(fetchMedicineSuggestions).mockReset();
|
||||
vi.mocked(createMedicine).mockReset();
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("lists name, rest and next expiry per medicine", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([
|
||||
medicine({ totalRemaining: 12, nextExpiry: "2028-01-31" }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
const card = (await screen.findByRole("link", { name: /Apivar/ })) as HTMLElement;
|
||||
expect(card).toHaveAttribute("href", "/medicines/m1");
|
||||
expect(within(card).getByText("12 Stück")).toBeInTheDocument();
|
||||
expect(within(card).getByText("31.01.2028")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a negative rest in red with the word Negativ", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([medicine({ totalRemaining: -2, unit: "ML" })]);
|
||||
renderPage();
|
||||
|
||||
const negative = await screen.findByText(/Negativ/);
|
||||
expect(negative.closest("span.text-destructive")).not.toBeNull();
|
||||
expect(negative.closest("a")).toHaveTextContent("−2 ml");
|
||||
});
|
||||
|
||||
it("offers the empty catalogue 'Aus deinen Behandlungen übernehmen?' with the suggestions", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([
|
||||
suggestion(),
|
||||
suggestion({ name: "Oxalsäure", treatmentCount: 1 }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Aus deinen Behandlungen übernehmen?")).toBeInTheDocument();
|
||||
expect(screen.getByText("Thymovar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Oxalsäure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applying a suggestion opens the form prefilled with the values of the latest treatment", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Übernehmen: Thymovar/ }));
|
||||
|
||||
expect(await screen.findByLabelText(/^Name/)).toHaveValue("Thymovar");
|
||||
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Thymol");
|
||||
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(7);
|
||||
expect(screen.getByText(/Vorbelegt aus deiner letzten Behandlung/)).toBeInTheDocument();
|
||||
expect(createMedicine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the plain empty state when there is nothing to suggest", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Noch keine Tierarzneimittel")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Aus deinen Behandlungen übernehmen?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the main action is Mittel anlegen, once in the header", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([medicine()]);
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("link", { name: /Apivar/ });
|
||||
expect(screen.getAllByRole("button", { name: "Mittel anlegen" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hides create buttons without veterinary-medicines:create", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([]);
|
||||
vi.mocked(fetchMedicineSuggestions).mockResolvedValue([suggestion()]);
|
||||
renderPage(["veterinary-medicines:read"]);
|
||||
|
||||
await screen.findByText("Thymovar");
|
||||
expect(screen.queryByRole("button", { name: /Mittel anlegen/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Übernehmen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces the number of shown medicines in one short line instead of the whole list", async () => {
|
||||
vi.mocked(fetchMedicines).mockResolvedValue([medicine(), medicine({ id: "m2", name: "Thymovar" })]);
|
||||
renderPage();
|
||||
|
||||
const count = await screen.findByText("2 aktive Mittel");
|
||||
expect(count).toHaveAttribute("aria-live", "polite");
|
||||
expect(document.querySelectorAll("[aria-live]").length).toBe(1);
|
||||
});
|
||||
|
||||
it("shows no data and no retry without veterinary-medicines:read", async () => {
|
||||
renderPage([]);
|
||||
|
||||
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||
expect(fetchMedicines).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /Erneut versuchen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Pill } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchMedicines } from "@/lib/medicines/api";
|
||||
import { formatDay } from "@/lib/medicines/format";
|
||||
import type { MedicineView } from "@/lib/medicines/types";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { RestText } from "@/components/medicines/rest-text";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { NoReadAccess } from "@/components/medicines/no-read-access";
|
||||
import { MedicineFormDialog } from "./medicine-form-dialog";
|
||||
import { MedicineSuggestions } from "./medicine-suggestions";
|
||||
|
||||
export default function MedicinesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canRead = hasPermission("veterinary-medicines:read");
|
||||
const canCreate = hasPermission("veterinary-medicines:create");
|
||||
const [tab, setTab] = useState<EntityLifecycleTab>("active");
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const medicinesQuery = useQuery({ queryKey: ["medicines"], queryFn: () => fetchMedicines(), enabled: canRead });
|
||||
|
||||
const medicines = medicinesQuery.data ?? [];
|
||||
const active = medicines.filter((medicine) => medicine.active);
|
||||
const inactive = medicines.filter((medicine) => !medicine.active);
|
||||
const visible = tab === "active" ? active : inactive;
|
||||
const countText = medicinesQuery.data
|
||||
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
||||
: "";
|
||||
|
||||
if (!canRead) return <NoReadAccess />;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Pill}
|
||||
title="Tierarzneimittel"
|
||||
description="Dein Katalog mit Packungen und dem Bewegungsbuch. Behandlungen buchen aus der Packung ab."
|
||||
actions={canCreate ? <MedicineFormDialog disabled={isOffline} describedBy={describedBy} /> : undefined}
|
||||
/>
|
||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
{medicines.length > 0 ? (
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{medicines.length > 0 ? countText : ""}
|
||||
</p>
|
||||
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() =>
|
||||
medicines.length === 0 ? (
|
||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title={tab === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||
: "Deaktivierte Mittel erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MedicineGrid medicines={visible} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicineGrid({ medicines }: { medicines: MedicineView[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{medicines.map((medicine) => (
|
||||
<EntityCard
|
||||
key={medicine.id}
|
||||
href={`/medicines/${medicine.id}`}
|
||||
icon={Pill}
|
||||
title={medicine.name}
|
||||
subtitle={medicine.activeIngredient ?? "—"}
|
||||
badge={medicine.active ? undefined : <Badge variant="secondary">Deaktiviert</Badge>}
|
||||
fields={[
|
||||
{ label: "Rest", value: <RestText value={medicine.totalRemaining} unit={medicine.unit} /> },
|
||||
{ label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,4 +65,14 @@ describe("nav-config", () => {
|
||||
expect(item?.label).toBe("Vorschläge");
|
||||
expect(item?.counter).toBe("suggestions");
|
||||
});
|
||||
|
||||
it("shows Prüfbereitschaft only with compliance:read", () => {
|
||||
expect(hrefs([])).not.toContain("/compliance");
|
||||
expect(hrefs(["compliance:read"])).toContain("/compliance");
|
||||
const group = visibleNavGroups({ permissions: ["compliance:read"] }).find((g) =>
|
||||
g.items.some((item) => item.href === "/compliance"),
|
||||
);
|
||||
expect(group?.label).toBe("Betrieb");
|
||||
expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Package,
|
||||
Scissors,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShoppingBag,
|
||||
Snowflake,
|
||||
Users,
|
||||
@@ -70,6 +71,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
|
||||
{ label: "Völker", href: "/colonies", icon: Layers },
|
||||
{ label: "Königinnen", href: "/queens", icon: Crown },
|
||||
{
|
||||
label: "Prüfbereitschaft",
|
||||
href: "/compliance",
|
||||
icon: ShieldCheck,
|
||||
permission: "compliance:read",
|
||||
},
|
||||
{
|
||||
label: "Überwinterung",
|
||||
href: "/winter",
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("ProductDetailPage", () => {
|
||||
sku: null,
|
||||
packagingSize: null,
|
||||
shelfLifeMonths: null,
|
||||
legalName: null,
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
status: "ACTIVE",
|
||||
@@ -102,6 +103,7 @@ describe("ProductDetailPage", () => {
|
||||
sku: null,
|
||||
packagingSize: 250,
|
||||
shelfLifeMonths: 18,
|
||||
legalName: "Blütenhonig",
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
status: "ACTIVE",
|
||||
@@ -113,6 +115,7 @@ describe("ProductDetailPage", () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText("Gewicht pro Glas: 250 g")).toBeInTheDocument();
|
||||
expect(screen.getByText("Haltbarkeit: 18 Monate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bezeichnung auf dem Etikett: Blütenhonig")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides jar weight and shelf life when they are not set", async () => {
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function ProductDetailPage() {
|
||||
<p>Haltbarkeit: {product.shelfLifeMonths} Monate</p>
|
||||
) : null}
|
||||
{product.sku ? <p>SKU: {product.sku}</p> : null}
|
||||
{product.legalName ? <p>Bezeichnung auf dem Etikett: {product.legalName}</p> : null}
|
||||
{product.salesUnit ? (
|
||||
<p>Verkaufseinheit: {product.salesUnit}</p>
|
||||
) : null}
|
||||
|
||||
@@ -16,6 +16,7 @@ const PRODUCT: Product = {
|
||||
sku: null,
|
||||
packagingSize: 500,
|
||||
shelfLifeMonths: 24,
|
||||
legalName: "Blütenhonig",
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
status: "ACTIVE",
|
||||
@@ -108,4 +109,16 @@ describe("EditProductDialog", () => {
|
||||
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("Frühtracht");
|
||||
});
|
||||
|
||||
it("the product form shows, saves and clears the label name", async () => {
|
||||
await openDialog();
|
||||
const field = screen.getByLabelText(/Bezeichnung auf dem Etikett \(z\. B\. Blütenhonig\)/);
|
||||
expect(field).toHaveValue("Blütenhonig");
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() => expect(updateProduct).toHaveBeenCalled());
|
||||
expect(vi.mocked(updateProduct).mock.calls[0][1]).toMatchObject({ legalName: null });
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
const [sku, setSku] = useState(product.sku ?? "");
|
||||
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
|
||||
const [shelfLifeMonths, setShelfLifeMonths] = useState(product.shelfLifeMonths?.toString() ?? "");
|
||||
const [legalName, setLegalName] = useState(product.legalName ?? "");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [packagingError, setPackagingError] = useState<string | null>(null);
|
||||
const [shelfLifeError, setShelfLifeError] = useState<string | null>(null);
|
||||
@@ -49,6 +50,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
setSku(product.sku ?? "");
|
||||
setPackagingSize(product.packagingSize?.toString() ?? "");
|
||||
setShelfLifeMonths(product.shelfLifeMonths?.toString() ?? "");
|
||||
setLegalName(product.legalName ?? "");
|
||||
setWeight(product.weight?.toString() ?? "");
|
||||
setSalesUnit(product.salesUnit ?? "");
|
||||
setStatus(product.status);
|
||||
@@ -66,6 +68,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
sku: emptyToNull(sku),
|
||||
packagingSize: emptyToNull(packagingSize, Number),
|
||||
shelfLifeMonths: emptyToNull(shelfLifeMonths, Number),
|
||||
legalName: emptyToNull(legalName),
|
||||
weight: emptyToNull(weight, Number),
|
||||
salesUnit: emptyToNull(salesUnit),
|
||||
status,
|
||||
@@ -204,6 +207,21 @@ export function EditProductDialog({ product }: { product: Product }) {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-legal-name">
|
||||
Bezeichnung auf dem Etikett (z. B. Blütenhonig)
|
||||
</Label>
|
||||
<Input
|
||||
id="product-edit-legal-name"
|
||||
maxLength={200}
|
||||
aria-describedby="product-edit-legal-name-hint"
|
||||
value={legalName}
|
||||
onChange={(event) => setLegalName(event.target.value)}
|
||||
/>
|
||||
<p id="product-edit-legal-name-hint" className="text-xs text-muted-foreground">
|
||||
Steht als Verkehrsbezeichnung auf dem Etikett. Ohne Angabe fehlt sie dort.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="product-edit-weight">Artikelgewicht gesamt (g)</Label>
|
||||
<Input
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("NewProductPage", () => {
|
||||
weight: null,
|
||||
salesUnit: null,
|
||||
shelfLifeMonths: null,
|
||||
legalName: null,
|
||||
kind: "BOTTLED_HONEY",
|
||||
shopCategory: "HONIG",
|
||||
});
|
||||
@@ -115,4 +116,17 @@ describe("NewProductPage", () => {
|
||||
expect(screen.getByText("Bitte ganze Monate von 1 bis 60 angeben.")).toBeInTheDocument();
|
||||
expect(createProduct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the label name of the product trimmed", async () => {
|
||||
vi.mocked(createProduct).mockResolvedValue({ id: "p-1" } as never);
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Name/), "Frühtracht");
|
||||
const legalName = screen.getByLabelText(/Bezeichnung auf dem Etikett \(z\. B\. Blütenhonig\)/);
|
||||
expect(legalName).toHaveAttribute("maxlength", "200");
|
||||
await userEvent.type(legalName, " Blütenhonig ");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Produkt anlegen" }));
|
||||
|
||||
expect(vi.mocked(createProduct).mock.calls[0][0]).toMatchObject({ legalName: "Blütenhonig" });
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,20 @@ export default function NewProductPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Bezeichnung auf dem Etikett (z. B. Blütenhonig)"
|
||||
hint="Steht als Verkehrsbezeichnung auf dem Etikett. Ohne Angabe fehlt sie dort."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={200}
|
||||
aria-describedby={describedBy}
|
||||
value={values.legalName}
|
||||
onChange={(event) => set("legalName", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Gewicht pro Glas (g)"
|
||||
hint="Honig pro Glas in Gramm, z. B. 500. Die Abfüllung übernimmt den Wert."
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ProductFormValues {
|
||||
salesUnit: string;
|
||||
packagingSize: string;
|
||||
shelfLifeMonths: string;
|
||||
legalName: string;
|
||||
weight: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
@@ -27,6 +28,7 @@ export function initialProductValues(): ProductFormValues {
|
||||
salesUnit: "",
|
||||
packagingSize: "",
|
||||
shelfLifeMonths: "",
|
||||
legalName: "",
|
||||
weight: "",
|
||||
sku: "",
|
||||
description: "",
|
||||
@@ -53,6 +55,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
|
||||
sku: emptyToNull(values.sku),
|
||||
packagingSize: emptyToNull(values.packagingSize, Number),
|
||||
shelfLifeMonths: emptyToNull(values.shelfLifeMonths, Number),
|
||||
legalName: emptyToNull(values.legalName),
|
||||
weight: emptyToNull(values.weight, Number),
|
||||
salesUnit: emptyToNull(values.salesUnit),
|
||||
kind: values.kind,
|
||||
|
||||
@@ -20,6 +20,8 @@ const settings: SiteSettings = {
|
||||
websiteUrl: null,
|
||||
holderName: null,
|
||||
holderAddress: null,
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
originCountry: "Deutschland",
|
||||
updatedAt: "2026-10-13T00:00:00.000Z",
|
||||
};
|
||||
@@ -68,3 +70,71 @@ describe("SocialMediaTab origin country", () => {
|
||||
expect(await screen.findByText("Bitte ein Land angeben.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SocialMediaTab food business", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchSiteSettings).mockReset();
|
||||
vi.mocked(updateSiteSettings).mockReset();
|
||||
vi.mocked(updateSiteSettings).mockResolvedValue(settings);
|
||||
});
|
||||
|
||||
it("shows the saved food business fields with their limits", async () => {
|
||||
vi.mocked(fetchSiteSettings).mockResolvedValue({
|
||||
...settings,
|
||||
foodBusinessName: "Imkerei Muster",
|
||||
foodBusinessAddress: "Bühlweg 10, 86450 Altenmünster",
|
||||
});
|
||||
renderTab();
|
||||
|
||||
const name = await screen.findByLabelText(/^Name des Lebensmittelunternehmers/);
|
||||
expect(name).toHaveValue("Imkerei Muster");
|
||||
expect(name).toHaveAttribute("maxlength", "200");
|
||||
const address = screen.getByLabelText(/^Anschrift des Lebensmittelunternehmers/);
|
||||
expect(address).toHaveValue("Bühlweg 10, 86450 Altenmünster");
|
||||
expect(address).toHaveAttribute("maxlength", "300");
|
||||
});
|
||||
|
||||
it("Vom Bienenhalter übernehmen fills the fields visibly and saves only on Speichern", async () => {
|
||||
vi.mocked(fetchSiteSettings).mockResolvedValue({
|
||||
...settings,
|
||||
holderName: "Max Imker",
|
||||
holderAddress: "Dorfstraße 1, 12345 Ort",
|
||||
});
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Vom Bienenhalter übernehmen" }));
|
||||
|
||||
expect(screen.getByLabelText(/^Name des Lebensmittelunternehmers/)).toHaveValue("Max Imker");
|
||||
expect(screen.getByLabelText(/^Anschrift des Lebensmittelunternehmers/)).toHaveValue(
|
||||
"Dorfstraße 1, 12345 Ort",
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/noch nicht gespeichert/);
|
||||
expect(updateSiteSettings).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({
|
||||
foodBusinessName: "Max Imker",
|
||||
foodBusinessAddress: "Dorfstraße 1, 12345 Ort",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite the fields when the beekeeper has no data", async () => {
|
||||
vi.mocked(fetchSiteSettings).mockResolvedValue({ ...settings, foodBusinessName: "Eigener Name" });
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Vom Bienenhalter übernehmen" }));
|
||||
|
||||
expect(screen.getByLabelText(/^Name des Lebensmittelunternehmers/)).toHaveValue("Eigener Name");
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/nichts eingetragen/);
|
||||
});
|
||||
|
||||
it("saves emptied or blank food business fields as null so the server clears them", async () => {
|
||||
vi.mocked(fetchSiteSettings).mockResolvedValue({ ...settings, foodBusinessName: "Alt" });
|
||||
renderTab();
|
||||
|
||||
await userEvent.clear(await screen.findByLabelText(/^Name des Lebensmittelunternehmers/));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({ foodBusinessName: null });
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UserRound } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
|
||||
import { SiteSettings } from "@/lib/site-settings/types";
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -21,6 +23,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
|
||||
const [holderName, setHolderName] = useState(settings.holderName ?? "");
|
||||
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
|
||||
const [foodBusinessName, setFoodBusinessName] = useState(settings.foodBusinessName ?? "");
|
||||
const [foodBusinessAddress, setFoodBusinessAddress] = useState(
|
||||
settings.foodBusinessAddress ?? "",
|
||||
);
|
||||
const [adoptNote, setAdoptNote] = useState<string | null>(null);
|
||||
const [originCountry, setOriginCountry] = useState(settings.originCountry);
|
||||
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
|
||||
|
||||
@@ -30,9 +37,21 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["site-settings"] });
|
||||
toast.success("Website-Einstellungen gespeichert");
|
||||
},
|
||||
onError: () => toast.error("Einstellungen konnten nicht gespeichert werden"),
|
||||
onError: (cause) =>
|
||||
toast.error(saveErrorText(cause, "Einstellungen konnten nicht gespeichert werden")),
|
||||
});
|
||||
|
||||
// Fills the form fields only; nothing is saved until „Speichern“.
|
||||
function adoptFromHolder() {
|
||||
if (holderName.trim() === "" && holderAddress.trim() === "") {
|
||||
setAdoptNote("Beim Bienenhalter ist noch nichts eingetragen.");
|
||||
return;
|
||||
}
|
||||
setFoodBusinessName(holderName);
|
||||
setFoodBusinessAddress(holderAddress);
|
||||
setAdoptNote("Übernommen, noch nicht gespeichert. Prüfe die Angaben und klicke auf Speichern.");
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// NOT NULL in the database: it can be changed, not emptied.
|
||||
@@ -47,6 +66,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
websiteUrl,
|
||||
holderName,
|
||||
holderAddress,
|
||||
foodBusinessName: foodBusinessName.trim() || null,
|
||||
foodBusinessAddress: foodBusinessAddress.trim() || null,
|
||||
originCountry: originCountry.trim(),
|
||||
});
|
||||
}
|
||||
@@ -141,6 +162,52 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
|
||||
<legend className="px-1 text-sm font-medium">Lebensmittelunternehmer (für das Etikett)</legend>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Name und Anschrift stehen als Pflichtangabe auf jedem Honigetikett. Sie werden nicht
|
||||
im Shop und nicht auf der Herkunftsseite angezeigt.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="site-food-business-name">Name des Lebensmittelunternehmers</Label>
|
||||
<Input
|
||||
id="site-food-business-name"
|
||||
maxLength={200}
|
||||
value={foodBusinessName}
|
||||
onChange={(event) => {
|
||||
setFoodBusinessName(event.target.value);
|
||||
setAdoptNote(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="site-food-business-address">
|
||||
Anschrift des Lebensmittelunternehmers
|
||||
</Label>
|
||||
<Input
|
||||
id="site-food-business-address"
|
||||
maxLength={300}
|
||||
value={foodBusinessAddress}
|
||||
onChange={(event) => {
|
||||
setFoodBusinessAddress(event.target.value);
|
||||
setAdoptNote(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="self-start"
|
||||
onClick={adoptFromHolder}
|
||||
>
|
||||
<UserRound className="size-4" />
|
||||
Vom Bienenhalter übernehmen
|
||||
</Button>
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{adoptNote}
|
||||
</p>
|
||||
</fieldset>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||
</Button>
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function EditLabelTemplatePage() {
|
||||
elements: template.elements,
|
||||
widthMm: template.widthMm,
|
||||
heightMm: template.heightMm,
|
||||
minXHeightMm: template.minXHeightMm,
|
||||
columns: template.columns,
|
||||
rows: template.rows,
|
||||
marginTopMm: template.marginTopMm,
|
||||
|
||||
+202
-4
@@ -1,13 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LabelDesigner } from "./label-designer";
|
||||
import { toast } from "sonner";
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import type { LabelCheckResult } from "@/lib/label-templates/check";
|
||||
import type { LabelElement } from "@/lib/label-templates/types";
|
||||
import { LabelDesigner, type LabelDesignerValue } from "./label-designer";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const RESULT: LabelCheckResult = {
|
||||
printable: false,
|
||||
gaps: [{ code: "ORIGIN", title: "Herkunftsland fehlt", detail: "Fehlt auf dem Etikett.", elementIds: [] }],
|
||||
warnings: [],
|
||||
sample: true,
|
||||
// Deliberately different from the stored value: the hint must show this one.
|
||||
requirements: { minXHeightMm: 2.3, netQuantityMinCapHeightMm: 3 },
|
||||
};
|
||||
|
||||
const TEXT: LabelElement = {
|
||||
id: "t1",
|
||||
type: "text",
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: 40,
|
||||
height: 8,
|
||||
text: "Honig",
|
||||
placeholderKey: null,
|
||||
fontSize: 8,
|
||||
fontFamily: "sans",
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: "#000000",
|
||||
align: "left",
|
||||
};
|
||||
|
||||
const INITIAL: LabelDesignerValue = {
|
||||
name: "Etikett",
|
||||
elements: [TEXT, { id: "q1", type: "qrCode", x: 50, y: 1, size: 10 }],
|
||||
widthMm: 90,
|
||||
heightMm: 35,
|
||||
minXHeightMm: 1.7,
|
||||
columns: null,
|
||||
rows: null,
|
||||
marginTopMm: 5,
|
||||
marginLeftMm: 5,
|
||||
gapXMm: 2,
|
||||
gapYMm: 2,
|
||||
cropMarks: true,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
function sentBodies(): { elements: { id: string }[]; minXHeightMm: number }[] {
|
||||
return vi
|
||||
.mocked(apiFetch)
|
||||
.mock.calls.map((call) => JSON.parse(String((call[1] as RequestInit).body)));
|
||||
}
|
||||
|
||||
describe("LabelDesigner placeholder menu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(RESULT);
|
||||
});
|
||||
|
||||
describe("LabelDesigner", () => {
|
||||
it("keeps the placeholder menu labelled as an action after inserting, never showing the raw key", async () => {
|
||||
render(<LabelDesigner onSubmit={vi.fn()} submitLabel="Anlegen" />);
|
||||
const insertMenu = screen.getByText("Platzhalter einfügen").closest('[role="combobox"]');
|
||||
@@ -25,3 +89,137 @@ describe("LabelDesigner", () => {
|
||||
expect(insertMenu).not.toHaveTextContent("Chargennummer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LabelDesigner compliance check", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(RESULT);
|
||||
vi.mocked(toast).mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("debounces the check by 500 ms and sends text elements only", async () => {
|
||||
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/label-templates/check");
|
||||
expect(sentBodies()[0].elements.map((element) => element.id)).toEqual(["t1"]);
|
||||
expect(sentBodies()[0].minXHeightMm).toBe(INITIAL.minXHeightMm);
|
||||
expect(screen.getByText("Fehlt auf dem Etikett.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Pflichtangaben-Block einfügen adds the block and Rückgängig removes it again", async () => {
|
||||
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
const insert = screen.getByRole("button", { name: "Pflichtangaben-Block einfügen" });
|
||||
expect(insert).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
await act(async () => {
|
||||
insert.click();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// The next check carries the block: the original text plus one element per new placeholder and the net quantity.
|
||||
const afterInsert = sentBodies().at(-1)!.elements;
|
||||
expect(afterInsert.length).toBeGreaterThan(1);
|
||||
const toastCall = vi.mocked(toast).mock.calls.at(-1)!;
|
||||
const action = (toastCall[1] as { action: { label: string; onClick: () => void } }).action;
|
||||
expect(action.label).toBe("Rückgängig");
|
||||
|
||||
await act(async () => {
|
||||
action.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(sentBodies().at(-1)!.elements.map((element) => element.id)).toEqual(["t1"]);
|
||||
});
|
||||
|
||||
it("keeps the insert button inactive with a reason until the first check is back", async () => {
|
||||
vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
|
||||
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
|
||||
const insert = screen.getByRole("button", { name: "Pflichtangaben-Block einfügen" });
|
||||
|
||||
expect(insert).toHaveAttribute("aria-disabled", "true");
|
||||
const reason = document.getElementById(insert.getAttribute("aria-describedby") ?? "");
|
||||
expect(reason).toHaveTextContent(/Prüfung/);
|
||||
});
|
||||
|
||||
it("shows the stored minimum and the one the check demands, and states no legal number itself", async () => {
|
||||
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
|
||||
expect(screen.getByLabelText(/Mindest-x-Höhe/)).toHaveValue("1,7");
|
||||
const hint = document.getElementById("designer-min-x-height-hint")!;
|
||||
expect(hint).toHaveTextContent(/gesetzlichen Mindestwert/);
|
||||
expect(hint).not.toHaveTextContent(/mm/);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
// The number comes from the check result, not from the frontend.
|
||||
expect(hint).toHaveTextContent("2,3 mm");
|
||||
});
|
||||
|
||||
it("leaves the field empty for a new template and sends no minimum on create", async () => {
|
||||
vi.useRealTimers();
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const { minXHeightMm: stored, ...withoutMin } = INITIAL;
|
||||
void stored;
|
||||
render(<LabelDesigner initial={withoutMin} onSubmit={onSubmit} submitLabel="Anlegen" />);
|
||||
expect(screen.getByLabelText(/Mindest-x-Höhe/)).toHaveValue("");
|
||||
expect(screen.getByLabelText(/Mindest-x-Höhe/)).not.toHaveAttribute("aria-invalid", "true");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("minXHeightMm");
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the minimum x-height field accepts the technical range 0.5 to 5", async () => {
|
||||
vi.useRealTimers();
|
||||
render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
|
||||
const field = screen.getByLabelText(/Mindest-x-Höhe/);
|
||||
|
||||
for (const valid of ["0,5", "0.5", "5", "2,75"]) {
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, valid);
|
||||
expect(field).not.toHaveAttribute("aria-invalid", "true");
|
||||
}
|
||||
for (const invalid of ["0,4", "5,1", "0", "-1", "abc"]) {
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, invalid);
|
||||
expect(field).toHaveAttribute("aria-invalid", "true");
|
||||
expect(screen.getByText(/zwischen 0,5 und 5 mm/)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("saves the minimum x-height and refuses to save an invalid one", async () => {
|
||||
vi.useRealTimers();
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(<LabelDesigner initial={INITIAL} onSubmit={onSubmit} submitLabel="Speichern" />);
|
||||
const field = screen.getByLabelText(/Mindest-x-Höhe/);
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "9");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(field).toHaveFocus();
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "0,9");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ minXHeightMm: 0.9 }));
|
||||
});
|
||||
});
|
||||
+127
-5
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { ChangeEvent, useEffect, useId, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Download,
|
||||
Image as ImageIcon,
|
||||
Minus,
|
||||
QrCode,
|
||||
ShieldCheck,
|
||||
Type,
|
||||
Upload,
|
||||
ZoomIn,
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
PLACEHOLDER_LABELS,
|
||||
PlaceholderKey,
|
||||
} from "@/lib/label-templates/types";
|
||||
import {
|
||||
MIN_X_HEIGHT_RANGE_MM,
|
||||
useDebouncedLabelCheck,
|
||||
} from "@/lib/label-templates/check";
|
||||
import { buildComplianceBlock } from "@/lib/label-templates/compliance-block";
|
||||
import { LabelCompliancePanel } from "@/components/labels/label-compliance-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -40,6 +47,18 @@ const A4_HEIGHT_MM = 297;
|
||||
const ZOOM_STEPS: number[] = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
const ZOOM_MIN = ZOOM_STEPS[0];
|
||||
const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1];
|
||||
const MIN_X_HEIGHT_ERROR = "Bitte einen Wert zwischen 0,5 und 5 mm eingeben.";
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
return String(value).replace(".", ",");
|
||||
}
|
||||
|
||||
/** German or English decimal sign; `null` for empty input or outside the range the server accepts. */
|
||||
function parseMinXHeight(text: string): number | null {
|
||||
const value = Number(text.trim().replace(",", "."));
|
||||
if (text.trim() === "" || !Number.isFinite(value)) return null;
|
||||
return value >= MIN_X_HEIGHT_RANGE_MM.min && value <= MIN_X_HEIGHT_RANGE_MM.max ? value : null;
|
||||
}
|
||||
|
||||
function nextZoomStep(current: number, direction: 1 | -1): number {
|
||||
const index = ZOOM_STEPS.indexOf(current);
|
||||
@@ -76,6 +95,8 @@ export interface LabelDesignerValue {
|
||||
elements: LabelElement[];
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
/** Absent on a new template: the server sets its default on create. */
|
||||
minXHeightMm?: number;
|
||||
columns: number | null;
|
||||
rows: number | null;
|
||||
marginTopMm: number;
|
||||
@@ -141,6 +162,9 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90);
|
||||
const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35);
|
||||
const [minXHeightText, setMinXHeightText] = useState(
|
||||
initial?.minXHeightMm === undefined ? "" : formatDecimal(initial.minXHeightMm),
|
||||
);
|
||||
const [columns, setColumns] = useState<number | null>(initial?.columns ?? null);
|
||||
const [rows, setRows] = useState<number | null>(initial?.rows ?? null);
|
||||
const [marginTopMm, setMarginTopMm] = useState(initial?.marginTopMm ?? 5);
|
||||
@@ -155,6 +179,16 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
|
||||
const insertHintId = useId();
|
||||
const minXHeightMm = parseMinXHeight(minXHeightText);
|
||||
const minXHeightInvalid = minXHeightText.trim() !== "" && minXHeightMm === null;
|
||||
const check = useDebouncedLabelCheck({
|
||||
elements,
|
||||
widthMm,
|
||||
heightMm,
|
||||
minXHeightMm: minXHeightMm ?? Number.NaN,
|
||||
});
|
||||
const canInsertBlock = check.result !== null;
|
||||
|
||||
// Mirrors the backend's clamp in label-templates.service.ts exactly, so
|
||||
// the hint shown here always matches what actually prints - a value typed
|
||||
@@ -205,6 +239,27 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
});
|
||||
}
|
||||
|
||||
// The block is a start, not a finished layout: it lands on the label and can
|
||||
// be taken back in one step.
|
||||
function insertComplianceBlock() {
|
||||
if (!check.result) return;
|
||||
const block = buildComplianceBlock({ widthMm, heightMm }, check.result.requirements);
|
||||
const ids = new Set(block.map((element) => element.id));
|
||||
setElements((current) => [...current, ...block]);
|
||||
setSelectedId(block[0]?.id ?? null);
|
||||
toast("Pflichtangaben-Block eingefügt", {
|
||||
description: "Ordne die Felder auf dem Etikett an.",
|
||||
action: {
|
||||
label: "Rückgängig",
|
||||
onClick: () => {
|
||||
setElements((current) => current.filter((element) => !ids.has(element.id)));
|
||||
setSelectedId((current) => (current !== null && ids.has(current) ? null : current));
|
||||
},
|
||||
},
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
|
||||
function duplicateElement(id: string) {
|
||||
const source = elements.find((element) => element.id === id);
|
||||
if (!source) return;
|
||||
@@ -231,12 +286,13 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
}, [selectedId, elements]);
|
||||
|
||||
// Leaving the page (tab close, reload) with unsaved changes asks first.
|
||||
const currentSnapshot = JSON.stringify({ name, elements, widthMm, heightMm });
|
||||
const currentSnapshot = JSON.stringify({ name, elements, widthMm, heightMm, minXHeightText });
|
||||
const initialSnapshot = JSON.stringify({
|
||||
name: initial?.name ?? "",
|
||||
elements: initial?.elements ?? [],
|
||||
widthMm: initial?.widthMm ?? 90,
|
||||
heightMm: initial?.heightMm ?? 35,
|
||||
minXHeightText: initial?.minXHeightMm === undefined ? "" : formatDecimal(initial.minXHeightMm),
|
||||
});
|
||||
const hasUnsavedChanges = currentSnapshot !== initialSnapshot;
|
||||
useEffect(() => {
|
||||
@@ -277,6 +333,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
elements,
|
||||
widthMm,
|
||||
heightMm,
|
||||
...(minXHeightMm === null ? {} : { minXHeightMm }),
|
||||
columns,
|
||||
rows,
|
||||
marginTopMm,
|
||||
@@ -320,6 +377,9 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
if (typeof value.name === "string" && value.name && !name) setName(value.name);
|
||||
if (typeof value.widthMm === "number") setWidthMm(value.widthMm);
|
||||
if (typeof value.heightMm === "number") setHeightMm(value.heightMm);
|
||||
if (typeof value.minXHeightMm === "number") {
|
||||
setMinXHeightText(formatDecimal(value.minXHeightMm));
|
||||
}
|
||||
if (typeof value.columns === "number" || value.columns === null) setColumns(value.columns);
|
||||
if (typeof value.rows === "number" || value.rows === null) setRows(value.rows);
|
||||
if (typeof value.marginTopMm === "number") setMarginTopMm(value.marginTopMm);
|
||||
@@ -342,6 +402,10 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
toast.error("Bitte einen Namen für die Vorlage angeben.");
|
||||
return;
|
||||
}
|
||||
if (minXHeightInvalid) {
|
||||
document.getElementById("designer-min-x-height")?.focus();
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
@@ -349,6 +413,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
elements,
|
||||
widthMm,
|
||||
heightMm,
|
||||
...(minXHeightMm === null ? {} : { minXHeightMm }),
|
||||
columns,
|
||||
rows,
|
||||
marginTopMm,
|
||||
@@ -405,6 +470,33 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="designer-min-x-height">Mindest-x-Höhe (mm)</Label>
|
||||
<Input
|
||||
id="designer-min-x-height"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="min-h-11 sm:h-11"
|
||||
value={minXHeightText}
|
||||
aria-invalid={minXHeightInvalid ? true : undefined}
|
||||
aria-describedby="designer-min-x-height-hint"
|
||||
onChange={(event) => setMinXHeightText(event.target.value)}
|
||||
/>
|
||||
<p id="designer-min-x-height-hint" className="text-xs text-muted-foreground">
|
||||
Der Wert muss den gesetzlichen Mindestwert erreichen.
|
||||
{check.result
|
||||
? ` Die Prüfung verlangt aktuell mindestens ${check.result.requirements.minXHeightMm.toLocaleString("de-DE", { maximumFractionDigits: 2 })} mm.`
|
||||
: ""}
|
||||
{minXHeightText.trim() === ""
|
||||
? " Leer lassen: Der Server setzt den Standardwert. Die Prüfung läuft, sobald hier ein Wert steht."
|
||||
: ""}
|
||||
</p>
|
||||
{minXHeightInvalid ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{MIN_X_HEIGHT_ERROR}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm" htmlFor="designer-default">
|
||||
<input
|
||||
id="designer-default"
|
||||
@@ -538,6 +630,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
render={
|
||||
<label className="cursor-pointer">
|
||||
<Upload className="size-4" />
|
||||
@@ -551,7 +644,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleExport}>
|
||||
<Button type="button" variant="ghost" size="sm" className="min-h-11" onClick={handleExport}>
|
||||
<Download className="size-4" />
|
||||
Exportieren
|
||||
</Button>
|
||||
@@ -562,6 +655,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() => addElement(newTextElement())}
|
||||
>
|
||||
<Type className="size-4" />
|
||||
@@ -577,7 +671,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
if (key) handlePlaceholderInsert(key);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-auto" size="sm" aria-label="Platzhalter einfügen">
|
||||
<SelectTrigger className="min-h-11 w-auto" size="sm" aria-label="Platzhalter einfügen">
|
||||
<SelectValue>Platzhalter einfügen</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -592,6 +686,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() =>
|
||||
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
|
||||
}
|
||||
@@ -603,6 +698,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() =>
|
||||
addElement({
|
||||
id: newId(),
|
||||
@@ -619,16 +715,42 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
<Minus className="size-4" />
|
||||
Linie
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" render={
|
||||
<Button type="button" variant="outline" size="sm" className="min-h-11" render={
|
||||
<label className="cursor-pointer">
|
||||
<ImageIcon className="size-4" />
|
||||
Bild/Logo
|
||||
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
|
||||
</label>
|
||||
} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
aria-disabled={canInsertBlock ? undefined : true}
|
||||
aria-describedby={canInsertBlock ? undefined : insertHintId}
|
||||
onClick={() => {
|
||||
if (canInsertBlock) insertComplianceBlock();
|
||||
}}
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
Pflichtangaben-Block einfügen
|
||||
</Button>
|
||||
{canInsertBlock ? null : (
|
||||
<p id={insertHintId} className="w-full text-xs text-muted-foreground">
|
||||
Geht, sobald die erste Prüfung da ist.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LabelCompliancePanel
|
||||
result={check.result}
|
||||
pending={check.pending}
|
||||
error={check.error}
|
||||
stale={check.stale}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user