feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- Veterinary medicine catalogue, lots and append-only movement ledger (rest always computed; corrections as counter-bookings; inactive medicines take no new stock; unit must match on lot moves). - Documents of medicine lots, authority notices and restrictions need the domain right on top of documents:* (403 before any lookup). - Label particulars: pure checkLabel with six mandatory gaps and four warnings, AFM font metrics, Berlin-day dates, geometric reading order for split addresses, new master-data fields with row-locked updates. - Herd events ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached (ids only, never forwarded to webhooks); CSV import emits ColonyCreated with audit. - ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8a31c42578
commit
c18860c75c
73 files changed
+7121
-309
No files matched your search
+88
-11
@@ -23,6 +23,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
|
||||
| `ExtractionsModule` | Schleuderungen | Harvests |
|
||||
| `HoneyBatchesModule` | Honigchargen, Honigmessungen | Extractions |
|
||||
| `StorageModule` | Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) | HoneyBatches |
|
||||
| `VeterinaryMedicinesModule` | Tierarzneimittel-Katalog, Packungen, Bewegungsbuch (T6, Abschnitt 12.2); exportiert `VeterinaryMedicinesService` und `VeterinaryMedicineLotsService` für die Behandlung (Task 3) | – |
|
||||
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
|
||||
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
|
||||
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
||||
@@ -119,7 +120,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
|
||||
5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung.
|
||||
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
|
||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. Seit T6 gilt für die Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes (Abschnitt 12.3, Annahmen 69 und 70).
|
||||
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Auch die Königin-Zuordnung (`QueenAssigned` → `calendar-sync.queen-assignment`) und die Wartezeit (`calendar-sync.withdrawal`, Abschnitt 11.8) laufen so; `CalendarSyncService.enqueue` und das Einreihen nach dem Commit gibt es nicht mehr, nur `enqueueIn`. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung/Wartezeit) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender und `getWithdrawalStatus` rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. Auch bei der Schleuderung warnt die App (`WITHDRAWAL_ACTIVE`, Abschnitt 11.12). Diese Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne `treatments:read`: Lebensmittelsicherheit geht vor der Lese-Trennung.
|
||||
@@ -167,7 +168,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
47. **Pflanzen-Meldungen, Neuberechnung und Saisonwechsel**: Rechte: `phenology:read` (Liste), `phenology:create` (Melden), `phenology:update` (Bearbeiten und Zurückziehen); `GET /phenology/state` braucht `apiaries:read` und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); `apiaryId` leer (`""`) ergibt 400, `null` heißt „alle Stände“. Wer `phenology:update` hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. `PhenologyReported` und `PhenologyCorrected` (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. `PhenologyCorrected` trägt `previousApiaryId` (beim Zurückziehen gleich `apiaryId`): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite `null` (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler `phenology.recompute` rechnet für den späteren von Berliner Tag des Ereignisses (`occurredAt`) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (kein Schlüssel), und `phenology.recompute-apiary` rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. **Bewusste Abweichung von der Spec:** Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. `SeasonPhaseChanged` bleibt für den Autopiloten.
|
||||
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
|
||||
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
|
||||
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), mit einer Ausnahme (Entscheidung zu Task 27): `Apiary` wird vor `Colony` gesperrt. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Karte im Code führt `Apiary` weiterhin weiter hinten (Stand dieses Dokuments): Die Ausnahme steht als Regel hier, nicht in der Karte.
|
||||
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), `Apiary` steht seit T6 an erster Stelle der Karte, also vor `Colony` (Entscheidung zu Task 27, in T6 Task 1 in die Karte übernommen). Hinter `NotificationDelivery` stehen die T6-Tabellen `LabelTemplate`, `SiteSettings`, `VeterinaryMedicine`, `VeterinaryMedicineLot`, `ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement` (Schlüssel jeweils `id`, `Product` stand schon in der Karte). Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände (aufsteigend), dann das Volk; `row-lock.spec.ts` hält die Reihenfolge und die Einmaligkeit jeder Tabelle fest. Der Abgleich je Stand (`AutopilotService.syncApiaryIn`) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (`lockRowsForUpdate`, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler `autopilot.colony-sync` sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Umbauten der Aufrufer (`ColoniesService.create/update/move/merge`, `ApiariesService`, `ApiaryRestrictionsService`) folgen mit T6 Task 8; ein Umsetzen, das zwischen Lesen und Sperren den Stand wechselt, endet mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“ ohne automatischen Neuversuch (Abschnitt 12.1).
|
||||
51. **Telegram: fehlerhaftes Update wird übersprungen (Annahme der Umsetzung)**: Ein Update, dessen Verarbeitung scheitert, wird bis zu 5 Mal oder höchstens 10 Minuten wiederholt (Zähler und erste Fehlzeit je `update_id` im Speicher des Pods; `MAX_UPDATE_ATTEMPTS`, `MAX_UPDATE_RETRY_MS`). Danach überspringt es eine eigene Transaktion: Offset weiter, `lastError` = „Telegram-Nachricht übersprungen“, eine Warnung ohne IDs und Text im Log, der Rest des Batches läuft weiter. Ohne diese Grenze würde ein dauerhaft scheiternder Befehl alle späteren Befehle (etwa jedes `/start`) sperren. Kosten bei falscher Annahme: ein verlorenes `/start`, der Nutzer erzeugt den Link neu. Eine verlorene Lease zählt nie als Fehlversuch, Verbindungsfehler ebenso wenig; die Zähler werden nach jedem Batch bereinigt. Abschnitt 11.13.
|
||||
52. **Wassergehalt: Bestätigung gilt für den gemessenen Wert**: Die Bestätigung über der gesetzlichen Grenze (`acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']`) trägt zusätzlich `acknowledgedWaterContent` mit dem Wert aus der 409-Antwort. Sie gilt nur, wenn der unter der Sperre geprüfte Wert genau gleich ist; sonst folgt eine frische 409. Wer bestätigt hat, hat also diesen Wert gesehen, und eine zwischenzeitlich neue Messung entwertet die Bestätigung (Lebensmittelsicherheit; Kosten bei falscher Annahme: ein zusätzliches Feld). Streng größer als die Grenze löst die Bestätigung aus, genau 20,0 % warnt nur. Abschnitt 11.12.
|
||||
53. **Vorbelegungs-Hinweise: Speichern bleibt erlaubt**: Laden die Hinweise einer Vorbelegung (Warnungen, etwa die Wartezeit) nicht, sperrt die Oberfläche das Speichern nicht; eine dauerhafte Zeile weist darauf hin, das Backend prüft beim Speichern. Kosten bei falscher Annahme: Ein Nutzer speichert, ohne die Wartezeitwarnung gesehen zu haben; die Antwort des Speicherns trägt die Warnung trotzdem. Abschnitt 11.12.
|
||||
@@ -176,6 +177,18 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
56. **Step-up: ein 401 wird nie mit Refresh wiederholt**: Bei Aufrufen mit Nachweis (Telegram verbinden, Backup-Import, Passwort ändern, Konto deaktivieren, MFA bestätigen und abschalten) bedeutet ein 401 „Nachweis falsch“. Der Client erneuert dort das Token nicht und wiederholt nicht (`refreshOn401: false`), weil ein zweiter Versuch den Sperrzähler verdoppeln würde. Ist die Sitzung wirklich abgelaufen, erscheint statt Stille der deutsche Hinweis „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“ (der Client kennt den Ablauf des Tokens nicht und erneuert nicht vorab). Kosten bei falscher Annahme: Eine abgelaufene Sitzung zeigt bei einem Step-up einmal einen Fehler statt einer stillen Erneuerung. Abschnitt 11.13.
|
||||
57. **Abfüllung: Produktwechsel setzt Artikelwerte zurück**: Wechselt das Produkt, verwirft die Maske von Hand geänderte Glasgröße und MHD und nimmt die Werte des neuen Artikels; ein Datumswechsel lässt eigene Eingaben stehen. Sonst bliebe die Glasgröße eines anderen Artikels unbemerkt im Formular. Kosten bei falscher Annahme: Wer erst tippt und dann das Produkt wählt, muss neu eintragen. Abschnitt 11.12.
|
||||
58. **„Alle fehlgeschlagenen erneut versuchen“: die Zahl kann zu niedrig sein**: Die API liefert keine Gesamtzahl der `FAILED`-Zeilen, die Rückfrage nennt daher nur „Mindestens N“ (größerer Wert aus `failed24h` und den `FAILED`-Zeilen der Liste) und immer die Grenze „höchstens 100, neueste zuerst“. Kosten bei falscher Annahme: Es gehen mehr (bis 100) Nachrichten erneut hinaus als angezeigt. Abschnitt 10, System-Seite.
|
||||
59. **T6-Schema: Prüfungen in der Datenbank, Rechtswerte als Einstellung**: CHECK-Constraints sichern `VeterinaryMedicineLot.quantity > 0`, `VeterinaryMedicineMovement.quantity <> 0` und `(type = 'TREATMENT') = ("treatmentId" IS NOT NULL)`, `ApiaryRestriction.endsOn >= startsOn` sowie `LabelTemplate.minXHeightMm` zwischen 0,5 und 5. Katalog, Packung, Behandlung und Stand hängen mit `ON DELETE RESTRICT` aneinander. `AuthorityNotice.openKey` ist nullbar-eindeutig (höchstens ein offener Entwurf je Art und Jahr). Die Vorgabe `ComplianceSettings.netQuantityDigitRules` (bis 50 g: 2 mm, bis 200 g: 3 mm, bis 1000 g: 4 mm, darüber 6 mm) und `minXHeightMm` 1,2 mm sind Annahme – vom Inhaber zu prüfen; im Code stehen keine Rechtswerte. Abschnitt 12.1.
|
||||
60. **Tierarzneimittel: eigener Katalog und eigenes Bewegungsbuch**: Ergänzung zu Annahme 10. Mittel (`VeterinaryMedicine`), Packungen (`VeterinaryMedicineLot`) und Bewegungen (`VeterinaryMedicineMovement`) sind nicht `StockItem`/`BatchMovement`. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht (es gibt keine Route dafür). Der Rest einer Packung ist `quantity` plus Summe der Bewegungen, auf Tausendstel gerundet, immer berechnet und nie gespeichert; ein negativer Rest ist erlaubt (die Behandlung hat stattgefunden) und erscheint in der Prüfbereitschaft. Der Katalog startet leer, der Seed legt nichts an. Kosten bei falscher Annahme: Wer einen Buchungsfehler erwartet zu löschen, muss gegenbuchen. Abschnitt 12.2.
|
||||
61. **Ein inaktives Mittel nimmt keinen neuen Bestand**: Eine Packung lässt sich weder zu einem inaktiven Mittel anlegen noch auf ein solches umhängen (400 „Das Mittel ist deaktiviert.“, `active` wird unter der Mittelsperre gelesen). Kosten bei falscher Annahme: Wer eine Lieferung eines stillgelegten Mittels buchen will, muss es erst wieder aktivieren.
|
||||
62. **Packung und Mittel müssen dieselbe Einheit haben**: Beim Anlegen und beim Umhängen einer Packung auf ein anderes Mittel gilt 400 „Die Einheit des Mittels passt nicht zur Packung.“; die Einheit eines Mittels ist nur änderbar, solange keine Packung existiert, das Mittel einer Packung nur, solange keine Bewegung existiert (jeweils 409).
|
||||
63. **Wiederherstellung ersetzt das Packungsbuch**: Eine versiegelte Datei überschreibt Mittel, Packungen und Bewegungen nach Id; sie führt das Buch nicht mit dem vorhandenen zusammen. Aus unversiegelten Dateien entsteht kein Packungsbezug einer Behandlung (Abschnitt 8). Kosten bei falscher Annahme: Buchungen, die nach der Sicherung entstanden sind, fehlen nach dem Einspielen und müssen nachgetragen werden.
|
||||
64. **Beim Wiederherstellen gewinnt der offene Entwurf im Zielsystem**: Hält dort ein Entwurf (`openKey`, etwa der vom Tagesjob angelegte TSK-Entwurf) den Schlüssel unter anderer Id, entfällt die Anzeige der Datei mit ihren Dokumenten; der Restore scheitert nicht am Unique-Constraint. Kosten bei falscher Annahme: Der Entwurf aus der Datei fehlt und muss neu erfasst werden (die Ids stehen in `summary.skippedNoticeIds`).
|
||||
65. **Etikett-Pflichtangaben prüft eine reine Funktion am aufgelösten Inhalt**: `checkLabel` (`src/label-templates/label-particulars.ts`) bewertet alle Text-Elemente mit eingesetzten Platzhaltern, normalisiert mit NFKC, Kleinschreibung und „alles außer Buchstaben und Ziffern wird zum Leerzeichen“. Ein Text, der nicht in sein Feld passt (gemessene Höhe größer als Feldhöhe plus 0,5 pt), zählt nicht und löst `TEXT_CLIPPED` aus. Nicht geprüft werden die „gleiches Sichtfeld“-Regel und die Nährwertdeklaration (für Honig befreit – Annahme, vom Inhaber zu prüfen). Das Datum auf dem Etikett und das erwartete MHD der Prüfung kommen beide aus `labelDayOf` (Berliner Kalendertag, unabhängig von der Prozess-Zeitzone). Eine Zahl direkt vor der Menge („1,250 g“) gilt nicht als Treffer für 250 g. Eine Anschrift wird auch erkannt, wenn sie auf mehrere Elemente verteilt ist, weil eine unerkannte Anschrift den Druck sperrt. Abschnitt 12.4.
|
||||
66. **Füllmengen-Höhe: jede Nennung zählt**: `NET_QUANTITY_HEIGHT` ist erfüllt, wenn irgendeine Nennung der Füllmenge hoch genug ist (größte Versalhöhe aller Träger); eine zusätzliche kleine Nennung warnt nicht. Die Warnung sperrt nie. Kosten bei falscher Annahme: Eine große Nennung neben einer zu kleinen wird nicht bemängelt.
|
||||
67. **Verkehrsbezeichnung: „honig“ in einem beliebigen Wort genügt**: Für `LEGAL_NAME` reicht ein Wort mit „honig“ („Blütenhonig“, auch „Honigtau“); ob die Bezeichnung inhaltlich stimmt, prüft die Software nicht. Kosten bei falscher Annahme: Ein falsch benannter Honig sperrt den Druck nicht.
|
||||
68. **Losnummer mit „L“ am Anfang erfüllt die Präfix-Regel**: Beginnt die Losnummer schon mit „L“ („L-001“), stellt der Platzhalter `lotLine` kein weiteres „L“ voran und `LOT_PREFIX` warnt nicht. Die Warnung sperrt nie.
|
||||
69. **`compliance:manage` schließt `compliance:read` nicht ein**: Die Rechte sind getrennt; die Rollenvergabe koppelt sie (nur ADMIN und BEEKEEPER haben `manage`, und diese haben auch `read`). Ein Nutzer mit nur `manage` darf bei Dokumenten hochladen, aber die Liste nicht lesen (403). Kosten bei falscher Annahme: Eine handgebaute Rolle mit nur `manage` sieht nichts, was sie angelegt hat.
|
||||
70. **Dokumente gelöschter Datensätze bleiben bestehen**: Sie bleiben durch das Recht der Art geschützt und werden nicht mitgelöscht; die löschenden Pfade der späteren Tasks (Entwürfe, Sperrbezirke: Tasks 8 und 9) entfernen ihre Dokumente selbst. Kosten bei falscher Annahme: Bis dahin bleiben Dateien ohne Datensatz im Speicher.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
@@ -252,7 +265,7 @@ Scheitert die Migration, scheitert der initContainer und der neue Pod startet ni
|
||||
|
||||
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
|
||||
|
||||
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Seit T4b zusätzlich `PhenologyReport` (eigene Beobachtungen; Import nach `winterCheck`, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). `ApiaryPhenology` (abgeleiteter Saisonzustand, der Job `daily-evaluation` berechnet ihn neu) und `ApiaryWeatherDay` (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren `HoneyBatch`-Zeilen kein `initialAmount` tragen, werden beim Import wie in Migration `t4_honey_ledger` zurückgerechnet (Annahme 44, `src/backup/legacy-honey-ledger.ts`): `initialAmount` wird gesetzt; die `BOTTLING`-Buchungen mit der stabilen ID `legacy-bottling:<Abfüllung>` entstehen nur aus versiegelten Dateien, nach dem Abschnitt `batchMovement`, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur `initialAmount` bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (`honeyLedgerViolations`) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und `imported.honeyLedgerViolations = <Anzahl>` (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits). Die Telegram-Tabellen werden nie exportiert: `TelegramLink` (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), `TelegramLinkToken` (Einmal-Tokens, nur Minuten gültig), `NotificationDelivery` (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und `TelegramPollState` (Update-Offset und Abfrage-Lease dieses Deployments). Die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
|
||||
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle: `Suggestion` (Import nach `colony`, vor `task`, weil `Task.sourceSuggestionId` darauf zeigt; `decidedByUserId` wie andere Benutzer-IDs, `null` bleibt `null`; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und `NotificationPreference`. Seit T4b zusätzlich `PhenologyReport` (eigene Beobachtungen; Import nach `winterCheck`, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). `ApiaryPhenology` (abgeleiteter Saisonzustand, der Job `daily-evaluation` berechnet ihn neu) und `ApiaryWeatherDay` (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren `HoneyBatch`-Zeilen kein `initialAmount` tragen, werden beim Import wie in Migration `t4_honey_ledger` zurückgerechnet (Annahme 44, `src/backup/legacy-honey-ledger.ts`): `initialAmount` wird gesetzt; die `BOTTLING`-Buchungen mit der stabilen ID `legacy-bottling:<Abfüllung>` entstehen nur aus versiegelten Dateien, nach dem Abschnitt `batchMovement`, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur `initialAmount` bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (`honeyLedgerViolations`) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und `imported.honeyLedgerViolations = <Anzahl>` (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem `dedupeKey`, der dort unter anderer ID existiert, wird übersprungen und in `imported.notificationSkipped` gezählt (nie umgehängt); Einträge alter Bundles ohne `userId` werden je aktivem Nutzer kopiert (abgeleitete ID `<id>:<userId>`, wiederholbar), ohne `category` nach der Regel von Migration `100800` (`relatedType 'JobRun'` oder leer → `SYSTEM`, sonst `SUGGESTION`). Ein alter Bundle-Wert für `Inspection.feedStatus` wird auf das Futter-Vokabular abgebildet (Annahme 33). Seit T6 (Abschnitt 12) gehören `VeterinaryMedicine`, `VeterinaryMedicineLot`, `VeterinaryMedicineMovement`, `ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction` und `ComplianceAcknowledgement` zum versiegelten Bundle; keine dieser Tabellen steht in `MODELS_EXCLUDED_FROM_BACKUP` oder `LEGACY_UNSEALED_TABLES`, eine unversiegelte Altdatei überspringt sie und meldet sie unter `skipped`. Beim Einspielen stehen Mittel und Packung vor den Behandlungen, die Buchungen der Packung danach, Sperrbezirke folgen den Ständen. Behandlungen einer unversiegelten Datei verlieren `medicineId`, `medicineLotId` und `doseQuantity` (ohne Bewegungsbuch gäbe es eine Behandlung mit Packung, aber ohne Buchung, also eine Fälschung des Bestands); die Zahl steht als `imported.treatmentMedicineLinksDropped` in der Zusammenfassung, dazu eine Log-Warnung. Eine versiegelte Wiederherstellung ersetzt das Packungsbuch (Annahme 63). Hält im Zielsystem ein Entwurf den `openKey` einer Behördenanzeige unter anderer Id, gewinnt dieser Entwurf: Die Anzeige der Datei wird samt ihren Dokumenten (`AUTHORITY_NOTICE`) übersprungen und als `imported.authorityNoticeSkipped` bzw. `imported.authorityNoticeDocumentsSkipped` gezählt, die Ids stehen in `summary.skippedNoticeIds` (Annahme 64). Die Wiederherstellung schreibt keine Domänenereignisse, also auch kein `ColonyCreated` (Abschnitt 12.5). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits). Die Telegram-Tabellen werden nie exportiert: `TelegramLink` (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), `TelegramLinkToken` (Einmal-Tokens, nur Minuten gültig), `NotificationDelivery` (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und `TelegramPollState` (Update-Offset und Abfrage-Lease dieses Deployments). Die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `keLine truncated
|
||||
- **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“).
|
||||
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
|
||||
|
||||
@@ -321,7 +334,7 @@ Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivitä
|
||||
|
||||
**Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre.
|
||||
|
||||
**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen.
|
||||
**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status, endedOn, releasedQueenId, via }` (Abschnitt 11.7). `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). Neu sind seit T6 `ColonyCreated`, `ColonyMoved`, `ApiaryChanged` und `TskReferenceDayReached` (Abschnitt 12.5); `ColonyCreated` schreiben auch Teilen und CSV-Import. Das Teilen schreibt `ColonySplit`, die Königin-Zuordnung `QueenAssigned` in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen.
|
||||
|
||||
**Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin.
|
||||
- Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`.
|
||||
@@ -333,7 +346,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
|
||||
**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
|
||||
|
||||
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen mit einer Ausnahme: `Apiary` kommt vor `Colony` (Annahme 50).
|
||||
**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. Die Reihenfolge der Sperr-Karte (`LOCK_ORDER`) gilt für alle Tabellen; `Apiary` steht seit T6 an erster Stelle, also vor `Colony` (Annahme 50).
|
||||
|
||||
**Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.
|
||||
|
||||
@@ -346,7 +359,7 @@ Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forwar
|
||||
- `SeasonPhaseChanged` (Aggregat `Apiary`, Payload `apiaryId`, `phase`, `segment`, `previousPhase`, `previousSegment`, `source`) schreibt `PhenologyStateService`, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über `dailyDedupeKey`. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Der Handler `autopilot.apiary-sync` hört darauf; eine Einzelmeldung je Saisonwechsel gibt es bewusst nicht (Annahme 47).
|
||||
- `PhenologyRecomputeRequested { apiaryId, allowBackward, day }` (Aggregat `Apiary`, ohne Schlüssel) schreibt der Handler `phenology.recompute` je aktivem Stand, wenn eine Meldung für alle Stände gilt; `phenology.recompute-apiary` rechnet damit einen Stand je Zustellung.
|
||||
- `WinterCheckRecorded { colonyId }` (Aggregat `WinterCheck`) schreiben `WinterChecksService.create` und `update` in ihrer Transaktion (ein Verschieben auf ein anderes Volk meldet auch das alte); `autopilot.colony-sync` löst damit A9/A10 sofort auf.
|
||||
`Apiary`, `PhenologyReport`, `Extraction` und `HoneyBatch` stehen am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `NotificationDelivery` steht ganz zuletzt (Abschnitt 11.13).
|
||||
`PhenologyReport`, `Extraction` und `HoneyBatch` stehen hinter `NotificationPreference` am Ende der Sperr-Karte (`LOCKABLE_TABLES`, Schlüssel `id`); `Apiary` steht seit T6 an erster Stelle (Annahme 50). `NotificationDelivery` folgt danach, ihr schließen sich die T6-Tabellen an (Abschnitte 11.13 und 12.1).
|
||||
|
||||
**Handler-Übersicht (T4).** Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über `tx` schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (`lockRowForUpdate`; fehlt es, ist nichts zu tun), Status über `tx` neu lesen (beendet: nichts), Regel über `readRule(tx, …)` lesen (abgeschaltet: nichts vorschlagen).
|
||||
|
||||
@@ -444,7 +457,7 @@ Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:m
|
||||
|
||||
## 11. Logik-Ketten und Saison-Autopilot (T4a bis T4c)
|
||||
|
||||
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13). Noch offen sind die Bestätigung beim Wassergehalt (Regel `HONEY_WATER_CONTENT`) und das Verknüpfen und Abfragen von Telegram (Task 36).
|
||||
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job `daily-evaluation`. Entwurf: Spec `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md`. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13); T4 ist vollständig (Stand 2026-10-15).
|
||||
|
||||
### 11.1 Vorschläge
|
||||
|
||||
@@ -452,7 +465,7 @@ Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensc
|
||||
|
||||
**PLAN und DO.** `PLAN` hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus `PLAN_STEP_TITLES`, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. `DO` ist genau eine Aufgabe und braucht ein Datum (eigenes oder `dueDate` des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus `renderSuggestion` mit dem `actionTemplate` der Regel. Die Aufgabe entsteht über `TasksService.createInTx` (Audit, `TaskCreated`, danach Kalender und Webhook). `sourceInspectionId` wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter `FOR SHARE` geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein `assignedUserId`, der nicht existiert oder inaktiv ist: 400 mit `issues`.
|
||||
|
||||
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference; Ausnahme: `Apiary` vor `Colony`, Annahme 50).
|
||||
**Übergänge.** Jede Entscheidung sperrt die Zeile `Suggestion` (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`) und setzt ihn per Compare-and-Set in SQL gegen `now()`; der Audit-Eintrag (`Suggestion UPDATE`) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; `proposeIn` sperrt zuerst die Volkszeile, dann die Regelzeile (`LOCK_ORDER` in `row-lock.ts`: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference; davor seit T6 `Apiary`, Annahme 50).
|
||||
|
||||
| von | nach | Auslöser |
|
||||
|---|---|---|
|
||||
@@ -566,7 +579,7 @@ Ein einziger Fenster-Helfer (`src/treatments/withdrawal-window.ts`, Annahme 10)
|
||||
|
||||
### 11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht
|
||||
|
||||
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
|
||||
**Meldungen.** Annahme 39. `GET /notifications` (eigene, nach aktuellen Rechten gefiltert, höchstens 100), `POST /notifications/read-all` (vor `:id/read` deklariert), `POST /notifications/:id/read` (404 für fremde, unbekannte oder verborgene). Kategorien (`NotificationCategory`) u. a. `SUGGESTION`, `SYSTEM`, `WITHDRAWAL`, `TASK_DUE`, `DIGEST`, `SEASON` und seit T6 `COMPLIANCE` („Behörden & Prüfung“, für die Hinweise „Bestand geändert“ und „TSK-Stichtag“, Abschnitt 12.1); die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne `link`-Pfade (nur mit führendem `/`, nie `//` oder `/\`). Einstellungen je Nutzer in `NotificationPreference`: `GET/PATCH /profile/notification-settings` (`@AnyAuthenticatedUser`, API-Schlüssel erhalten 403): `taskDueEnabled`, `digestEnabled`, `digestTime` (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); `telegramEnabled` schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
|
||||
|
||||
**Erinnerung je Aufgabe.** Handler `notifications.task-due` (`RemindersModule`, von `AppModule`, `WorkerModule` und `JobsModule` importiert) schreibt zu `TaskDue` je Empfänger eine Meldung (HINWEIS, `TASK_DUE`, Link `/tasks`, Schlüssel `task-due:<Aufgabe>:<dueOn>:<userId>`): „Heute fällig: <Titel>“, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: <Titel>“. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit `tasks:complete`; immer nur mit `tasks:read` und eingeschaltetem `taskDueEnabled` (fehlende Einstellung = an). Nur offene Aufgaben (`OPEN`, `IN_PROGRESS`); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen.
|
||||
|
||||
@@ -668,7 +681,7 @@ Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfange
|
||||
- `TELEGRAM_BOT_USERNAME` verliert ein führendes `@` und muss `^[A-Za-z][A-Za-z0-9_]{4,31}$` erfüllen, sonst gilt er als nicht gesetzt (`telegramBotAvailable()` falsch) mit Warnung.
|
||||
- Der Wächter `no-network-outside-loops.spec.ts` hält fest, dass das Netzwerk nur in den erlaubten Dateien und nur aus Schleifen genutzt wird (Abschnitt 9).
|
||||
|
||||
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE` und `DIGEST`; `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
|
||||
**Planung.** `NotificationDeliveryPlanner.planIn(db, notifications)` (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (`telegramEnabled`, Verbindung, `telegramCategories`) und die DB-Uhr, legt per `createMany({ skipDuplicates })` (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien `TASK_DUE`, `DIGEST` und seit T6 `COMPLIANCE` (Migration `20261016100400_t6_notification_compliance_default`, Abschnitt 12.1); `SUGGESTION`, `WITHDRAWAL`, `SEASON` und `SYSTEM` schaltet der Nutzer zu. `COMPLIANCE` hat wie `SYSTEM` keine Tageszeit (nur die Ruhezeit) und ist 24 Stunden frisch. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (`availableAt`) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).
|
||||
|
||||
**Texte** (`src/telegram/telegram-texts.ts`). `TELEGRAM_TEMPLATES` hält die festen Texte, `renderTelegramText` baut daraus den Klartext: ohne Details nur „Neue Meldung in ApiCore.“ und Link, Standardlink `/dashboard`, ein Schrägstrich am Ende von `FRONTEND_ORIGIN` wird toleriert. Höchstens 600 Zeichen nach Codepunkten; die Meldung wird mit Auslassungspunkten gekürzt, der Link bleibt ganz, Steuer- und unsichtbare Zeichen entfallen. Ohne `FRONTEND_ORIGIN` entfällt der Link (Annahme 46).
|
||||
|
||||
@@ -689,8 +702,72 @@ Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfange
|
||||
|
||||
**Betrieb.** Die Karte „Telegram“ unter Verwaltung > System (Abschnitt 10) zeigt Empfänger (Lease, Alter des letzten Abrufs, `lastError`) und Sender (wartend, überfällig, fehlgeschlagen in 24 h, letzte Fehler) und erlaubt das Wiederholen fehlgeschlagener Zustellungen. Die Einstellungsansicht (`GET /profile/notification-settings`) nennt das Feld `telegram.botNameSet` (Bot-Name gesetzt; das Token kennt die api nicht), derselbe Begriff wie am System-Endpunkt. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7.
|
||||
|
||||
**Oberfläche: Profil > Telegram** (`TelegramLinkCard`). Der Zustand steht in einer Statuszeile (`role="status"`): „Telegram ist nicht eingerichtet.“ (ohne `botNameSet`, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den `StepUpDialog`; danach erscheint der große Knopf „In Telegram öffnen“ (`target="_blank"`, `rel="noopener noreferrer"`) mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (`ConfirmDialog`) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit `https://t.me/` beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in `localStorage`/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (`ToggleChip`, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per `PATCH`. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).
|
||||
**Oberfläche: Profil > Telegram** (`TelegramLinkCard`). Der Zustand steht in einer Statuszeile (`role="status"`): „Telegram ist nicht eingerichtet.“ (ohne `botNameSet`, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den `StepUpDialog`; danach erscheint der große Knopf „In Telegram öffnen“ (`target="_blank"`, `rel="noopener noreferrer"`) mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (`ConfirmDialog`) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit `https://t.me/` beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in `localStorage`/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (Stand T4c; `COMPLIANCE` liefert das Backend schon, die Karte zeigt sie mit T6 Task 18; `ToggleChip`, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per `PATCH`. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).
|
||||
|
||||
**Step-up** (`src/components/auth/step-up-dialog.tsx`, geteilt mit dem Backup-Import; `pendingLabel`, beim Import „Wird eingespielt…“). Der Dialog fragt das aktuelle Passwort und bei MFA den 6-stelligen Code; `onConfirm(proof)` darf werfen, dann bleibt er offen, leert die Felder, fokussiert das Passwortfeld und zeigt den deutschen Grund in einer dauerhaften Live-Region. Während der Ausführung sind die Eingaben `readOnly`, Esc, Hintergrund und Schließen-Knopf wirkungslos. Die Felder leben nur, solange der Dialog offen ist. Ein fehlgeschlagener Import erscheint im Dialog statt als Toast. Alle Step-up-Aufrufe senden mit `refreshOn401: false` (Annahme 56): `createTelegramLinkToken`, `importBackup`, `changePassword`, `deactivateAccount`, `confirmMfaEnrollment` und `disableMfa`. `stepUpErrorText` (`src/lib/auth/step-up-error.ts`, auch im Passwortformular des Profils) liefert: 429 „Zu viele Versuche. Bitte in einer Minute noch einmal.“, ein 401 ohne eigene Meldung „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“, deutsche Server-Meldungen unverändert. Der Client kennt den Ablauf des Tokens nicht und erneuert vor Step-up-Aufrufen nicht vorab.
|
||||
|
||||
Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
|
||||
|
||||
## 12. Compliance (T6)
|
||||
|
||||
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), 4 (Dokumente), 5 (Etikett-Pflichtangaben) und 7 (Ereignisse). Task 3 (Behandlung aus Packung), 6 (Druck-Sperre und Probedruck), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 bis 19 (Behördenpaket, Prüfbereitschaft, Handler und Meldungen, Tagesregeln, Oberfläche) folgen und ergänzen diesen Abschnitt.
|
||||
|
||||
### 12.1 Schema und Rechte
|
||||
|
||||
**Migrationen** (alle additiv, nach `20261015110000_t4_telegram`): `20261016100000_t6_veterinary_medicines` (Enums `MedicineUnit`, `VeterinaryMedicineMovementType`; drei Tabellen; `Treatment.medicineId`, `medicineLotId`, `doseQuantity` mit Fremdschlüsseln `ON DELETE RESTRICT`, Indizes `Treatment_medicineId_idx` und `medicineLotId`; `DocumentEntityType` + `MEDICINE_LOT`; Rechte), `…100100_t6_label_particulars` (`SiteSettings.foodBusinessName`/`foodBusinessAddress`, `Product.legalName`, `LabelTemplate.minXHeightMm` Vorgabe 1,2, `AuditAction` + `PRINT`), `…100200_t6_authority_compliance` (`ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement`; `DocumentEntityType` + `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`; Rechte), `…100300_t6_notification_category_compliance` (nur `ALTER TYPE … ADD VALUE 'COMPLIANCE'`; der Wert wird in derselben Migration nicht benutzt) und `…100400_t6_notification_compliance_default`. Prüfungen und Annahmen zum Schema: Annahme 59. `netQuantityDigitRules` ist ein Prisma-`@default`, damit die SQL-Vorgabe keinen Drift gegen das Schema erzeugt; die Singleton-Zeile `ComplianceSettings` legt der Dienst (Task 9) per `upsert` an. Ein Behandlungs-Löschen mit Buchung scheitert an den RESTRICT-Schlüsseln mit `P2003`; Task 3 bildet das auf 409 „Diese Behandlung hat eine Buchung aus einer Packung und kann nicht gelöscht werden.“ ab.
|
||||
|
||||
**Rechte** (Katalog `permission-catalog.ts`; die Migrationen vergeben, der Katalog-Sync berechtigt nur System-Rollen):
|
||||
|
||||
| Recht | Gruppe (Sortierung) | ADMIN | BEEKEEPER | STAFF | READONLY | API_CLIENT |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `veterinary-medicines:read` | Tierarzneimittel (3700) | ✓ | ✓ | ✓ | ✓ | – |
|
||||
| `veterinary-medicines:create`, `:update` | Tierarzneimittel | ✓ | ✓ | ✓ | – | – |
|
||||
| `compliance:read` | Prüfbereitschaft & Behörden (3800) | ✓ | ✓ | ✓ | ✓ | – |
|
||||
| `compliance:manage` | Prüfbereitschaft & Behörden (3801) | ✓ | ✓ | – | – | – |
|
||||
|
||||
Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein eigenes Recht. Sperrbezirke lesen mit `apiaries:read` und schreiben mit `compliance:manage`. `AuditAction` kennt `PRINT` (Etikettendruck, Task 6).
|
||||
|
||||
**Sperr-Reihenfolge.** `Apiary` steht an erster Stelle von `LOCKABLE_TABLES`, danach `Colony` und die übrigen Tabellen wie bisher; die acht neuen Tabellen stehen hinter `NotificationDelivery` (Annahme 50). Die Völker-Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände mit `lockRowsForShare(tx, 'Apiary', ids)` (aufsteigend), dann das Volk; `ApiaryRestrictionsService` sperrt die betroffenen Stände mit `lockRowsForUpdate`, damit sich eine Sperre und ein laufendes Umsetzen hintereinander einreihen. Wechselt der Stand eines Volkes zwischen Lesen und Sperren, endet die Anfrage mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, ohne automatischen Neuversuch. Die Umbauten der Aufrufer sind Task 8. Services, die T6 anfasst und `before` bisher außerhalb lasen (`SiteSettingsService.update`, `ProductsService.update`, `LabelTemplatesService.update`), lesen es seit Task 5 nach der Zeilensperre.
|
||||
|
||||
**Backup.** Die sieben neuen Tabellen gehören zum versiegelten Bundle; Reihenfolge, Verlust des Packungsbezugs in unversiegelten Dateien und die Regel für Behördenanzeigen stehen in Abschnitt 8 (Annahmen 63 und 64).
|
||||
|
||||
**Meldungs-Kategorie `COMPLIANCE`** („Behörden & Prüfung“). Sie trägt die Hinweise „Bestand geändert“ und „TSK-Stichtag“ (Task 11) und ist anders als `SYSTEM` für Telegram vorgewählt: Die Vorgabe `telegramCategories` ist `[TASK_DUE, DIGEST, COMPLIANCE]`, und die Daten-Migration hängt `COMPLIANCE` an jede bestehende, nichtleere Liste an. Eine leere Liste (der Nutzer hat bewusst alles abgewählt, Opt-out) bleibt leer; Kosten bei falscher Annahme: ein Nutzer mit Opt-out bekäme sonst Behördenhinweise. `IsEnum(NotificationCategory)` und die Längengrenze übernehmen den Wert aus dem Enum, es gibt keine zweite Kategorienliste im Code. Das Frontend (Task 18) ergänzt Label und Auswahl; bis dahin zeigt es den Wert als unbekannte Kategorie.
|
||||
|
||||
**Null-Verträge.** `describeNullContracts` (`src/common/validation/null-contract.spec-helper.ts`, per `tsconfig.build.json` aus dem Build ausgeschlossen) registriert je Update-DTO die drei Prüfungen der Null-Regel (Annahme 20); jede T6-Task legt ihre Verträge in eine eigene Spec-Datei.
|
||||
|
||||
### 12.2 Tierarzneimittel: Katalog, Packungen, Bewegungsbuch
|
||||
|
||||
`VeterinaryMedicinesModule` (`src/veterinary-medicines/`, in `AppModule`, nicht im `WorkerModule`: es hat keine Handler) trennt drei Ebenen: das Mittel (`VeterinaryMedicine`, Handelsname, Wirkstoff, Einheit, Wartezeit, Lagerhinweise), die Packung (`VeterinaryMedicineLot`, Beleg, Verfall) und die Bewegung (`VeterinaryMedicineMovement`: `TREATMENT` nur aus einer Behandlung, `CORRECTION` für die Inventur, `DISPOSAL` mit Pflichtgrund). Grundsätze: Annahmen 60 bis 62. Der Rest ist `roundToThousandths(lot.quantity + Σ movement.quantity)` (`src/common/quantity.ts`, die einzige Rundung auf Tausendstel; `roundKg` in `honey-plausibility.ts` ist ein Alias, `bottling.service.ts` ruft sie direkt auf, `-0` wird zu `0`). Die reinen Helfer stehen in `medicine-ledger.ts` (`summarizeLot`, `treatmentBookingDelta` für die Gegenbuchung bei geänderter Packung oder Dosis, Task 3), die Sichten in `medicine-views.ts` (eine `groupBy`-Abfrage je Liste, kein N+1).
|
||||
|
||||
**API** (Rechte `veterinary-medicines:read|create|update`, Datumsfelder der Antworten als Tagestexte `JJJJ-MM-TT`):
|
||||
- `GET /veterinary-medicines?active=`, `GET /veterinary-medicines/suggestions` (eigene, noch nicht katalogisierte Handelsnamen aus `Treatment.product` ohne Beachtung der Groß-/Kleinschreibung, höchstens 50, mit den Werten der jüngsten Behandlung; legt nichts an), `GET/PATCH /veterinary-medicines/:id`, `POST /veterinary-medicines`, `POST …/:id/deactivate|reactivate` (ohne Änderung keine Schreibzeile und kein Audit).
|
||||
- `GET /veterinary-medicine-lots?medicineId=&selectable=true&includeLotId=` (wählbar: Rest > 0 und Mittel aktiv, älteste Haltbarkeit zuerst; `includeLotId` hält die Packung einer bearbeiteten Behandlung zusätzlich wählbar), `GET/PATCH /veterinary-medicine-lots/:id` (mit Bewegungsbuch), `POST /veterinary-medicine-lots`, `POST …/:id/movements` mit `{ type: 'CORRECTION', actualRemaining }` oder `{ type: 'DISPOSAL', quantity, reason }`.
|
||||
- Die Anlege-Endpunkte und `:id/movements` tragen `@Idempotent()`. Die Listen sind noch nicht paginiert; mit wachsendem Buch ist ein Limit zu ergänzen.
|
||||
|
||||
**Schreiben.** Mittel und Packungen lesen `before` nach der Zeilensperre und schreiben das Audit in derselben Transaktion; die Sperrfolge ist Mittel, dann Packung. Eine Inventur bucht die Differenz zwischen Buchrest und gezähltem Rest unter der Packungssperre (Delta 0 bucht nichts und schreibt kein Audit), zwei gleichzeitige Inventuren laufen nacheinander. Eine Entsorgung, die auf 0 rundet (unter 0,0005), ist 400. `name` und `reason` dürfen nicht nur aus Leerzeichen bestehen. `minTemperatureC <= maxTemperatureC` prüft der Dienst gegen den zusammengeführten Stand (400). Task 3 hält Dosen auf dem Tausendstelraster (eine Dosis unter 0,001 ist 400) und bucht die Packung nach dem Volk. Der Seed legt keine Mittel an; `seed-no-medicines.spec.ts` sichert das (nur die fünf bekannten Seed-Module, keine weitere `.ts` unter `prisma/`).
|
||||
|
||||
### 12.3 Dokumente mit Rechten je Art
|
||||
|
||||
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`).
|
||||
|
||||
### 12.4 Etikett-Pflichtangaben
|
||||
|
||||
`checkLabel` (`src/label-templates/label-particulars.ts`) ist rein und ohne Datenbank; Regeln und Grenzen: Annahmen 65 bis 68. **Pflichtangaben** (sperren den Druck, Code `LABEL_PARTICULARS_MISSING` mit `{ gaps }` folgt mit Task 6): `LEGAL_NAME` (ein Wort mit „honig“), `NET_QUANTITY` (Füllmenge der Abfüllung, „250,5 g“ wie „250.5 g“, „1 kg“ nur bei vollen Kilogramm; als ganze Wörter), `BEST_BEFORE` („Mindestens haltbar bis“ mit dem Datum), `LOT`, `ORIGIN` und `FOOD_BUSINESS` (Name und Anschrift). **Warnungen** (sperren nie): `LOT_PREFIX`, `X_HEIGHT` (unter `LabelTemplate.minXHeightMm`), `NET_QUANTITY_HEIGHT` (Ziffernhöhe unter der Regel aus `ComplianceSettings.netQuantityDigitRules`), `TEXT_CLIPPED`. Das Ergebnis trägt `requirements` (`minXHeightMm`, `netQuantityMinCapHeightMm`) und je Befund `elementIds` (bei Lücken die abgeschnittenen Elemente, die die Angabe getragen hätten). Ist eine Anschrift über mehrere Elemente verteilt, erkennt sie die Gesamtprüfung, ordnet sie aber keinem Element für `X_HEIGHT`/`TEXT_CLIPPED` zu.
|
||||
|
||||
**Höhen.** mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica: x-Höhe 523, Versalhöhe 718). Der Zugriff auf das private pdfkit-Feld steckt allein in `font-metrics.ts` (`fontMetricsOf`, je Schnitt gecacht, Fehler bei fehlenden Werten, durch einen Spec-Test gesichert); `pdfFontName` und `MM_TO_PT` wohnen dort. Beispiel: 6 pt ergibt 1,11 mm (Warnung), 7 pt 1,29 mm (in Ordnung). Die Rechtswerte sind Eingaben, die Beispielregeln stehen nur in den Tests.
|
||||
|
||||
**Stammdaten und Platzhalter.** `SiteSettings.foodBusinessName` (200) und `foodBusinessAddress` (300), `Product.legalName` (200) und `LabelTemplate.minXHeightMm` (0,5 bis 5) bleiben leer bzw. bei der Vorgabe, bis der Inhaber sie pflegt; leer ist eine Lücke, kein Standardwert. Neue Platzhalter: `legalName`, `originCountry`, `foodBusinessName`, `foodBusinessAddress`, `bestBeforeLine` („Mindestens haltbar bis TT.MM.JJJJ“), `lotLine` („L <Losnummer>“); die Liste liegt als `test/fixtures/placeholder-keys.json` vor (Wächter `placeholder-keys.spec.ts`, das Frontend vergleicht seine Liste damit). `buildPlaceholderValues(bottlingBatch, settings)` setzt unbesetzte Stammdaten auf `''`. Die öffentliche Herkunftsseite und der Shop-Feed zeigen weder Anschrift noch Verkehrsbezeichnung (e2e belegt, auch mit gesetztem `legalName`). `holderName`/`holderAddress` laufen über denselben `trimmedOrNull`-Helfer (`null` löscht). Task 6 ruft `labelDayOf(bottling.bestBeforeDate)` für `expected.bestBefore` und nutzt einen gemeinsamen Messer je Druck (`pdfTextMeasurer()`).
|
||||
|
||||
### 12.5 Ereignisse für Völker und Stände
|
||||
|
||||
Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Compliance` neu (`'Apiary'` gab es schon). Alle tragen nur Ids, Codes, Jahr und Tag, nie Adresse, Koordinaten oder Namen, und sind nicht in `DOMAIN_EVENT_WEBHOOKS`; der Testhelfer `herd-payload.spec-helper.ts` prüft die wirklich geschriebenen Payloads gegen eine Allow-Liste.
|
||||
- `ColonyCreated { apiaryId }` (Aggregat `Colony`): `ColoniesService.create` und für den Ableger in `split` (zusätzlich zu `ColonySplit`), jeweils in der Transaktion des Schreibens. Auch der CSV-Import schreibt es je angelegtem Volk in derselben Transaktion mit Datensatz und Audit (`Colony` CREATE), damit die Bestandsänderung die importierten Völker sieht (gemeinsamer Erzeuger `colonyCreatedEvent`, `src/colonies/colony-created-event.ts`). Der Import sendet weiterhin keinen Webhook `colony.created`; der Webhook bleibt im Altpfad (Annahme 11). Fehlerzeilen, Überspringen und Zähler des Imports sind unverändert. Eine Wiederherstellung aus dem Backup schreibt bewusst kein `ColonyCreated`.
|
||||
- `ColonyMoved { fromApiaryId, toApiaryId }` (Aggregat `Colony`): `move` und `update` mit geändertem Stand. Ein Umsetzen in denselben Stand schreibt Historie und Audit, aber kein Ereignis.
|
||||
- `ApiaryChanged { change }` (Aggregat `Apiary`), `change` = `CREATED` | `DEACTIVATED` | `REACTIVATED` | `LOCATION_CHANGED`. `LOCATION_CHANGED` nur, wenn Adresse, Ort, Breite oder Länge sich gegenüber dem gesperrten `before` ändern. Je Schreibvorgang ein Ereignis, ohne `dedupeKey`.
|
||||
- `TskReferenceDayReached { year, referenceDay }` (Aggregat `Compliance`, `referenceDay` als `JJJJ-MM-TT`, tagesbezogen über `dailyDedupeKey`): nur im Katalog; der Erzeuger ist die Tagesauswertung (Task 11).
|
||||
|
||||
`ApiariesService` schreibt Datensatz, Audit und Ereignis seit Task 7 in **einer** Transaktion; `update`, `deactivate` und `reactivate` sperren zuerst die Standzeile (`lockRowForUpdate`) und lesen `before` danach, `deactivate` zählt lebende Völker erst danach über `tx`; `reactivate` behält die Audit-Aktion `UPDATE`. Die Handler (`compliance.herd-change`, `compliance.tsk-reference-day`) liegen im künftigen `ComplianceModule` (von `AppModule` und `WorkerModule` importiert, ohne `PermissionsModule`, Task 11); die Erzeuger laufen nur in der API. Frontend: `DOMAIN_EVENT_LABELS` kennt die vier Typen („Volk angelegt“, „Volk umgesetzt“, „Stand geändert“, „TSK-Stichtag erreicht“); Task 14 trägt sie nicht noch einmal ein.
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { ApiariesController } from './apiaries.controller';
|
||||
import { ApiariesService } from './apiaries.service';
|
||||
import { ApiaryFieldService } from './apiary-field.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutopilotModule],
|
||||
imports: [AutopilotModule, DomainEventsModule],
|
||||
controllers: [ApiariesController],
|
||||
providers: [ApiariesService, ApiaryFieldService],
|
||||
exports: [ApiariesService],
|
||||
|
||||
@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { ApiariesService } from './apiaries.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||
|
||||
describe('ApiariesService', () => {
|
||||
let service: ApiariesService;
|
||||
let prisma: {
|
||||
apiary: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let order: string[];
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
|
||||
colony: { count: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
|
||||
const existingApiary = {
|
||||
id: 'apiary-1',
|
||||
name: 'Heimstand',
|
||||
status: 'ACTIVE',
|
||||
deactivatedAt: null,
|
||||
address: 'Weg 1',
|
||||
location: 'Garten',
|
||||
latitude: 48.1,
|
||||
longitude: 11.5,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
const changes = (): string[] =>
|
||||
(
|
||||
events.record.mock.calls as [unknown, { payload: { change: string } }][]
|
||||
).map(([, event]) => event.payload.change);
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
order = [];
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||
}),
|
||||
apiary: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(existingApiary);
|
||||
}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||
};
|
||||
prisma = {
|
||||
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ApiariesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
// Whatever a test recorded, the herd payloads carry only ids.
|
||||
afterEach(() => {
|
||||
expectOnlyIdPayloads(events.record.mock.calls);
|
||||
});
|
||||
|
||||
it('findById throws NotFoundException when missing', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('create persists the apiary and records an audit entry', async () => {
|
||||
prisma.apiary.create.mockResolvedValue(existingApiary);
|
||||
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
|
||||
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||
|
||||
const result = await service.create({ name: 'Heimstand' }, 'user-1');
|
||||
|
||||
expect(result).toEqual(existingApiary);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Apiary',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ApiaryChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: 'apiary-1',
|
||||
actorUserId: 'user-1',
|
||||
payload: { change: 'CREATED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a failing audit write rolls back the apiary change', async () => {
|
||||
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||
auditLog.record.mockRejectedValue(new Error('audit down'));
|
||||
|
||||
await expect(
|
||||
service.create({ name: 'Heimstand' }, 'user-1'),
|
||||
).rejects.toThrow('audit down');
|
||||
// Record, audit and event share one transaction callback: its rejection
|
||||
// is what rolls the record back.
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('update reads before after locking the Apiary row', async () => {
|
||||
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
|
||||
|
||||
expect(order).toEqual(['lock', 'read']);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
beforeData: existingApiary,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.apiary.update.mockResolvedValue({
|
||||
it('update of a missing apiary answers 404', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ address: 'Neuer Weg 2' }],
|
||||
[{ location: 'Wiese' }],
|
||||
[{ latitude: 50 }],
|
||||
[{ longitude: 12 }],
|
||||
])('update records LOCATION_CHANGED for %j', async (dto) => {
|
||||
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
|
||||
|
||||
await service.update('apiary-1', dto, 'user-1');
|
||||
|
||||
expect(changes()).toEqual(['LOCATION_CHANGED']);
|
||||
});
|
||||
|
||||
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
|
||||
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
|
||||
await service.update(
|
||||
'apiary-1',
|
||||
{ name: 'Anders', notes: 'neu' },
|
||||
'user-1',
|
||||
);
|
||||
// The same value sent again is no change either.
|
||||
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
|
||||
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
|
||||
tx.apiary.update.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'INACTIVE',
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.deactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe('INACTIVE');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivate counts every living colony, weak and queenless ones included', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
await service.deactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(prisma.colony.count).toHaveBeenCalledWith({
|
||||
expect(order).toEqual(['lock', 'read']);
|
||||
expect(tx.colony.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
apiaryId: 'apiary-1',
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
},
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
||||
tx,
|
||||
);
|
||||
expect(changes()).toEqual(['DEACTIVATED']);
|
||||
});
|
||||
|
||||
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.colony.count.mockResolvedValue(2);
|
||||
tx.colony.count.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
service.deactivate('apiary-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.apiary.update).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate throws ConflictException when already active', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
await expect(
|
||||
service.reactivate('apiary-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'INACTIVE',
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
prisma.apiary.update.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'ACTIVE',
|
||||
deactivatedAt: null,
|
||||
});
|
||||
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
const result = await service.reactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(prisma.apiary.update).toHaveBeenCalledWith({
|
||||
expect(tx.apiary.update).toHaveBeenCalledWith({
|
||||
where: { id: 'apiary-1' },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
|
||||
tx,
|
||||
);
|
||||
expect(changes()).toEqual(['REACTIVATED']);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { DomainEventPayloads } from '../domain-events/domain-event-types';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
||||
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
|
||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||
|
||||
@@ -15,11 +18,22 @@ export interface ApiaryFilter {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
|
||||
|
||||
// The fields a change of which counts as a new location of the apiary.
|
||||
const LOCATION_FIELDS = [
|
||||
'address',
|
||||
'location',
|
||||
'latitude',
|
||||
'longitude',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ApiariesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
|
||||
@@ -43,15 +57,21 @@ export class ApiariesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
|
||||
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: apiary.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: apiary,
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const apiary = await tx.apiary.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: apiary.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -59,69 +79,120 @@ export class ApiariesService {
|
||||
dto: UpdateApiaryDto,
|
||||
actingUserId: string,
|
||||
): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
|
||||
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
|
||||
}
|
||||
return apiary;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||
// without this guard a colony can silently keep pointing at an
|
||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead.
|
||||
const activeColonyCount = await this.prisma.colony.count({
|
||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
throw new ConflictException(
|
||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||
// without this guard a colony can silently keep pointing at an
|
||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead. Counted under the
|
||||
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
|
||||
// into it that is still running finishes first.
|
||||
const activeColonyCount = await tx.colony.count({
|
||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
throw new ConflictException(
|
||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||
);
|
||||
}
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'DEACTIVATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'DEACTIVATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
if (before.status === 'ACTIVE') {
|
||||
throw new ConflictException('Apiary is already active');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
if (before.status === 'ACTIVE') {
|
||||
throw new ConflictException('Apiary is already active');
|
||||
}
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
}
|
||||
|
||||
// Locks the apiary row and reads it afterwards: the result - not an earlier
|
||||
// read - is "before" for the comparison, the audit and the event.
|
||||
private async lockApiary(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
): Promise<Apiary> {
|
||||
await lockRowForUpdate(tx, 'Apiary', id);
|
||||
const apiary = await tx.apiary.findUnique({ where: { id } });
|
||||
if (!apiary) {
|
||||
throw new NotFoundException('Apiary not found');
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
private recordChange(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
change: ApiaryChange,
|
||||
actorUserId: string,
|
||||
): Promise<void> {
|
||||
return this.events.record(tx, {
|
||||
type: 'ApiaryChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: apiaryId,
|
||||
actorUserId,
|
||||
payload: { change },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
import { AutopilotModule } from './autopilot/autopilot.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
@@ -132,6 +133,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
ErpExportModule,
|
||||
ApiKeysModule,
|
||||
SystemModule,
|
||||
VeterinaryMedicinesModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -608,6 +608,7 @@ export class BackupService {
|
||||
}
|
||||
imported.queen = rowsOf('queen').length;
|
||||
|
||||
// Restore deliberately emits no ColonyCreated (no domain events).
|
||||
for (const row of rowsOf('colony')) {
|
||||
await tx.colony.upsert({
|
||||
where: { id: row.id as string },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -33,11 +34,6 @@ export interface BottlingBatchFilter {
|
||||
honeyBatchId?: string;
|
||||
}
|
||||
|
||||
/** Amounts are kept in kilograms with gram precision. */
|
||||
function roundKg(amount: number): number {
|
||||
return Math.round(amount * 1000) / 1000;
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
const REMOVED_NOTE = 'Abfüllung entfernt';
|
||||
@@ -212,7 +208,9 @@ export class BottlingService {
|
||||
? await this.assertBottledProduct(dto.productId)
|
||||
: null;
|
||||
const resolved = resolveJarAndBestBefore(dto, article);
|
||||
const totalAmount = roundKg((resolved.jarSize * dto.jarCount) / 1000);
|
||||
const totalAmount = roundToThousandths(
|
||||
(resolved.jarSize * dto.jarCount) / 1000,
|
||||
);
|
||||
|
||||
const lotNumber =
|
||||
dto.lotNumber ??
|
||||
@@ -352,7 +350,7 @@ export class BottlingService {
|
||||
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||
const jarSize = dto.jarSize ?? before.jarSize;
|
||||
const jarCount = dto.jarCount ?? before.jarCount;
|
||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||
const totalAmount = roundToThousandths((jarSize * jarCount) / 1000);
|
||||
|
||||
if (movedToOtherHoneyBatch) {
|
||||
// Both batches in one statement in ascending id order, so two
|
||||
@@ -392,7 +390,7 @@ export class BottlingService {
|
||||
await this.book(
|
||||
tx,
|
||||
honeyBatchId,
|
||||
roundKg(before.totalAmount - totalAmount),
|
||||
roundToThousandths(before.totalAmount - totalAmount),
|
||||
actingUserId,
|
||||
{ sourceBottlingBatchId: id },
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||
|
||||
describe('ColoniesService', () => {
|
||||
let service: ColoniesService;
|
||||
@@ -194,9 +195,12 @@ describe('ColoniesService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[, event]] = events.record.mock.calls as [
|
||||
[unknown, { payload: { queenSource: unknown; splitOn: string } }],
|
||||
];
|
||||
const event = (
|
||||
events.record.mock.calls as [
|
||||
unknown,
|
||||
{ type: string; payload: { queenSource: unknown; splitOn: string } },
|
||||
][]
|
||||
).find(([, recorded]) => recorded.type === 'ColonySplit')![1];
|
||||
expect(event.payload.queenSource).toBeNull();
|
||||
expect(event.payload.splitOn).toBe(berlinToday());
|
||||
});
|
||||
@@ -514,6 +518,110 @@ describe('ColoniesService', () => {
|
||||
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('herd events', () => {
|
||||
// Whatever a test recorded, the herd payloads carry only ids.
|
||||
afterEach(() => {
|
||||
expectOnlyIdPayloads(events.record.mock.calls);
|
||||
});
|
||||
|
||||
function recorded(type: string): unknown[] {
|
||||
return (events.record.mock.calls as [unknown, { type: string }][]).filter(
|
||||
([, event]) => event.type === type,
|
||||
);
|
||||
}
|
||||
|
||||
it('create records ColonyCreated with the apiary inside the transaction', async () => {
|
||||
tx.colony.create.mockResolvedValue({
|
||||
id: 'colony-9',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
|
||||
await service.create(
|
||||
{ code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-9',
|
||||
actorUserId: 'user-1',
|
||||
payload: { apiaryId: 'apiary-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('split records ColonyCreated for the child next to ColonySplit', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.create.mockResolvedValue({
|
||||
id: 'colony-2',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
|
||||
|
||||
await service.split(
|
||||
'colony-1',
|
||||
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const types = (
|
||||
events.record.mock.calls as [unknown, { type: string }][]
|
||||
).map(([, event]) => event.type);
|
||||
expect(types).toEqual(
|
||||
expect.arrayContaining(['ColonySplit', 'ColonyCreated']),
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-2',
|
||||
actorUserId: 'user-1',
|
||||
payload: { apiaryId: 'apiary-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('move records ColonyMoved with from and to', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-2',
|
||||
});
|
||||
|
||||
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyMoved',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-1',
|
||||
actorUserId: 'user-1',
|
||||
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('update with a changed apiary records ColonyMoved and without one records none', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-2',
|
||||
});
|
||||
|
||||
await service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
expect(recorded('ColonyMoved')).toHaveLength(1);
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
type: 'ColonyMoved',
|
||||
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
|
||||
}),
|
||||
);
|
||||
|
||||
events.record.mockClear();
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
await service.update('colony-1', { notes: 'x' }, 'user-1');
|
||||
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
|
||||
expect(recorded('ColonyMoved')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
import { colonyCreatedEvent } from './colony-created-event';
|
||||
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
|
||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||
@@ -23,6 +25,21 @@ import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { colonyTransitionEvents, lockColony } from './colony-events';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
|
||||
function colonyMovedEvent(
|
||||
colonyId: string,
|
||||
fromApiaryId: string,
|
||||
toApiaryId: string,
|
||||
actorUserId: string,
|
||||
): AnyNewDomainEvent<'ColonyMoved'> {
|
||||
return {
|
||||
type: 'ColonyMoved',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
actorUserId,
|
||||
payload: { fromApiaryId, toApiaryId },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ColonyFilter {
|
||||
status?: ColonyStatus;
|
||||
apiaryId?: string;
|
||||
@@ -96,10 +113,10 @@ export class ColoniesService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||
return created;
|
||||
});
|
||||
// No domain event for a new colony in this stage - the webhook stays on
|
||||
// the direct path.
|
||||
// The webhook stays on the direct path (the event is not forwarded).
|
||||
await this.webhooks.dispatch('colony.created', colony);
|
||||
return colony;
|
||||
}
|
||||
@@ -159,6 +176,10 @@ export class ColoniesService {
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
await this.events.record(
|
||||
tx,
|
||||
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
|
||||
);
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
@@ -204,6 +225,12 @@ export class ColoniesService {
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
if (before.apiaryId !== updated.apiaryId) {
|
||||
await this.events.record(
|
||||
tx,
|
||||
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
|
||||
);
|
||||
}
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
@@ -263,6 +290,7 @@ export class ColoniesService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||
await this.events.record(tx, {
|
||||
type: 'ColonySplit',
|
||||
aggregateType: 'Colony',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Colony } from '../../generated/prisma';
|
||||
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
|
||||
/**
|
||||
* The ColonyCreated event of a colony that was just written (create, split
|
||||
* child, CSV import). The caller records it in the transaction that wrote the
|
||||
* colony. Backup restore deliberately records none.
|
||||
*/
|
||||
export function colonyCreatedEvent(
|
||||
colony: Pick<Colony, 'id' | 'apiaryId'>,
|
||||
actorUserId: string,
|
||||
): AnyNewDomainEvent<'ColonyCreated'> {
|
||||
return {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colony.id,
|
||||
actorUserId,
|
||||
payload: { apiaryId: colony.apiaryId },
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,18 @@ describe('legacy after-commit paths', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('apiaries.service.ts has no after-commit side effects', () => {
|
||||
for (const call of [
|
||||
'runAfterCommit(',
|
||||
'webhooks.dispatch(',
|
||||
'calendarSync.enqueue(',
|
||||
]) {
|
||||
expect(filesCalling(call)).not.toContain(
|
||||
'src/apiaries/apiaries.service.ts',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => {
|
||||
const removed = [
|
||||
'evaluateInspection',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { roundToThousandths } from './quantity';
|
||||
|
||||
describe('roundToThousandths', () => {
|
||||
it('rounds to thousandths', () => {
|
||||
expect(roundToThousandths(0.0004)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps 1.2345 as 1.235 (half up)', () => {
|
||||
expect(roundToThousandths(1.2345)).toBe(1.235);
|
||||
});
|
||||
|
||||
it('rounds ties towards +Infinity and never returns -0', () => {
|
||||
expect(roundToThousandths(-0.0005)).toBe(0);
|
||||
expect(Object.is(roundToThousandths(-0.0005), 0)).toBe(true);
|
||||
expect(Object.is(roundToThousandths(-0.0004), 0)).toBe(true);
|
||||
expect(roundToThousandths(1.2345)).toBe(1.235);
|
||||
expect(roundToThousandths(-1.2345)).toBe(-1.234);
|
||||
});
|
||||
|
||||
it('cleans float noise of a sum', () => {
|
||||
expect(roundToThousandths(0.1 + 0.2)).toBe(0.3);
|
||||
});
|
||||
|
||||
it('keeps negative values', () => {
|
||||
expect(roundToThousandths(-3.3333)).toBe(-3.333);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Quantities (kilograms of honey, millilitres of a medicine, ...) are kept on
|
||||
* a grid of thousandths, so sums of floats compare and print cleanly.
|
||||
*/
|
||||
export function roundToThousandths(value: number): number {
|
||||
// `+ 0` turns -0 (a tiny negative value) into 0.
|
||||
return Math.round(value * 1000) / 1000 + 0;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export class DocumentsController {
|
||||
findAll(
|
||||
@Query('entityType') entityType: string,
|
||||
@Query('entityId') entityId: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
if (!entityType || !entityId) {
|
||||
throw new BadRequestException('entityType and entityId are required');
|
||||
@@ -57,6 +58,7 @@ export class DocumentsController {
|
||||
return this.documentsService.findAll(
|
||||
entityType as DocumentEntityType,
|
||||
entityId,
|
||||
actor.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,9 +99,15 @@ export class DocumentsController {
|
||||
|
||||
@RequirePermission('documents:read')
|
||||
@Get(':id/download')
|
||||
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
|
||||
const { buffer, fileName, mimeType } =
|
||||
await this.documentsService.readFile(id);
|
||||
async download(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { buffer, fileName, mimeType } = await this.documentsService.readFile(
|
||||
id,
|
||||
actor.id,
|
||||
);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { DocumentsService } from './documents.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
|
||||
@@ -21,7 +27,11 @@ describe('DocumentsService', () => {
|
||||
queen: { findUnique: jest.Mock };
|
||||
inspection: { findUnique: jest.Mock };
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
veterinaryMedicineLot: { findUnique: jest.Mock };
|
||||
authorityNotice: { findUnique: jest.Mock };
|
||||
apiaryRestriction: { findUnique: jest.Mock };
|
||||
};
|
||||
let permissions: { getPermissions: jest.Mock };
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const document = {
|
||||
@@ -50,11 +60,18 @@ describe('DocumentsService', () => {
|
||||
queen: { findUnique: jest.fn() },
|
||||
inspection: { findUnique: jest.fn() },
|
||||
honeyBatch: { findUnique: jest.fn() },
|
||||
veterinaryMedicineLot: { findUnique: jest.fn() },
|
||||
authorityNotice: { findUnique: jest.fn() },
|
||||
apiaryRestriction: { findUnique: jest.fn() },
|
||||
};
|
||||
permissions = {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set<string>()),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new DocumentsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissions as unknown as PermissionResolverService,
|
||||
);
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
@@ -64,7 +81,7 @@ describe('DocumentsService', () => {
|
||||
|
||||
it('findAll strips storagePath from results', async () => {
|
||||
prisma.document.findMany.mockResolvedValueOnce([document]);
|
||||
const result = await service.findAll('COLONY', 'colony-1');
|
||||
const result = await service.findAll('COLONY', 'colony-1', 'user-1');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'doc-1',
|
||||
@@ -143,14 +160,14 @@ describe('DocumentsService', () => {
|
||||
describe('readFile', () => {
|
||||
it('throws NotFoundException when the document does not exist', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(null);
|
||||
await expect(service.readFile('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(
|
||||
service.readFile('missing', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('reads the stored file from disk', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(document);
|
||||
const result = await service.readFile('doc-1');
|
||||
const result = await service.readFile('doc-1', 'user-1');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
|
||||
expect(result.fileName).toBe('bild.jpg');
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
@@ -178,6 +195,21 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete file cleanup', () => {
|
||||
it('still deletes the row and warns when the file cannot be removed', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(document);
|
||||
(fs.unlink as jest.Mock).mockRejectedValueOnce(new Error('EPERM'));
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await expect(service.delete('doc-1', 'user-1')).resolves.toBeUndefined();
|
||||
expect(prisma.document.delete).toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('doc-1'));
|
||||
expect(auditLog.record).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const existing = {
|
||||
id: 'doc-1',
|
||||
@@ -236,4 +268,178 @@ describe('DocumentsService', () => {
|
||||
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-type permissions', () => {
|
||||
beforeEach(() => {
|
||||
// the fs module mock is shared between tests; start each with no calls
|
||||
jest.mocked(fs.writeFile).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
jest.mocked(fs.unlink).mockClear();
|
||||
});
|
||||
|
||||
const grant = (...keys: string[]) =>
|
||||
permissions.getPermissions.mockResolvedValue(new Set(keys));
|
||||
const lotDocument = {
|
||||
...document,
|
||||
entityType: 'MEDICINE_LOT',
|
||||
entityId: 'lot-1',
|
||||
};
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from('x'),
|
||||
]);
|
||||
const pngFile = {
|
||||
buffer: png,
|
||||
originalname: 'b.png',
|
||||
mimetype: 'image/png',
|
||||
size: png.length,
|
||||
} as Express.Multer.File;
|
||||
|
||||
it('assertEntityExists finds a medicine lot, an authority notice and an apiary restriction and 404s a missing one', async () => {
|
||||
grant(
|
||||
'documents:upload',
|
||||
'veterinary-medicines:update',
|
||||
'compliance:manage',
|
||||
);
|
||||
prisma.document.create.mockResolvedValue(document);
|
||||
prisma.veterinaryMedicineLot.findUnique.mockResolvedValueOnce({
|
||||
id: 'l',
|
||||
});
|
||||
prisma.authorityNotice.findUnique.mockResolvedValueOnce({ id: 'n' });
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValueOnce({ id: 'r' });
|
||||
|
||||
await service.create(pngFile, 'MEDICINE_LOT', 'l', 'user-1');
|
||||
await service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1');
|
||||
await service.create(pngFile, 'APIARY_RESTRICTION', 'r', 'user-1');
|
||||
expect(prisma.veterinaryMedicineLot.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'l' },
|
||||
});
|
||||
expect(prisma.authorityNotice.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'n' },
|
||||
});
|
||||
expect(prisma.apiaryRestriction.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'r' },
|
||||
});
|
||||
|
||||
for (const type of [
|
||||
'MEDICINE_LOT',
|
||||
'AUTHORITY_NOTICE',
|
||||
'APIARY_RESTRICTION',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.create(pngFile, type, 'missing', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
}
|
||||
});
|
||||
|
||||
it('assertEntityAccess is a no-op for the old entity types', async () => {
|
||||
for (const type of [
|
||||
'APIARY',
|
||||
'COLONY',
|
||||
'QUEEN',
|
||||
'INSPECTION',
|
||||
'HONEY_BATCH',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.assertEntityAccess('user-1', type, 'read'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.assertEntityAccess('user-1', type, 'write'),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
expect(permissions.getPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assertEntityAccess maps each new type to its read and write right', async () => {
|
||||
grant('veterinary-medicines:read', 'compliance:read');
|
||||
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'read');
|
||||
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'read');
|
||||
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'read');
|
||||
for (const type of [
|
||||
'MEDICINE_LOT',
|
||||
'AUTHORITY_NOTICE',
|
||||
'APIARY_RESTRICTION',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.assertEntityAccess('u', type, 'write'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
}
|
||||
grant('veterinary-medicines:update', 'compliance:manage');
|
||||
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'write');
|
||||
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'write');
|
||||
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'write');
|
||||
grant();
|
||||
await expect(
|
||||
service.assertEntityAccess('u', 'MEDICINE_LOT', 'read'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('findAll denies a restricted type before any database query', async () => {
|
||||
grant('documents:read');
|
||||
await expect(
|
||||
service.findAll('MEDICINE_LOT', 'lot-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.document.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create denies a restricted type before any check or write', async () => {
|
||||
grant('documents:upload');
|
||||
await expect(
|
||||
service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.authorityNotice.findUnique).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('readFile denies before reading the file from disk', async () => {
|
||||
grant('documents:read');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(service.readFile('doc-1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delete denies before touching the row or the file', async () => {
|
||||
grant('documents:delete', 'veterinary-medicines:read');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(service.delete('doc-1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(prisma.document.delete).not.toHaveBeenCalled();
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update checks the permission of the old and of the new entity type', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValue({ id: 'r' });
|
||||
prisma.document.update.mockResolvedValue(document);
|
||||
const relink = {
|
||||
entityType: 'APIARY_RESTRICTION' as const,
|
||||
entityId: 'r',
|
||||
};
|
||||
|
||||
grant('veterinary-medicines:update'); // old allowed, new not
|
||||
await expect(
|
||||
service.update('doc-1', relink, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
grant('compliance:manage'); // new allowed, old not
|
||||
await expect(
|
||||
service.update('doc-1', relink, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.document.update).not.toHaveBeenCalled();
|
||||
|
||||
grant('veterinary-medicines:update', 'compliance:manage');
|
||||
await service.update('doc-1', relink, 'user-1');
|
||||
expect(prisma.document.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update of a restricted document without the right is denied even for a rename', async () => {
|
||||
grant('documents:update');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(
|
||||
service.update('doc-1', { fileName: 'x.pdf' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
@@ -9,12 +11,32 @@ import * as path from 'node:path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { Document, DocumentEntityType } from '../../generated/prisma';
|
||||
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
||||
import { UpdateDocumentDto } from './dto/update-document.dto';
|
||||
|
||||
export type DocumentSummary = Omit<Document, 'storagePath'>;
|
||||
|
||||
/**
|
||||
* Documents of these entity types are specialist records (purchase receipts,
|
||||
* authority notices, restriction orders): on top of the generic documents:*
|
||||
* right the caller needs the right of the record they belong to. Types
|
||||
* without an entry have no additional requirement.
|
||||
*/
|
||||
export const DOCUMENT_ENTITY_PERMISSIONS: Partial<
|
||||
Record<DocumentEntityType, { read: PermissionKey; write: PermissionKey }>
|
||||
> = {
|
||||
MEDICINE_LOT: {
|
||||
read: 'veterinary-medicines:read',
|
||||
write: 'veterinary-medicines:update',
|
||||
},
|
||||
AUTHORITY_NOTICE: { read: 'compliance:read', write: 'compliance:manage' },
|
||||
APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' },
|
||||
};
|
||||
|
||||
function storageRoot(): string {
|
||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||
}
|
||||
@@ -34,15 +56,36 @@ function toSummary(document: Document): DocumentSummary {
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
private readonly logger = new Logger(DocumentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
/** 403 unless the user holds the type's specialist right (see map above). */
|
||||
async assertEntityAccess(
|
||||
userId: string,
|
||||
entityType: DocumentEntityType,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<void> {
|
||||
const required = DOCUMENT_ENTITY_PERMISSIONS[entityType]?.[mode];
|
||||
if (!required) {
|
||||
return;
|
||||
}
|
||||
const granted = await this.permissions.getPermissions(userId);
|
||||
if (!granted.has(required)) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(
|
||||
entityType: DocumentEntityType,
|
||||
entityId: string,
|
||||
actingUserId: string,
|
||||
): Promise<DocumentSummary[]> {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'read');
|
||||
const documents = await this.prisma.document.findMany({
|
||||
where: { entityType, entityId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -70,6 +113,18 @@ export class DocumentsService {
|
||||
return this.prisma.honeyBatch.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'MEDICINE_LOT':
|
||||
return this.prisma.veterinaryMedicineLot.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'AUTHORITY_NOTICE':
|
||||
return this.prisma.authorityNotice.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'APIARY_RESTRICTION':
|
||||
return this.prisma.apiaryRestriction.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -85,6 +140,7 @@ export class DocumentsService {
|
||||
entityId: string,
|
||||
actingUserId: string,
|
||||
): Promise<DocumentSummary> {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'write');
|
||||
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||
}
|
||||
@@ -125,6 +181,7 @@ export class DocumentsService {
|
||||
|
||||
async readFile(
|
||||
id: string,
|
||||
actingUserId: string,
|
||||
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
|
||||
const document = await this.prisma.document.findUnique({
|
||||
where: { id },
|
||||
@@ -132,6 +189,7 @@ export class DocumentsService {
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, document.entityType, 'read');
|
||||
const buffer = await fs.readFile(document.storagePath);
|
||||
return {
|
||||
buffer,
|
||||
@@ -154,8 +212,12 @@ export class DocumentsService {
|
||||
if (!before) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, before.entityType, 'write');
|
||||
const entityType = dto.entityType ?? before.entityType;
|
||||
const entityId = dto.entityId ?? before.entityId;
|
||||
if (entityType !== before.entityType) {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'write');
|
||||
}
|
||||
if (entityType !== before.entityType || entityId !== before.entityId) {
|
||||
await this.assertEntityExists(entityType, entityId);
|
||||
}
|
||||
@@ -181,8 +243,14 @@ export class DocumentsService {
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, document.entityType, 'write');
|
||||
await this.prisma.document.delete({ where: { id } });
|
||||
await fs.unlink(document.storagePath).catch(() => undefined);
|
||||
await fs.unlink(document.storagePath).catch((error: unknown) => {
|
||||
// The row is gone; an orphaned (possibly restricted) file must be visible.
|
||||
this.logger.warn(
|
||||
`Could not remove the file of document ${id}: ${loggableError(error).message}`,
|
||||
);
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Document',
|
||||
entityId: id,
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('DomainEventRegistry', () => {
|
||||
|
||||
it('rejects an event type outside the catalog', () => {
|
||||
class UnknownType {
|
||||
@OnDomainEvent('ColonyCreated' as DomainEventType, 'colonies.welcome')
|
||||
@OnDomainEvent('ColonyWelcomed' as DomainEventType, 'colonies.welcome')
|
||||
handle(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -129,6 +129,21 @@ export interface DomainEventPayloads {
|
||||
previousSegment: SeasonSegment | null;
|
||||
source: PhaseSource | null;
|
||||
};
|
||||
// T6, aggregate Colony: a colony came into being (create, or the child of a
|
||||
// split). Ids only; not forwarded to webhooks.
|
||||
ColonyCreated: { apiaryId: string };
|
||||
// T6, aggregate Colony: a colony changed its apiary (move, or a correction
|
||||
// through update). Not forwarded to webhooks.
|
||||
ColonyMoved: { fromApiaryId: string; toApiaryId: string };
|
||||
// T6, aggregate Apiary: the herd-relevant master data of an apiary changed.
|
||||
// The code only, never the address or coordinates. Not forwarded to webhooks.
|
||||
ApiaryChanged: {
|
||||
change: 'CREATED' | 'DEACTIVATED' | 'REACTIVATED' | 'LOCATION_CHANGED';
|
||||
};
|
||||
// T6, aggregate Compliance; time-based (dedupeKey per day): the TSK
|
||||
// reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to
|
||||
// webhooks.
|
||||
TskReferenceDayReached: { year: number; referenceDay: string };
|
||||
}
|
||||
|
||||
export type DomainEventType = keyof DomainEventPayloads;
|
||||
@@ -169,6 +184,10 @@ const CATALOG: Record<DomainEventType, true> = {
|
||||
PhenologyRecomputeRequested: true,
|
||||
SeasonPhaseChanged: true,
|
||||
WinterCheckRecorded: true,
|
||||
ColonyCreated: true,
|
||||
ColonyMoved: true,
|
||||
ApiaryChanged: true,
|
||||
TskReferenceDayReached: true,
|
||||
};
|
||||
|
||||
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
||||
@@ -192,7 +211,8 @@ export type AggregateType =
|
||||
| 'HoneyBatch'
|
||||
| 'PhenologyReport'
|
||||
| 'WinterCheck'
|
||||
| 'Apiary';
|
||||
| 'Apiary'
|
||||
| 'Compliance';
|
||||
|
||||
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
|
||||
type: T;
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
|
||||
|
||||
describe('domain event catalog', () => {
|
||||
it('lists every event type exactly once', () => {
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(34);
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(38);
|
||||
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
|
||||
expect(DOMAIN_EVENT_TYPES).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -202,6 +202,10 @@ describe('domain event catalog', () => {
|
||||
'PhenologyRecomputeRequested',
|
||||
'SeasonPhaseChanged',
|
||||
'WinterCheckRecorded',
|
||||
'ColonyCreated',
|
||||
'ColonyMoved',
|
||||
'ApiaryChanged',
|
||||
'TskReferenceDayReached',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Allow-list of payload keys of the T6 herd events: ids, a change code, a year
|
||||
// and a day - never a name, address, coordinate or note.
|
||||
export const HERD_PAYLOAD_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
ColonyCreated: ['apiaryId'],
|
||||
ColonyMoved: ['fromApiaryId', 'toApiaryId'],
|
||||
ApiaryChanged: ['change'],
|
||||
TskReferenceDayReached: ['year', 'referenceDay'],
|
||||
};
|
||||
|
||||
const SAFE_VALUE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* Checks the herd events among the `events.record` mock calls of a spec
|
||||
* (`[tx, event]` pairs) against the allow-list: no extra key, only id-shaped
|
||||
* strings or integers as values. Returns how many herd events it checked.
|
||||
*/
|
||||
export function expectOnlyIdPayloads(
|
||||
calls: readonly (readonly unknown[])[],
|
||||
): number {
|
||||
let checked = 0;
|
||||
for (const [, event] of calls as [unknown, unknown][]) {
|
||||
const { type, payload } = event as {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
const allowed = HERD_PAYLOAD_KEYS[type];
|
||||
if (allowed === undefined) {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
expect(Object.keys(payload).sort()).toEqual([...allowed].sort());
|
||||
for (const value of Object.values(payload)) {
|
||||
if (typeof value === 'number') {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
} else {
|
||||
expect(typeof value === 'string' && SAFE_VALUE.test(value)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
import { formatNumberDe } from '../suggestions/texts/format-number';
|
||||
import {
|
||||
WARNING_SEVERITY,
|
||||
@@ -25,9 +26,7 @@ export function sharedHoneyType(
|
||||
}
|
||||
|
||||
/** kg on a gram grid, so sums of floats compare and print cleanly. */
|
||||
export function roundKg(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
export const roundKg = roundToThousandths;
|
||||
|
||||
/**
|
||||
* The extraction (Schleuderung) against the net weight of its harvests
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { ImportService } from './import.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
|
||||
describe('ImportService', () => {
|
||||
let service: ImportService;
|
||||
@@ -9,19 +10,31 @@ describe('ImportService', () => {
|
||||
apiary: { create: jest.Mock; findFirst: jest.Mock };
|
||||
colony: { create: jest.Mock; findUnique: jest.Mock };
|
||||
queen: { create: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: { colony: { create: jest.Mock } };
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
apiary: { create: jest.fn(), findFirst: jest.fn() },
|
||||
colony: { create: jest.fn(), findUnique: jest.fn() },
|
||||
queen: { create: jest.fn(), findUnique: jest.fn() },
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
// The colony row is written through the transaction client; it shares the
|
||||
// mock with prisma.colony so the row tests read the same calls.
|
||||
tx = { colony: { create: prisma.colony.create } };
|
||||
prisma.$transaction.mockImplementation(
|
||||
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
||||
);
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ImportService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,6 +208,56 @@ describe('ImportService', () => {
|
||||
expect(createArg.data).toMatchObject({ strength: 6, occupiedFrames: 14 });
|
||||
});
|
||||
|
||||
it('records audit and ColonyCreated in the transaction of every created colony', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
prisma.colony.create
|
||||
.mockResolvedValueOnce({ id: 'colony-1', apiaryId: 'apiary-1' })
|
||||
.mockResolvedValueOnce({ id: 'colony-2', apiaryId: 'apiary-1' });
|
||||
|
||||
const report = await service.importCsv(
|
||||
'colonies',
|
||||
Buffer.from(
|
||||
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,PRODUCTION_COLONY',
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(report.created).toBe(2);
|
||||
expect(events.record).toHaveBeenCalledTimes(2);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-2',
|
||||
actorUserId: 'user-1',
|
||||
payload: { apiaryId: 'apiary-1' },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Colony',
|
||||
entityId: 'colony-1',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('records no event for a skipped or invalid row', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
|
||||
|
||||
await service.importCsv(
|
||||
'colonies',
|
||||
Buffer.from(
|
||||
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,NOT_A_TYPE',
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a validation error for an invalid colonyType', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { plainToInstance } from 'class-transformer';
|
||||
import { validate, ValidationError } from 'class-validator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { colonyCreatedEvent } from '../colonies/colony-created-event';
|
||||
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
|
||||
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
|
||||
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
|
||||
@@ -56,6 +58,7 @@ export class ImportService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async importCsv(
|
||||
@@ -80,7 +83,9 @@ export class ImportService {
|
||||
record[column] = (dataRows[i][index] ?? '').trim();
|
||||
});
|
||||
const rowNumber = i + 1;
|
||||
results.push(await this.importRow(entity, record, rowNumber));
|
||||
results.push(
|
||||
await this.importRow(entity, record, rowNumber, actingUserId),
|
||||
);
|
||||
}
|
||||
|
||||
const report: ImportReport = {
|
||||
@@ -110,12 +115,13 @@ export class ImportService {
|
||||
entity: ImportEntity,
|
||||
record: Record<string, string>,
|
||||
rowNumber: number,
|
||||
actingUserId: string,
|
||||
): Promise<ImportRowResult> {
|
||||
switch (entity) {
|
||||
case 'apiaries':
|
||||
return this.importApiaryRow(record, rowNumber);
|
||||
case 'colonies':
|
||||
return this.importColonyRow(record, rowNumber);
|
||||
return this.importColonyRow(record, rowNumber, actingUserId);
|
||||
case 'queens':
|
||||
return this.importQueenRow(record, rowNumber);
|
||||
}
|
||||
@@ -150,6 +156,7 @@ export class ImportService {
|
||||
private async importColonyRow(
|
||||
record: Record<string, string>,
|
||||
rowNumber: number,
|
||||
actingUserId: string,
|
||||
): Promise<ImportRowResult> {
|
||||
const apiaryName = emptyToUndefined(record.apiaryName);
|
||||
if (!apiaryName) {
|
||||
@@ -209,7 +216,22 @@ export class ImportService {
|
||||
id: existing.id,
|
||||
};
|
||||
}
|
||||
const colony = await this.prisma.colony.create({ data: { ...dto } });
|
||||
// Like ColoniesService.create: record, audit and event in one transaction.
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.colony.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||
return created;
|
||||
});
|
||||
return {
|
||||
row: rowNumber,
|
||||
status: 'created',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -91,4 +92,14 @@ export class CreateLabelTemplateDto {
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Kleinste x-Höhe (mm) für Pflichtangaben; darunter warnt die Etikettenprüfung. Vorgabe 1,2.',
|
||||
})
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
@Max(5)
|
||||
minXHeightMm?: number;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fontMetricsOf, heightMm, pdfTextMeasurer } from './font-metrics';
|
||||
import { LabelTextElement } from './label-element.types';
|
||||
|
||||
describe('font metrics', () => {
|
||||
// Guards against a pdfkit update that changes or hides the AFM values.
|
||||
it('Helvetica metrics are x-height 523 and cap height 718', () => {
|
||||
expect(fontMetricsOf('sans', false, false)).toEqual({
|
||||
xHeight: 523,
|
||||
capHeight: 718,
|
||||
});
|
||||
});
|
||||
|
||||
it('Times and Courier return their own AFM values', () => {
|
||||
expect(fontMetricsOf('serif', false, false)).toEqual({
|
||||
xHeight: 450,
|
||||
capHeight: 662,
|
||||
});
|
||||
expect(fontMetricsOf('serif', true, true)).toEqual({
|
||||
xHeight: 462,
|
||||
capHeight: 669,
|
||||
});
|
||||
expect(fontMetricsOf('mono', false, false)).toEqual({
|
||||
xHeight: 426,
|
||||
capHeight: 562,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the style variants of a family', () => {
|
||||
expect(fontMetricsOf('sans', true, false).xHeight).toBe(532);
|
||||
});
|
||||
|
||||
it('converts a size and a metric to millimetres', () => {
|
||||
expect(heightMm(6, 523)).toBeCloseTo(1.107, 3);
|
||||
expect(heightMm(7, 523)).toBeCloseTo(1.2915, 3);
|
||||
expect(heightMm(72, 1000)).toBeCloseTo(25.4, 9);
|
||||
});
|
||||
|
||||
describe('pdfTextMeasurer', () => {
|
||||
const element: LabelTextElement = {
|
||||
id: 'e',
|
||||
type: 'text',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 20,
|
||||
height: 10,
|
||||
text: '',
|
||||
fontSize: 10,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
it('measures more lines for a narrower box', () => {
|
||||
const measurer = pdfTextMeasurer();
|
||||
const text = 'Mindestens haltbar bis 05.03.2028 und noch mehr Text';
|
||||
const wide = measurer.heightOfStringPt(text, { ...element, width: 120 });
|
||||
const narrow = measurer.heightOfStringPt(text, { ...element, width: 20 });
|
||||
expect(narrow).toBeGreaterThan(wide);
|
||||
});
|
||||
|
||||
it('measures nothing for an empty text', () => {
|
||||
expect(pdfTextMeasurer().heightOfStringPt('', element)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { LabelFontFamily, LabelTextElement } from './label-element.types';
|
||||
|
||||
export const MM_TO_PT = 72 / 25.4;
|
||||
|
||||
/** Glyph metrics in AFM units (1/1000 of the font size). */
|
||||
export interface FontMetrics {
|
||||
xHeight: number;
|
||||
capHeight: number;
|
||||
}
|
||||
|
||||
export interface TextMeasurer {
|
||||
/** Height in pt the element's text needs at the element's width and font. */
|
||||
heightOfStringPt(text: string, element: LabelTextElement): number;
|
||||
}
|
||||
|
||||
/** The pdfkit standard font a family/style combination prints with. */
|
||||
export function pdfFontName(
|
||||
family: LabelFontFamily,
|
||||
bold: boolean,
|
||||
italic: boolean,
|
||||
): string {
|
||||
const base =
|
||||
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
|
||||
if (base === 'Times') {
|
||||
if (bold && italic) return 'Times-BoldItalic';
|
||||
if (bold) return 'Times-Bold';
|
||||
if (italic) return 'Times-Italic';
|
||||
return 'Times-Roman';
|
||||
}
|
||||
const suffix =
|
||||
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
/** Height in mm of a glyph class: size in pt x metric / 1000 x 25.4 / 72. */
|
||||
export function heightMm(fontSizePt: number, metricPer1000: number): number {
|
||||
return ((fontSizePt * metricPer1000) / 1000) * (25.4 / 72);
|
||||
}
|
||||
|
||||
// pdfkit keeps the loaded font in the private field `_font`; this is the one
|
||||
// place that reads it. font-metrics.spec.ts pins the values, so a pdfkit
|
||||
// update that moves or renames the field fails there and nowhere else.
|
||||
interface PdfDocumentInternals {
|
||||
_font?: { xHeight?: unknown; capHeight?: unknown };
|
||||
}
|
||||
|
||||
const metricsCache = new Map<string, FontMetrics>();
|
||||
|
||||
/** AFM x-height and cap height of a built-in PDF font. */
|
||||
export function fontMetricsOf(
|
||||
family: LabelFontFamily,
|
||||
bold: boolean,
|
||||
italic: boolean,
|
||||
): FontMetrics {
|
||||
const name = pdfFontName(family, bold, italic);
|
||||
const cached = metricsCache.get(name);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
doc.font(name);
|
||||
const font = (doc as unknown as PdfDocumentInternals)._font;
|
||||
const { xHeight, capHeight } = font ?? {};
|
||||
if (
|
||||
typeof xHeight !== 'number' ||
|
||||
typeof capHeight !== 'number' ||
|
||||
!(xHeight > 0) ||
|
||||
!(capHeight > 0)
|
||||
) {
|
||||
throw new Error(`No AFM metrics for font ${name}`);
|
||||
}
|
||||
const metrics = { xHeight, capHeight };
|
||||
metricsCache.set(name, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/** Measures text with a throw-away PDF document; nothing is written out. */
|
||||
export function pdfTextMeasurer(): TextMeasurer {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
return {
|
||||
heightOfStringPt(text, element) {
|
||||
if (text === '') {
|
||||
return 0;
|
||||
}
|
||||
return doc
|
||||
.font(pdfFontName(element.fontFamily, element.bold, element.italic))
|
||||
.fontSize(element.fontSize)
|
||||
.heightOfString(text, { width: element.width * MM_TO_PT });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import { UpdateProductDto } from '../products/dto/update-product.dto';
|
||||
import { UpdateSiteSettingsDto } from '../site-settings/dto/update-site-settings.dto';
|
||||
import { describeNullContracts } from '../common/validation/null-contract.spec-helper';
|
||||
|
||||
// Only the fields T6 Task 5 adds; the older fields stay in
|
||||
// update-dto-null-handling.spec.ts.
|
||||
describeNullContracts('Label particulars null contracts', {
|
||||
UpdateSiteSettingsDto: {
|
||||
dto: UpdateSiteSettingsDto,
|
||||
notNull: [],
|
||||
clearable: [
|
||||
'foodBusinessName',
|
||||
'foodBusinessAddress',
|
||||
'holderName',
|
||||
'holderAddress',
|
||||
],
|
||||
},
|
||||
UpdateProductDto: {
|
||||
dto: UpdateProductDto,
|
||||
notNull: [],
|
||||
clearable: ['legalName'],
|
||||
},
|
||||
UpdateLabelTemplateDto: {
|
||||
dto: UpdateLabelTemplateDto,
|
||||
notNull: ['minXHeightMm'],
|
||||
clearable: [],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildPlaceholderValues, labelDayOf } from './label-templates.service';
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
};
|
||||
|
||||
function values(bestBeforeDate: Date, date = bestBeforeDate) {
|
||||
return buildPlaceholderValues(
|
||||
{
|
||||
jarSize: 500,
|
||||
lotNumber: '2026-0001',
|
||||
date,
|
||||
bestBeforeDate,
|
||||
honeyBatch: { batchNumber: 'HB-1', honeyType: null },
|
||||
product: null,
|
||||
},
|
||||
SETTINGS,
|
||||
);
|
||||
}
|
||||
|
||||
// The expected days are literal Berlin days (CET in winter, CEST in summer),
|
||||
// so the cases hold whatever zone the test process runs in.
|
||||
describe('label days', () => {
|
||||
it.each([
|
||||
['midnight UTC, winter', '2027-03-01T00:00:00.000Z', '2027-03-01'],
|
||||
['midnight UTC, summer', '2027-07-01T00:00:00.000Z', '2027-07-01'],
|
||||
['23:30 UTC, winter', '2027-03-01T23:30:00.000Z', '2027-03-02'],
|
||||
['22:30 UTC, summer', '2027-06-30T22:30:00.000Z', '2027-07-01'],
|
||||
['21:30 UTC, summer', '2027-06-30T21:30:00.000Z', '2027-06-30'],
|
||||
['22:30 UTC, winter', '2027-02-28T22:30:00.000Z', '2027-02-28'],
|
||||
])('%s', (_name, instant, day) => {
|
||||
expect(labelDayOf(new Date(instant))).toBe(day);
|
||||
});
|
||||
|
||||
it('prints the best before day and line from the same day', () => {
|
||||
const result = values(new Date('2027-03-01T23:30:00.000Z'));
|
||||
expect(result.bestBeforeDate).toBe('02.03.2027');
|
||||
expect(result.bestBeforeLine).toBe('Mindestens haltbar bis 02.03.2027');
|
||||
});
|
||||
|
||||
it('prints the bottling date the same way', () => {
|
||||
const result = values(
|
||||
new Date('2028-03-01T00:00:00.000Z'),
|
||||
new Date('2026-03-01T23:30:00.000Z'),
|
||||
);
|
||||
expect(result.bottlingDate).toBe('02.03.2026');
|
||||
});
|
||||
|
||||
it('prints and checks the same day string', () => {
|
||||
const bestBefore = new Date('2027-03-01T00:00:00.000Z');
|
||||
const [year, month, day] = labelDayOf(bestBefore).split('-');
|
||||
expect(values(bestBefore).bestBeforeDate).toBe(`${day}.${month}.${year}`);
|
||||
});
|
||||
});
|
||||
|
||||
// A jest worker cannot change its own time zone, so the guarantee is by
|
||||
// construction: the date code must not read or build local-time dates. UTC
|
||||
// getters (getUTCDate, ...) and formatters with an explicit timeZone are fine.
|
||||
const FORBIDDEN = new RegExp(
|
||||
[
|
||||
'\\.(getDate|getDay|getMonth|getFullYear|getYear|getHours|getMinutes|getSeconds|getMilliseconds|getTimezoneOffset)\\(',
|
||||
'\\.set(Hours|Date|Month|FullYear|Minutes|Seconds)\\(',
|
||||
'\\.toDateString\\(',
|
||||
'toLocaleTimeString\\(',
|
||||
'toLocaleDateString\\((?![^)]*timeZone)',
|
||||
'toLocaleString\\((?![^)]*timeZone)',
|
||||
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
||||
// new Date(y, m, d, ...): several arguments build a local-time date
|
||||
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
|
||||
].join('|'),
|
||||
);
|
||||
|
||||
describe('local-time guard', () => {
|
||||
it.each([
|
||||
'date.getDate()',
|
||||
'date.getHours()',
|
||||
'date.getTimezoneOffset()',
|
||||
'date.getYear()',
|
||||
'date.getMilliseconds()',
|
||||
'date.setHours(0)',
|
||||
'date.setDate(1)',
|
||||
'date.toDateString()',
|
||||
"date.toLocaleDateString('de-DE')",
|
||||
"new Intl.DateTimeFormat('de-DE')",
|
||||
'new Date(2027, 2, 1)',
|
||||
])('flags %s', (sample) => {
|
||||
expect(sample).toMatch(FORBIDDEN);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"date.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })",
|
||||
"new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' })",
|
||||
'date.getUTCDate()',
|
||||
'date.getTime()',
|
||||
'new Date(Date.UTC(2027, 2, 1))',
|
||||
'new Date(`${day}T00:00:00.000Z`)',
|
||||
])('allows %s', (sample) => {
|
||||
expect(sample).not.toMatch(FORBIDDEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date sources avoid local-time APIs', () => {
|
||||
it.each([
|
||||
['label-templates.service.ts', __dirname],
|
||||
['label-particulars.ts', __dirname],
|
||||
['label-text.ts', __dirname],
|
||||
['font-metrics.ts', __dirname],
|
||||
['dates.ts', join(__dirname, '..', 'common')],
|
||||
['format-day.ts', join(__dirname, '..', 'common')],
|
||||
])('%s', (file, dir) => {
|
||||
expect(readFileSync(join(dir, file), 'utf8')).not.toMatch(FORBIDDEN);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,12 @@ export const PLACEHOLDER_KEYS = [
|
||||
'bestBeforeDate',
|
||||
'jarSize',
|
||||
'productName',
|
||||
'legalName',
|
||||
'originCountry',
|
||||
'foodBusinessName',
|
||||
'foodBusinessAddress',
|
||||
'bestBeforeLine',
|
||||
'lotLine',
|
||||
] as const;
|
||||
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
import { heightMm, TextMeasurer } from './font-metrics';
|
||||
import {
|
||||
checkLabel,
|
||||
LabelCheckInput,
|
||||
LabelFindingCode,
|
||||
NetQuantityDigitRule,
|
||||
SAMPLE_BOTTLING,
|
||||
} from './label-particulars';
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
import { buildPlaceholderValues } from './label-templates.service';
|
||||
|
||||
// Mirrors the default of ComplianceSettings.netQuantityDigitRules.
|
||||
const RULES: NetQuantityDigitRule[] = [
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ maxGrams: 200, minMm: 3 },
|
||||
{ maxGrams: 1000, minMm: 4 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
];
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||
};
|
||||
|
||||
const NO_CLIPPING: TextMeasurer = { heightOfStringPt: () => 0 };
|
||||
|
||||
function bottling(overrides: { jarSize?: number } = {}) {
|
||||
return {
|
||||
jarSize: overrides.jarSize ?? 500,
|
||||
lotNumber: '2026-0001',
|
||||
date: new Date('2026-03-05T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-03-05T00:00:00.000Z'),
|
||||
honeyBatch: { batchNumber: 'HB-1', honeyType: 'Sommerhonig' },
|
||||
product: { name: 'Sommerhonig 500 g', legalName: 'Blütenhonig' },
|
||||
};
|
||||
}
|
||||
|
||||
function textElement(
|
||||
id: string,
|
||||
key: PlaceholderKey | null,
|
||||
y: number,
|
||||
overrides: Partial<LabelTextElement> = {},
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y,
|
||||
width: 80,
|
||||
height: 10,
|
||||
text: '',
|
||||
placeholderKey: key,
|
||||
fontSize: 10,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// 20 pt Helvetica caps are 5.07 mm, above every default rule up to 1000 g.
|
||||
function fullLabel(): LabelTextElement[] {
|
||||
return [
|
||||
textElement('legal', 'legalName', 2),
|
||||
textElement('net', 'jarSize', 14, { fontSize: 20 }),
|
||||
textElement('bb', 'bestBeforeLine', 26),
|
||||
textElement('lot', 'lotLine', 38),
|
||||
textElement('origin', 'originCountry', 50),
|
||||
textElement('biz-name', 'foodBusinessName', 62),
|
||||
textElement('biz-address', 'foodBusinessAddress', 74),
|
||||
];
|
||||
}
|
||||
|
||||
function input(overrides: Partial<LabelCheckInput> = {}): LabelCheckInput {
|
||||
const jarSize = 500;
|
||||
return {
|
||||
elements: fullLabel(),
|
||||
minXHeightMm: 1.2,
|
||||
digitRules: RULES,
|
||||
values: buildPlaceholderValues(bottling({ jarSize }), SETTINGS),
|
||||
expected: {
|
||||
jarSizeGrams: jarSize,
|
||||
bestBefore: '2028-03-05',
|
||||
lotNumber: '2026-0001',
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: SETTINGS.foodBusinessName,
|
||||
foodBusinessAddress: SETTINGS.foodBusinessAddress,
|
||||
},
|
||||
sample: false,
|
||||
measurer: NO_CLIPPING,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function codes(findings: { code: LabelFindingCode }[]): LabelFindingCode[] {
|
||||
return findings.map((finding) => finding.code);
|
||||
}
|
||||
|
||||
function without(...ids: string[]): LabelTextElement[] {
|
||||
return fullLabel().filter((element) => !ids.includes(element.id));
|
||||
}
|
||||
|
||||
describe('checkLabel', () => {
|
||||
it('passes a label with all particulars', () => {
|
||||
const result = checkLabel(input());
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.printable).toBe(true);
|
||||
expect(result.sample).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['LEGAL_NAME', 'legal'],
|
||||
['NET_QUANTITY', 'net'],
|
||||
['BEST_BEFORE', 'bb'],
|
||||
['LOT', 'lot'],
|
||||
['ORIGIN', 'origin'],
|
||||
['FOOD_BUSINESS', 'biz-name'],
|
||||
] as const)('reports a missing %s by its code', (code, elementId) => {
|
||||
const elements =
|
||||
code === 'FOOD_BUSINESS'
|
||||
? without('biz-name', 'biz-address')
|
||||
: without(elementId);
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(codes(result.gaps)).toEqual([code]);
|
||||
expect(result.printable).toBe(false);
|
||||
expect(result.gaps[0].title).not.toBe('');
|
||||
expect(result.gaps[0].detail).not.toBe('');
|
||||
});
|
||||
|
||||
it('matches the address across line breaks and separators', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz', null, 62, {
|
||||
text: 'Imkerei Muster\nBühlweg 10 · 86450 Altenmünster',
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches an address that sits on two lines of one element', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz', null, 62, {
|
||||
text: 'Imkerei Muster, Bühlweg 10\n86450 Altenmünster',
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a static "500 g" on a 250 g bottling', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('net', null, 14, { text: 'Inhalt 500 g', fontSize: 20 }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({
|
||||
elements,
|
||||
values: buildPlaceholderValues(bottling({ jarSize: 250 }), SETTINGS),
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
it('accepts 250,5 g for a 250.5 g jar and "1 kg" for a 1000 g jar', () => {
|
||||
const decimal = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250,5 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250.5 },
|
||||
}),
|
||||
);
|
||||
expect(codes(decimal.gaps)).toEqual([]);
|
||||
|
||||
const dot = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250.5 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250.5 },
|
||||
}),
|
||||
);
|
||||
expect(codes(dot.gaps)).toEqual([]);
|
||||
|
||||
const kilogram = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1 kg' },
|
||||
expected: { ...input().expected, jarSizeGrams: 1000 },
|
||||
}),
|
||||
);
|
||||
expect(codes(kilogram.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not accept 1250 g for 250 g', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1250 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
it('does not accept "1 kg" for a jar that is not a full kilogram', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1 kg' },
|
||||
expected: { ...input().expected, jarSizeGrams: 1250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
describe('best before', () => {
|
||||
function withBestBefore(text: string): LabelTextElement[] {
|
||||
return [...without('bb'), textElement('bb', null, 26, { text })];
|
||||
}
|
||||
|
||||
it('needs the wording and the date, leading zeros do not matter', () => {
|
||||
for (const text of [
|
||||
'Mindestens haltbar bis 05.03.2028',
|
||||
'Mindestens haltbar bis 5.3.2028',
|
||||
'mindestens HALTBAR bis 5. 03. 2028',
|
||||
]) {
|
||||
expect(
|
||||
checkLabel(input({ elements: withBestBefore(text) })).gaps,
|
||||
).toEqual([]);
|
||||
}
|
||||
// wording without the date, date without the wording, a wrong date
|
||||
for (const text of [
|
||||
'Mindestens haltbar bis',
|
||||
'MHD 05.03.2028',
|
||||
'Mindestens haltbar bis 06.03.2028',
|
||||
'Mindestens haltbar bis 05.03.2029',
|
||||
]) {
|
||||
expect(
|
||||
codes(checkLabel(input({ elements: withBestBefore(text) })).gaps),
|
||||
).toEqual(['BEST_BEFORE']);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the wording and the date in separate elements', () => {
|
||||
const elements = [
|
||||
...without('bb'),
|
||||
textElement('bb-label', null, 26, { text: 'Mindestens haltbar bis' }),
|
||||
textElement('bb-date', 'bestBeforeDate', 32),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('treats clipped text as missing and warns TEXT_CLIPPED', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) => (element.id === 'lot' ? 999 : 0),
|
||||
};
|
||||
const result = checkLabel(input({ measurer }));
|
||||
expect(codes(result.gaps)).toEqual(['LOT']);
|
||||
expect(codes(result.warnings)).toEqual(['TEXT_CLIPPED']);
|
||||
expect(result.warnings[0].elementIds).toEqual(['lot']);
|
||||
expect(result.printable).toBe(false);
|
||||
});
|
||||
|
||||
it('clips with the real measurer when the box is too short for the text', () => {
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', 'lotLine', 38, { height: 1 }),
|
||||
];
|
||||
const result = checkLabel(input({ elements, measurer: undefined }));
|
||||
expect(codes(result.gaps)).toEqual(['LOT']);
|
||||
expect(codes(result.warnings)).toContain('TEXT_CLIPPED');
|
||||
});
|
||||
|
||||
it('does not warn TEXT_CLIPPED for clipped text that carries nothing', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) => (element.id === 'deco' ? 999 : 0),
|
||||
};
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('deco', null, 90, { text: 'Mit Liebe gemacht' }),
|
||||
];
|
||||
const result = checkLabel(input({ elements, measurer }));
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
describe('FOOD_BUSINESS', () => {
|
||||
it('is a gap when either master data field is empty', () => {
|
||||
for (const expected of [
|
||||
{ foodBusinessName: null },
|
||||
{ foodBusinessAddress: null },
|
||||
{ foodBusinessName: ' ' },
|
||||
{ foodBusinessAddress: '' },
|
||||
]) {
|
||||
const result = checkLabel(
|
||||
input({ expected: { ...input().expected, ...expected } }),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a gap when only the name is on the label', () => {
|
||||
const result = checkLabel(input({ elements: without('biz-address') }));
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
});
|
||||
});
|
||||
|
||||
it('a label without product legalName is complete only with a fixed text containing "honig"', () => {
|
||||
const noLegalName = {
|
||||
...input().values,
|
||||
legalName: '',
|
||||
};
|
||||
expect(codes(checkLabel(input({ values: noLegalName })).gaps)).toEqual([
|
||||
'LEGAL_NAME',
|
||||
]);
|
||||
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('fixed', null, 90, { text: 'Echter Blütenhonig' }),
|
||||
];
|
||||
expect(checkLabel(input({ elements, values: noLegalName })).gaps).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
describe('x-height', () => {
|
||||
function withOtherSize(fontSize: number): LabelTextElement[] {
|
||||
return [
|
||||
...without('origin'),
|
||||
textElement('origin', 'originCountry', 50, { fontSize }),
|
||||
];
|
||||
}
|
||||
|
||||
it('Helvetica 6 pt is x-height 1.11 mm (warning), 7 pt is 1.29 mm', () => {
|
||||
const small = checkLabel(input({ elements: withOtherSize(6) }));
|
||||
expect(small.gaps).toEqual([]);
|
||||
expect(codes(small.warnings)).toEqual(['X_HEIGHT']);
|
||||
expect(small.warnings[0].elementIds).toEqual(['origin']);
|
||||
expect(small.warnings[0].title).toBe('Text zu klein (x-Höhe 1,1 mm)');
|
||||
expect(small.printable).toBe(true);
|
||||
|
||||
const fine = checkLabel(input({ elements: withOtherSize(7) }));
|
||||
expect(fine.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('x-height exactly at the limit is fine', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: withOtherSize(6), minXHeightMm: heightMm(6, 523) }),
|
||||
);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('only checks elements that carry a particular', () => {
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('deco', null, 90, { text: 'Mit Liebe', fontSize: 4 }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('net quantity height', () => {
|
||||
function withNetSize(fontSize: number): LabelTextElement[] {
|
||||
return [
|
||||
...without('net'),
|
||||
textElement('net', 'jarSize', 14, { fontSize }),
|
||||
];
|
||||
}
|
||||
|
||||
it('needs 4 mm digits for 500 g with the default rules (15.8 pt)', () => {
|
||||
const ok = checkLabel(input({ elements: withNetSize(15.8) }));
|
||||
expect(ok.warnings).toEqual([]);
|
||||
|
||||
const small = checkLabel(input({ elements: withNetSize(15.7) }));
|
||||
expect(codes(small.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
|
||||
expect(small.warnings[0].elementIds).toEqual(['net']);
|
||||
expect(small.printable).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[50, 2],
|
||||
[49.5, 2],
|
||||
[200, 3],
|
||||
[200.5, 4],
|
||||
[1000, 4],
|
||||
[1001, 6],
|
||||
[5000, 6],
|
||||
])(
|
||||
'uses the rule of the matching weight class for %s g (%s mm)',
|
||||
(grams, minMm) => {
|
||||
const result = checkLabel(
|
||||
input({ expected: { ...input().expected, jarSizeGrams: grams } }),
|
||||
);
|
||||
expect(result.requirements.netQuantityMinCapHeightMm).toBe(minMm);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the open class above the largest weight class', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '2 kg' },
|
||||
expected: { ...input().expected, jarSizeGrams: 2000 },
|
||||
}),
|
||||
);
|
||||
// 20 pt caps are 5.07 mm, the open class wants 6 mm
|
||||
expect(codes(result.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
|
||||
});
|
||||
|
||||
it('is satisfied when one of several mentions is large enough', () => {
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('net-small', null, 90, {
|
||||
text: 'Inhalt 500 g',
|
||||
fontSize: 8,
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts the rules itself and has no requirement without rules', () => {
|
||||
const shuffled = [RULES[3], RULES[1], RULES[0], RULES[2]];
|
||||
expect(
|
||||
checkLabel(input({ digitRules: shuffled })).requirements
|
||||
.netQuantityMinCapHeightMm,
|
||||
).toBe(4);
|
||||
const none = checkLabel(input({ digitRules: [] }));
|
||||
expect(none.requirements.netQuantityMinCapHeightMm).toBeNull();
|
||||
expect(none.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lot prefix', () => {
|
||||
it('warns on a lot without a leading L', () => {
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', null, 38, { text: 'Los 2026-0001' }),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(codes(result.warnings)).toEqual(['LOT_PREFIX']);
|
||||
});
|
||||
|
||||
it('accepts "L 2026-0001" and "L2026-0001"', () => {
|
||||
for (const text of ['L 2026-0001', 'L2026-0001', 'Ch.-B. L: 2026-0001']) {
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', null, 38, { text }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).warnings)).not.toContain(
|
||||
'LOT_PREFIX',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not add LOT_PREFIX when the lot is missing altogether', () => {
|
||||
const result = checkLabel(input({ elements: without('lot') }));
|
||||
expect(codes(result.gaps)).toEqual(['LOT']);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('a warning alone leaves printable true', () => {
|
||||
const elements = [
|
||||
...without('origin'),
|
||||
textElement('origin', 'originCountry', 50, { fontSize: 6 }),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(result.printable).toBe(true);
|
||||
});
|
||||
|
||||
it('sample result carries sample true', () => {
|
||||
expect(checkLabel(input({ sample: true })).sample).toBe(true);
|
||||
});
|
||||
|
||||
it('requirements carry minXHeightMm and the digit height of the jar size', () => {
|
||||
const result = checkLabel(input({ minXHeightMm: 1.5 }));
|
||||
expect(result.requirements).toEqual({
|
||||
minXHeightMm: 1.5,
|
||||
netQuantityMinCapHeightMm: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('an empty label reports every mandatory particular in a fixed order', () => {
|
||||
const result = checkLabel(input({ elements: [] }));
|
||||
expect(codes(result.gaps)).toEqual([
|
||||
'LEGAL_NAME',
|
||||
'NET_QUANTITY',
|
||||
'BEST_BEFORE',
|
||||
'LOT',
|
||||
'ORIGIN',
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLabel: robustness against odd layouts', () => {
|
||||
it('does not take "1,250 g" for 250 g', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1,250 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
it('does not take "250,5 g" for 5 g, but "Inhalt 6 x 500 g" for 500 g', () => {
|
||||
expect(
|
||||
codes(
|
||||
checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250,5 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 5 },
|
||||
}),
|
||||
).gaps,
|
||||
),
|
||||
).toEqual(['NET_QUANTITY']);
|
||||
expect(
|
||||
checkLabel(
|
||||
input({ values: { ...input().values, jarSize: 'Inhalt 6 x 500 g' } }),
|
||||
).gaps,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not warn LOT_PREFIX for a lot that already starts with L', () => {
|
||||
const lotNumber = 'L-001';
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', null, 38, { text: 'L-001' }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({ elements, expected: { ...input().expected, lotNumber } }),
|
||||
);
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(codes(result.warnings)).not.toContain('LOT_PREFIX');
|
||||
});
|
||||
|
||||
describe('an address split over elements', () => {
|
||||
it('is found with another element sorted in between', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-1', null, 62, {
|
||||
text: 'Imkerei Muster, Bühlweg 10',
|
||||
}),
|
||||
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
|
||||
textElement('biz-2', null, 74, { text: '86450 Altenmünster' }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('is found when the parts differ slightly in y', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-name', 'foodBusinessName', 62),
|
||||
textElement('biz-right', null, 70, {
|
||||
x: 40,
|
||||
text: '86450 Altenmünster',
|
||||
}),
|
||||
textElement('biz-left', null, 70.4, { text: 'Bühlweg 10' }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('is still a gap when a part is really missing', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-1', null, 62, {
|
||||
text: 'Imkerei Muster, Bühlweg 10',
|
||||
}),
|
||||
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLabel: round 2', () => {
|
||||
describe('the digit-before guard is per element', () => {
|
||||
it('ignores a lot number element right before the quantity', () => {
|
||||
const elements = [
|
||||
...without('lot', 'net'),
|
||||
textElement('lot', null, 38, { x: 2, width: 30, text: 'L 2026-0001' }),
|
||||
textElement('net', null, 38, {
|
||||
x: 40,
|
||||
width: 30,
|
||||
fontSize: 20,
|
||||
text: '500 g',
|
||||
}),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(codes(result.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a date element right before the quantity', () => {
|
||||
const elements = [
|
||||
...without('bb', 'net'),
|
||||
textElement('bb', null, 26, {
|
||||
x: 2,
|
||||
width: 50,
|
||||
text: 'Mindestens haltbar bis 05.03.2028',
|
||||
}),
|
||||
textElement('net', null, 26, {
|
||||
x: 60,
|
||||
width: 20,
|
||||
fontSize: 20,
|
||||
text: '500 g',
|
||||
}),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(codes(result.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still rejects "1,250 g" inside one element', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('net', null, 14, { fontSize: 20, text: 'Inhalt 1,250 g' }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({
|
||||
elements,
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a number glued to its unit ("250g")', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('a split address is bounded', () => {
|
||||
function split(
|
||||
street: { y: number; fontSize?: number },
|
||||
town: { y: number; fontSize?: number },
|
||||
): LabelTextElement[] {
|
||||
return [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-name', 'foodBusinessName', 56),
|
||||
textElement('street', null, street.y, {
|
||||
text: 'Bühlweg 10',
|
||||
fontSize: street.fontSize ?? 10,
|
||||
height: 5,
|
||||
}),
|
||||
textElement('town', null, town.y, {
|
||||
text: '86450 Altenmünster',
|
||||
fontSize: town.fontSize ?? 10,
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
it('is a gap when the town sits in a footer far away', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 62 }, { y: 150 }) }),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
});
|
||||
|
||||
it('is found when the town sits directly above the street', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 70 }, { y: 62 }) }),
|
||||
);
|
||||
expect(result.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets the x-height warning reach the parts of the match', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 62 }, { y: 68, fontSize: 6 }) }),
|
||||
);
|
||||
expect(result.gaps).toEqual([]);
|
||||
const warning = result.warnings.find((w) => w.code === 'X_HEIGHT');
|
||||
expect(warning?.elementIds).toEqual(['town']);
|
||||
});
|
||||
|
||||
it('names a clipped part in the gap and in TEXT_CLIPPED', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) => (element.id === 'town' ? 999 : 0),
|
||||
};
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 62 }, { y: 68 }), measurer }),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(result.gaps[0].elementIds).toEqual(['town']);
|
||||
const clipped = result.warnings.find((w) => w.code === 'TEXT_CLIPPED');
|
||||
expect(clipped?.elementIds).toEqual(['town']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLabel: round 3', () => {
|
||||
function addressParts(
|
||||
street: { y: number; x?: number; text?: string },
|
||||
town: { y: number; x?: number },
|
||||
): LabelTextElement[] {
|
||||
return [
|
||||
textElement('street', null, street.y, {
|
||||
x: street.x ?? 2,
|
||||
text: street.text ?? 'Bühlweg 10',
|
||||
height: 5,
|
||||
}),
|
||||
textElement('town', null, town.y, {
|
||||
x: town.x ?? 2,
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const base = () => [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-name', 'foodBusinessName', 56),
|
||||
];
|
||||
|
||||
it('reading order comes from the geometry, not from the array order', () => {
|
||||
// a far away footer town FIRST in the array is still a gap
|
||||
const far = [...addressParts({ y: 62 }, { y: 150 }).reverse(), ...base()];
|
||||
expect(codes(checkLabel(input({ elements: far })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
// street/town in reverse array order but adjacent on the label
|
||||
const adjacent = [
|
||||
...addressParts({ y: 62 }, { y: 68 }).reverse(),
|
||||
...base(),
|
||||
];
|
||||
expect(checkLabel(input({ elements: adjacent })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('measures the distance against the whole group, not the last element', () => {
|
||||
// a tall street box, an empty box below it, the town further down: the
|
||||
// empty box alone would be 24 mm from the town, the street box is not
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, {
|
||||
text: 'Bühlweg 10',
|
||||
height: 40,
|
||||
}),
|
||||
textElement('empty', null, 64, { text: '', height: 2 }),
|
||||
textElement('town', null, 90, {
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
describe('number and unit in two elements', () => {
|
||||
function split(numberSize: number, unitSize: number): LabelTextElement[] {
|
||||
return [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, {
|
||||
x: 2,
|
||||
width: 20,
|
||||
fontSize: numberSize,
|
||||
text: '500',
|
||||
}),
|
||||
textElement('unit', null, 14, {
|
||||
x: 24,
|
||||
width: 10,
|
||||
fontSize: unitSize,
|
||||
text: 'g',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
it('is found; the height warning looks at the parts', () => {
|
||||
const big = checkLabel(input({ elements: split(20, 20) }));
|
||||
expect(big.gaps).toEqual([]);
|
||||
expect(codes(big.warnings)).toEqual([]);
|
||||
|
||||
const small = checkLabel(input({ elements: split(8, 8) }));
|
||||
expect(small.gaps).toEqual([]);
|
||||
const warning = small.warnings.find(
|
||||
(w) => w.code === 'NET_QUANTITY_HEIGHT',
|
||||
);
|
||||
expect(warning?.elementIds).toEqual(['num', 'unit']);
|
||||
});
|
||||
|
||||
it('a big number next to a small unit counts the unit for x-height', () => {
|
||||
const result = checkLabel(input({ elements: split(20, 6) }));
|
||||
expect(result.gaps).toEqual([]);
|
||||
const xHeight = result.warnings.find((w) => w.code === 'X_HEIGHT');
|
||||
expect(xHeight?.elementIds).toEqual(['unit']);
|
||||
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
|
||||
});
|
||||
|
||||
it('does not join across rows or onto a bigger number', () => {
|
||||
const otherRow = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, { text: '500' }),
|
||||
textElement('unit', null, 30, { text: 'g' }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements: otherRow })).gaps)).toEqual([
|
||||
'NET_QUANTITY',
|
||||
]);
|
||||
const bigger = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, { width: 20, text: '1,500' }),
|
||||
textElement('unit', null, 14, { x: 24, width: 10, text: 'g' }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({
|
||||
elements: bigger,
|
||||
expected: { ...input().expected, jarSizeGrams: 500 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
});
|
||||
|
||||
it('gives only the elements of the match to the address carriers', () => {
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('lot-ref', null, 62, {
|
||||
x: 2,
|
||||
width: 20,
|
||||
fontSize: 5,
|
||||
text: 'Lot 10',
|
||||
}),
|
||||
textElement('street', null, 62, {
|
||||
x: 30,
|
||||
width: 25,
|
||||
text: 'Bühlweg 10',
|
||||
height: 5,
|
||||
}),
|
||||
textElement('town', null, 62, {
|
||||
x: 60,
|
||||
width: 25,
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(result.warnings.filter((w) => w.code === 'X_HEIGHT')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAMPLE_BOTTLING', () => {
|
||||
it('carries example values only for the preview', () => {
|
||||
expect(SAMPLE_BOTTLING.jarSize).toBe(500);
|
||||
expect(SAMPLE_BOTTLING.lotNumber).toBe('2026-0001');
|
||||
const values = buildPlaceholderValues(SAMPLE_BOTTLING, SETTINGS);
|
||||
expect(values.jarSize).toBe('500 g');
|
||||
expect(values.lotLine).toBe('L 2026-0001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
import {
|
||||
fontMetricsOf,
|
||||
heightMm,
|
||||
MM_TO_PT,
|
||||
pdfTextMeasurer,
|
||||
TextMeasurer,
|
||||
} from './font-metrics';
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
import {
|
||||
lotStartsWithL,
|
||||
normalizeForCheck,
|
||||
resolvedTexts,
|
||||
ROW_TOLERANCE_MM,
|
||||
} from './label-text';
|
||||
|
||||
export type LabelFindingCode =
|
||||
| 'LEGAL_NAME'
|
||||
| 'NET_QUANTITY'
|
||||
| 'BEST_BEFORE'
|
||||
| 'LOT'
|
||||
| 'ORIGIN'
|
||||
| 'FOOD_BUSINESS'
|
||||
| 'LOT_PREFIX'
|
||||
| 'X_HEIGHT'
|
||||
| 'NET_QUANTITY_HEIGHT'
|
||||
| 'TEXT_CLIPPED';
|
||||
|
||||
export interface LabelFinding {
|
||||
code: LabelFindingCode;
|
||||
title: string;
|
||||
detail: string;
|
||||
elementIds: string[];
|
||||
}
|
||||
|
||||
export interface LabelCheckResult {
|
||||
printable: boolean;
|
||||
gaps: LabelFinding[];
|
||||
warnings: LabelFinding[];
|
||||
sample: boolean;
|
||||
/** The legal values the check used, so the frontend needs no own copy. */
|
||||
requirements: {
|
||||
minXHeightMm: number;
|
||||
netQuantityMinCapHeightMm: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NetQuantityDigitRule {
|
||||
maxGrams: number | null;
|
||||
minMm: number;
|
||||
}
|
||||
|
||||
export interface LabelCheckInput {
|
||||
elements: readonly LabelTextElement[];
|
||||
minXHeightMm: number;
|
||||
digitRules: readonly NetQuantityDigitRule[];
|
||||
values: Record<PlaceholderKey, string>;
|
||||
expected: {
|
||||
jarSizeGrams: number;
|
||||
/** YYYY-MM-DD */
|
||||
bestBefore: string;
|
||||
lotNumber: string;
|
||||
originCountry: string;
|
||||
foodBusinessName: string | null;
|
||||
foodBusinessAddress: string | null;
|
||||
};
|
||||
sample: boolean;
|
||||
measurer?: TextMeasurer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example values for the preview when no bottling is chosen. They only fill
|
||||
* the template so it can be shown; they are not legal values.
|
||||
*/
|
||||
export const SAMPLE_BOTTLING = Object.freeze({
|
||||
jarSize: 500 as const,
|
||||
lotNumber: '2026-0001' as const,
|
||||
date: new Date('2026-07-01T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'),
|
||||
honeyBatch: Object.freeze({
|
||||
batchNumber: 'HB-2026-001',
|
||||
honeyType: 'Sommerhonig',
|
||||
}),
|
||||
product: Object.freeze({
|
||||
name: 'Sommerhonig 500 g',
|
||||
legalName: 'Sommerhonig',
|
||||
}),
|
||||
});
|
||||
|
||||
// A line that is at most half a point too tall still prints (spec 5.4).
|
||||
const CLIP_TOLERANCE_PT = 0.5;
|
||||
// Floating point slack for the "at least" comparisons.
|
||||
const HEIGHT_EPSILON_MM = 1e-9;
|
||||
|
||||
const BEST_BEFORE_WORDING = 'mindestens haltbar bis';
|
||||
const LEGAL_NAME_WORD = 'honig';
|
||||
|
||||
interface Candidate {
|
||||
element: LabelTextElement;
|
||||
normalized: string;
|
||||
clipped: boolean;
|
||||
}
|
||||
|
||||
interface Particular {
|
||||
code: Extract<
|
||||
LabelFindingCode,
|
||||
| 'LEGAL_NAME'
|
||||
| 'NET_QUANTITY'
|
||||
| 'BEST_BEFORE'
|
||||
| 'LOT'
|
||||
| 'ORIGIN'
|
||||
| 'FOOD_BUSINESS'
|
||||
>;
|
||||
/** Is the particular on the label, judged on the whole joined text? */
|
||||
isOnLabel(views: TextViews): boolean;
|
||||
/** Phrases that may be spread over several elements. */
|
||||
phrases?: string[];
|
||||
/** Matches spread over two elements, e.g. "500" | "g" (number first). */
|
||||
pairs?(views: TextViews): Candidate[][];
|
||||
/** Does one element's text hold (a part of) the particular? */
|
||||
isCarriedBy(normalized: string): boolean;
|
||||
missing: { title: string; detail: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The label text in the ways a particular may be spread over elements: every
|
||||
* element alone, all in reading order, row by row, and chains of nearby
|
||||
* elements that share a word with the phrase looked for.
|
||||
*/
|
||||
interface TextViews {
|
||||
visible: Candidate[];
|
||||
/** Normalized text of each visible element on its own. */
|
||||
elements: string[];
|
||||
full: string;
|
||||
/** Reading order, cut where two neighbours are far apart. */
|
||||
blocks: Candidate[][];
|
||||
rows: Candidate[][];
|
||||
chains(normalizedPhrase: string): Candidate[][];
|
||||
}
|
||||
|
||||
// Parts of a split phrase must be at most this far apart (edge to edge).
|
||||
const MAX_JOIN_GAP_MM = 10;
|
||||
|
||||
function hasPhrase(
|
||||
normalizedText: string,
|
||||
normalizedPhrase: string,
|
||||
notAfterDigit = false,
|
||||
): boolean {
|
||||
if (normalizedPhrase === '') {
|
||||
return false;
|
||||
}
|
||||
const padded = ` ${normalizedText} `;
|
||||
const needle = ` ${normalizedPhrase} `;
|
||||
let index = padded.indexOf(needle);
|
||||
while (index !== -1) {
|
||||
if (!notAfterDigit) {
|
||||
return true;
|
||||
}
|
||||
// "1,250 g" is not "250 g": a number token right before the amount means
|
||||
// the amount is only the tail of a bigger number. Only ever applied to
|
||||
// one element's text: a digit from a neighbouring element (lot, date)
|
||||
// says nothing about the amount.
|
||||
const before = padded.slice(0, index).split(' ').pop() ?? '';
|
||||
if (!/^\d+$/.test(before)) {
|
||||
return true;
|
||||
}
|
||||
index = padded.indexOf(needle, index + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function joined(candidates: readonly Candidate[]): string {
|
||||
return candidates
|
||||
.map((candidate) => candidate.normalized)
|
||||
.filter((text) => text !== '')
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function sharesWord(normalizedText: string, normalizedPhrase: string): boolean {
|
||||
const words = new Set(normalizedPhrase.split(' '));
|
||||
return normalizedText.split(' ').some((word) => words.has(word));
|
||||
}
|
||||
|
||||
interface Span {
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
// Symmetric vertical distance of an element to a span of elements: 0 when
|
||||
// they overlap vertically, whichever lies above.
|
||||
function distanceTo(span: Span, candidate: Candidate): number {
|
||||
const { y, height } = candidate.element;
|
||||
return Math.max(0, y - span.bottom, span.top - (y + height));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts a list (already in reading order) wherever the next element is more
|
||||
* than MAX_JOIN_GAP_MM away from everything gathered so far in the group
|
||||
* (the running top and bottom of the group, not only the previous element).
|
||||
*
|
||||
* Known limits, accepted: horizontal distance is ignored, and an unrelated
|
||||
* element that happens to share a word with the phrase can sit inside a chain.
|
||||
*/
|
||||
function groupByDistance(list: Candidate[]): Candidate[][] {
|
||||
const groups: Candidate[][] = [];
|
||||
let span: Span = { top: 0, bottom: 0 };
|
||||
for (const candidate of list) {
|
||||
const group = groups[groups.length - 1];
|
||||
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
|
||||
group.push(candidate);
|
||||
span = {
|
||||
top: Math.min(span.top, candidate.element.y),
|
||||
bottom: Math.max(
|
||||
span.bottom,
|
||||
candidate.element.y + candidate.element.height,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
groups.push([candidate]);
|
||||
span = {
|
||||
top: candidate.element.y,
|
||||
bottom: candidate.element.y + candidate.element.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function textViews(visible: Candidate[]): TextViews {
|
||||
const rows: Candidate[][] = [];
|
||||
let rowTop = Number.NEGATIVE_INFINITY;
|
||||
for (const candidate of visible) {
|
||||
if (candidate.element.y - rowTop > ROW_TOLERANCE_MM) {
|
||||
rows.push([]);
|
||||
rowTop = candidate.element.y;
|
||||
}
|
||||
rows[rows.length - 1].push(candidate);
|
||||
}
|
||||
return {
|
||||
visible,
|
||||
elements: visible.map((candidate) => candidate.normalized),
|
||||
full: joined(visible),
|
||||
blocks: groupByDistance(visible),
|
||||
rows,
|
||||
chains: (phrase) =>
|
||||
groupByDistance(
|
||||
visible.filter((candidate) => sharesWord(candidate.normalized, phrase)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The elements of `group` that make up the first occurrence of the phrase in
|
||||
* their joined text, or null. Only elements overlapping the occurrence
|
||||
* count, not neighbours that merely sit in the same group.
|
||||
*/
|
||||
function matchParts(group: Candidate[], phrase: string): Candidate[] | null {
|
||||
const parts = group.filter((candidate) => candidate.normalized !== '');
|
||||
const text = parts.map((candidate) => candidate.normalized).join(' ');
|
||||
const start = ` ${text} `.indexOf(` ${phrase} `);
|
||||
if (phrase === '' || start === -1) {
|
||||
return null;
|
||||
}
|
||||
const end = start + phrase.length;
|
||||
let offset = 0;
|
||||
const hit: Candidate[] = [];
|
||||
for (const part of parts) {
|
||||
const partEnd = offset + part.normalized.length;
|
||||
if (offset < end && partEnd > start) {
|
||||
hit.push(part);
|
||||
}
|
||||
offset = partEnd + 1;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of every group whose joined text holds the phrase: a block of
|
||||
* neighbours in reading order, one row, or a chain of nearby related elements
|
||||
* (a chain also in reverse order: a short address printed town-first is still
|
||||
* printed together).
|
||||
*/
|
||||
function groupsHolding(views: TextViews, phrase: string): Candidate[][] {
|
||||
const forward = [...views.blocks, ...views.rows, ...views.chains(phrase)];
|
||||
const reversed = views.chains(phrase).map((chain) => [...chain].reverse());
|
||||
return [...forward, ...reversed]
|
||||
.map((group) => matchParts(group, phrase))
|
||||
.filter((parts): parts is Candidate[] => parts !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* "500" | "g": an element ending in the amount (not glued to a bigger
|
||||
* number) followed, in the same row, by an element starting with the unit.
|
||||
*/
|
||||
function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
|
||||
const pairs: Candidate[][] = [];
|
||||
views.visible.forEach((first, index) => {
|
||||
const second = views.visible[index + 1];
|
||||
if (
|
||||
!second ||
|
||||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tokens = first.normalized.split(' ');
|
||||
const unitStart = second.normalized.split(' ')[0];
|
||||
for (const needle of needles) {
|
||||
const words = needle.split(' ');
|
||||
const unit = words.pop();
|
||||
const amount = tokens.slice(-words.length);
|
||||
const before = tokens[tokens.length - words.length - 1] ?? '';
|
||||
if (
|
||||
words.length > 0 &&
|
||||
unit === unitStart &&
|
||||
amount.join(' ') === words.join(' ') &&
|
||||
!/^\d+$/.test(before)
|
||||
) {
|
||||
pairs.push([first, second]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
return value.toFixed(1).replace('.', ',');
|
||||
}
|
||||
|
||||
function bestBeforeDatePattern(bestBefore: string): RegExp {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(bestBefore);
|
||||
if (!match) {
|
||||
throw new Error('bestBefore must be a YYYY-MM-DD date');
|
||||
}
|
||||
const [, year, month, day] = match;
|
||||
return new RegExp(`(^| )0?${Number(day)} 0?${Number(month)} ${year}( |$)`);
|
||||
}
|
||||
|
||||
function netQuantityNeedles(jarSizeGrams: number): string[] {
|
||||
const needles = [normalizeForCheck(`${jarSizeGrams} g`)];
|
||||
if (jarSizeGrams > 0 && jarSizeGrams % 1000 === 0) {
|
||||
needles.push(normalizeForCheck(`${jarSizeGrams / 1000} kg`));
|
||||
}
|
||||
return needles;
|
||||
}
|
||||
|
||||
function particulars(expected: LabelCheckInput['expected']): Particular[] {
|
||||
const netNeedles = netQuantityNeedles(expected.jarSizeGrams);
|
||||
const datePattern = bestBeforeDatePattern(expected.bestBefore);
|
||||
const lot = normalizeForCheck(expected.lotNumber);
|
||||
const origin = normalizeForCheck(expected.originCountry);
|
||||
const businessName = normalizeForCheck(expected.foodBusinessName ?? '');
|
||||
const businessAddress = normalizeForCheck(expected.foodBusinessAddress ?? '');
|
||||
const hasBusinessData = businessName !== '' && businessAddress !== '';
|
||||
const hasHoneyWord = (text: string) =>
|
||||
text.split(' ').some((word) => word.includes(LEGAL_NAME_WORD));
|
||||
|
||||
return [
|
||||
{
|
||||
code: 'LEGAL_NAME',
|
||||
isOnLabel: (views) => hasHoneyWord(views.full),
|
||||
isCarriedBy: hasHoneyWord,
|
||||
missing: {
|
||||
title: 'Verkehrsbezeichnung fehlt',
|
||||
detail:
|
||||
'Die Bezeichnung des Lebensmittels fehlt. Sie muss das Wort „Honig“ enthalten, zum Beispiel „Blütenhonig“. Tragen Sie sie am Artikel ein oder setzen Sie einen festen Text.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'NET_QUANTITY',
|
||||
pairs: (views) => quantityPairs(views, netNeedles),
|
||||
isOnLabel: (views) =>
|
||||
views.elements.some((text) =>
|
||||
netNeedles.some((needle) => hasPhrase(text, needle, true)),
|
||||
) || quantityPairs(views, netNeedles).length > 0,
|
||||
isCarriedBy: (text) =>
|
||||
netNeedles.some((needle) => hasPhrase(text, needle, true)),
|
||||
missing: {
|
||||
title: 'Füllmenge fehlt',
|
||||
detail: `Die Füllmenge dieser Abfüllung (${String(expected.jarSizeGrams).replace('.', ',')} g) steht nicht auf dem Etikett. Ein fester Text mit einer anderen Menge zählt nicht.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'BEST_BEFORE',
|
||||
isOnLabel: ({ full }) =>
|
||||
hasPhrase(full, BEST_BEFORE_WORDING) && datePattern.test(full),
|
||||
isCarriedBy: (text) =>
|
||||
hasPhrase(text, BEST_BEFORE_WORDING) || datePattern.test(text),
|
||||
missing: {
|
||||
title: 'Mindesthaltbarkeitsdatum fehlt',
|
||||
detail:
|
||||
'Es braucht den Text „Mindestens haltbar bis“ und das Datum dieser Abfüllung.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'LOT',
|
||||
isOnLabel: (views) => hasPhrase(views.full, lot),
|
||||
isCarriedBy: (text) => hasPhrase(text, lot),
|
||||
missing: {
|
||||
title: 'Losnummer fehlt',
|
||||
detail: 'Die Losnummer dieser Abfüllung steht nicht auf dem Etikett.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'ORIGIN',
|
||||
isOnLabel: (views) => hasPhrase(views.full, origin),
|
||||
isCarriedBy: (text) => hasPhrase(text, origin),
|
||||
missing: {
|
||||
title: 'Herkunftsland fehlt',
|
||||
detail:
|
||||
'Das Herkunftsland aus den Einstellungen steht nicht auf dem Etikett.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'FOOD_BUSINESS',
|
||||
phrases: [businessName, businessAddress],
|
||||
isOnLabel: (views) =>
|
||||
hasBusinessData &&
|
||||
groupsHolding(views, businessName).length > 0 &&
|
||||
groupsHolding(views, businessAddress).length > 0,
|
||||
isCarriedBy: (text) =>
|
||||
hasPhrase(text, businessName) || hasPhrase(text, businessAddress),
|
||||
missing: {
|
||||
title: 'Name und Anschrift fehlen',
|
||||
detail: hasBusinessData
|
||||
? 'Name und Anschrift des Lebensmittelunternehmers aus den Einstellungen stehen nicht vollständig auf dem Etikett.'
|
||||
: 'Name und Anschrift des Lebensmittelunternehmers sind in den Einstellungen nicht eingetragen.',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function candidatesOf(
|
||||
input: LabelCheckInput,
|
||||
measurer: TextMeasurer,
|
||||
): Candidate[] {
|
||||
return resolvedTexts(input.elements, input.values).map(
|
||||
({ element, text }) => {
|
||||
const normalized = normalizeForCheck(text);
|
||||
const clipped =
|
||||
normalized !== '' &&
|
||||
measurer.heightOfStringPt(text, element) >
|
||||
element.height * MM_TO_PT + CLIP_TOLERANCE_PT;
|
||||
return { element, normalized, clipped };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function ruleFor(
|
||||
rules: readonly NetQuantityDigitRule[],
|
||||
grams: number,
|
||||
): NetQuantityDigitRule | null {
|
||||
const ordered = [...rules].sort(
|
||||
(a, b) =>
|
||||
(a.maxGrams ?? Number.POSITIVE_INFINITY) -
|
||||
(b.maxGrams ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return (
|
||||
ordered.find((rule) => rule.maxGrams === null || grams <= rule.maxGrams) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function unique(ids: string[]): string[] {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function xHeightFinding(
|
||||
carriers: Candidate[],
|
||||
minXHeightMm: number,
|
||||
): LabelFinding | null {
|
||||
const measured = carriers.map(({ element }) => ({
|
||||
id: element.id,
|
||||
xHeightMm: heightMm(
|
||||
element.fontSize,
|
||||
fontMetricsOf(element.fontFamily, element.bold, element.italic).xHeight,
|
||||
),
|
||||
}));
|
||||
const tooSmall = measured.filter(
|
||||
(entry) => entry.xHeightMm < minXHeightMm - HEIGHT_EPSILON_MM,
|
||||
);
|
||||
if (tooSmall.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const smallest = Math.min(...tooSmall.map((entry) => entry.xHeightMm));
|
||||
return {
|
||||
code: 'X_HEIGHT',
|
||||
title: `Text zu klein (x-Höhe ${formatDecimal(smallest)} mm)`,
|
||||
detail: `Pflichtangaben brauchen eine x-Höhe von mindestens ${formatDecimal(minXHeightMm)} mm. Schrift vergrößern.`,
|
||||
elementIds: unique(tooSmall.map((entry) => entry.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function netQuantityHeightFinding(
|
||||
carriers: Candidate[],
|
||||
minMm: number | null,
|
||||
): LabelFinding | null {
|
||||
if (minMm === null || carriers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const tallest = Math.max(
|
||||
...carriers.map(({ element }) =>
|
||||
heightMm(
|
||||
element.fontSize,
|
||||
fontMetricsOf(element.fontFamily, element.bold, element.italic)
|
||||
.capHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (tallest >= minMm - HEIGHT_EPSILON_MM) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: 'NET_QUANTITY_HEIGHT',
|
||||
title: `Füllmenge zu klein (Ziffernhöhe ${formatDecimal(tallest)} mm)`,
|
||||
detail: `Für diese Füllmenge sind Ziffern von mindestens ${formatDecimal(minMm)} mm Höhe nötig. Schrift der Füllmenge vergrößern.`,
|
||||
elementIds: unique(carriers.map(({ element }) => element.id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the label's text elements against the mandatory particulars for one
|
||||
* bottling. Pure: the caller resolves the legal values (settings, digit
|
||||
* rules) and the expected facts. Text that does not fit its box counts as
|
||||
* missing; the check says so with TEXT_CLIPPED.
|
||||
*/
|
||||
export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
||||
const measurer = input.measurer ?? pdfTextMeasurer();
|
||||
const candidates = candidatesOf(input, measurer);
|
||||
const visible = candidates.filter((candidate) => !candidate.clipped);
|
||||
const views = textViews(visible);
|
||||
const combined = views.full;
|
||||
|
||||
const gaps: LabelFinding[] = [];
|
||||
const carriers = new Set<Candidate>();
|
||||
const clippedCarriers = new Set<Candidate>();
|
||||
let netQuantityCarriers: Candidate[] = [];
|
||||
let netQuantityFound = false;
|
||||
let lotFound = false;
|
||||
let lotCarriers: Candidate[] = [];
|
||||
|
||||
for (const particular of particulars(input.expected)) {
|
||||
const isOnLabel = particular.isOnLabel(views);
|
||||
// Phrases no single element holds are spread over several: the parts that
|
||||
// make up the match carry the particular, and so do clipped parts.
|
||||
const splitPhrases = (particular.phrases ?? []).filter(
|
||||
(phrase) =>
|
||||
phrase !== '' &&
|
||||
!visible.some((candidate) => hasPhrase(candidate.normalized, phrase)),
|
||||
);
|
||||
const splitParts = isOnLabel
|
||||
? splitPhrases.flatMap((phrase) => groupsHolding(views, phrase).flat())
|
||||
: [];
|
||||
const pairs = particular.pairs?.(views) ?? [];
|
||||
const singleHolders = visible.filter((candidate) =>
|
||||
particular.isCarriedBy(candidate.normalized),
|
||||
);
|
||||
const holders = [
|
||||
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
|
||||
];
|
||||
holders.forEach((candidate) => carriers.add(candidate));
|
||||
const clippedHolders = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.clipped &&
|
||||
(particular.isCarriedBy(candidate.normalized) ||
|
||||
splitPhrases.some((phrase) =>
|
||||
sharesWord(candidate.normalized, phrase),
|
||||
)),
|
||||
);
|
||||
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
|
||||
if (!isOnLabel) {
|
||||
gaps.push({
|
||||
code: particular.code,
|
||||
...particular.missing,
|
||||
elementIds: unique(clippedHolders.map(({ element }) => element.id)),
|
||||
});
|
||||
}
|
||||
if (particular.code === 'NET_QUANTITY') {
|
||||
netQuantityFound = isOnLabel;
|
||||
netQuantityCarriers = [...new Set([...singleHolders, ...pairs.flat()])];
|
||||
}
|
||||
if (particular.code === 'LOT') {
|
||||
lotFound = isOnLabel;
|
||||
lotCarriers = holders;
|
||||
}
|
||||
}
|
||||
|
||||
const warnings: LabelFinding[] = [];
|
||||
|
||||
if (lotFound) {
|
||||
const lot = normalizeForCheck(input.expected.lotNumber);
|
||||
if (
|
||||
!lotStartsWithL(input.expected.lotNumber) &&
|
||||
!new RegExp(`(^| )l ?${lot}( |$)`).test(combined)
|
||||
) {
|
||||
warnings.push({
|
||||
code: 'LOT_PREFIX',
|
||||
title: 'Losnummer ohne „L“',
|
||||
detail:
|
||||
'Vor der Losnummer sollte der Buchstabe „L“ stehen, zum Beispiel „L 2026-0001“.',
|
||||
elementIds: unique(lotCarriers.map(({ element }) => element.id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const xHeight = xHeightFinding([...carriers], input.minXHeightMm);
|
||||
if (xHeight) {
|
||||
warnings.push(xHeight);
|
||||
}
|
||||
|
||||
const digitRule = ruleFor(input.digitRules, input.expected.jarSizeGrams);
|
||||
const netQuantityHeight = netQuantityFound
|
||||
? netQuantityHeightFinding(netQuantityCarriers, digitRule?.minMm ?? null)
|
||||
: null;
|
||||
if (netQuantityHeight) {
|
||||
warnings.push(netQuantityHeight);
|
||||
}
|
||||
|
||||
if (clippedCarriers.size > 0) {
|
||||
warnings.push({
|
||||
code: 'TEXT_CLIPPED',
|
||||
title: 'Text wird abgeschnitten',
|
||||
detail:
|
||||
'Der Text passt nicht in sein Feld und zählt deshalb nicht. Feld vergrößern oder Schrift verkleinern.',
|
||||
elementIds: unique([...clippedCarriers].map(({ element }) => element.id)),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
printable: gaps.length === 0,
|
||||
gaps,
|
||||
warnings,
|
||||
sample: input.sample,
|
||||
requirements: {
|
||||
minXHeightMm: input.minXHeightMm,
|
||||
netQuantityMinCapHeightMm: digitRule?.minMm ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SiteSettingsModule } from '../site-settings/site-settings.module';
|
||||
import { LabelTemplatesController } from './label-templates.controller';
|
||||
import { LabelTemplatesService } from './label-templates.service';
|
||||
|
||||
@Module({
|
||||
imports: [SiteSettingsModule],
|
||||
controllers: [LabelTemplatesController],
|
||||
providers: [LabelTemplatesService],
|
||||
exports: [LabelTemplatesService],
|
||||
|
||||
@@ -5,8 +5,20 @@ import {
|
||||
} from './label-templates.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { LabelElement } from './label-element.types';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster' as string | null,
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster' as string | null,
|
||||
};
|
||||
|
||||
describe('LabelTemplatesService', () => {
|
||||
let service: LabelTemplatesService;
|
||||
let tx: {
|
||||
@@ -14,8 +26,11 @@ describe('LabelTemplatesService', () => {
|
||||
updateMany: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
};
|
||||
};
|
||||
let siteSettings: { get: jest.Mock };
|
||||
const order: string[] = [];
|
||||
let prisma: {
|
||||
labelTemplate: {
|
||||
findMany: jest.Mock;
|
||||
@@ -78,11 +93,21 @@ describe('LabelTemplatesService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
siteSettings = { get: jest.fn().mockResolvedValue(SETTINGS) };
|
||||
tx = {
|
||||
labelTemplate: {
|
||||
updateMany: jest.fn().mockResolvedValue({}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(template);
|
||||
}),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
@@ -100,6 +125,7 @@ describe('LabelTemplatesService', () => {
|
||||
service = new LabelTemplatesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
siteSettings as unknown as SiteSettingsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -140,6 +166,52 @@ describe('LabelTemplatesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('reads before after locking the LabelTemplate row', async () => {
|
||||
tx.labelTemplate.update.mockImplementation(() => {
|
||||
order.push('update');
|
||||
return Promise.resolve({ ...template, name: 'Neu' });
|
||||
});
|
||||
|
||||
await service.update('template-1', { name: 'Neu' }, 'user-1');
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'LabelTemplate',
|
||||
'template-1',
|
||||
);
|
||||
expect(order).toEqual(['lock', 'read', 'update']);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
beforeData: template,
|
||||
afterData: { ...template, name: 'Neu' },
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for a missing template', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { name: 'Neu' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.labelTemplate.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears other defaults when isDefault is set', async () => {
|
||||
tx.labelTemplate.update.mockResolvedValue(template);
|
||||
|
||||
await service.update('template-1', { isDefault: true }, 'user-1');
|
||||
|
||||
expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { not: 'template-1' } },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('sets active and isDefault to false, records DEACTIVATE', async () => {
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue(template);
|
||||
@@ -426,28 +498,62 @@ describe('LabelTemplatesService', () => {
|
||||
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||
product: { name: 'Glas 500g' },
|
||||
product: {
|
||||
name: 'Glas 500g',
|
||||
legalName: 'Blütenhonig' as string | null,
|
||||
},
|
||||
};
|
||||
|
||||
it('prints the jar size in grams, the unit honey is sold by', () => {
|
||||
expect(buildPlaceholderValues(bottlingBatch).jarSize).toBe('500 g');
|
||||
});
|
||||
|
||||
it('never labels the jar size as a volume', () => {
|
||||
expect(buildPlaceholderValues(bottlingBatch).jarSize).not.toMatch(
|
||||
/\b(ml|l|cl)\b/,
|
||||
expect(buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize).toBe(
|
||||
'500 g',
|
||||
);
|
||||
});
|
||||
|
||||
it('never labels the jar size as a volume', () => {
|
||||
expect(
|
||||
buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize,
|
||||
).not.toMatch(/\b(ml|l|cl)\b/);
|
||||
});
|
||||
|
||||
it('leaves an unset honey type and product as empty text, not "null"', () => {
|
||||
const values = buildPlaceholderValues({
|
||||
...bottlingBatch,
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
|
||||
product: null,
|
||||
});
|
||||
const values = buildPlaceholderValues(
|
||||
{
|
||||
...bottlingBatch,
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
|
||||
product: null,
|
||||
},
|
||||
SETTINGS,
|
||||
);
|
||||
|
||||
expect(values.honeyType).toBe('');
|
||||
expect(values.productName).toBe('');
|
||||
expect(values.legalName).toBe('');
|
||||
});
|
||||
|
||||
it('fills the particulars from the product and the settings', () => {
|
||||
const values = buildPlaceholderValues(bottlingBatch, SETTINGS);
|
||||
|
||||
expect(values.legalName).toBe('Blütenhonig');
|
||||
expect(values.originCountry).toBe('Deutschland');
|
||||
expect(values.foodBusinessName).toBe('Imkerei Muster');
|
||||
expect(values.foodBusinessAddress).toBe('Bühlweg 10, 86450 Altenmünster');
|
||||
expect(values.bestBeforeLine).toBe('Mindestens haltbar bis 01.06.2028');
|
||||
expect(values.lotLine).toBe('L-001');
|
||||
});
|
||||
|
||||
it('leaves unset master data as empty text, not "null"', () => {
|
||||
const values = buildPlaceholderValues(
|
||||
{
|
||||
...bottlingBatch,
|
||||
product: { name: 'Glas 500g', legalName: null },
|
||||
},
|
||||
{ ...SETTINGS, foodBusinessName: null, foodBusinessAddress: null },
|
||||
);
|
||||
|
||||
expect(values.legalName).toBe('');
|
||||
expect(values.foodBusinessName).toBe('');
|
||||
expect(values.foodBusinessAddress).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,20 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { LabelTemplate } from '../../generated/prisma';
|
||||
import { computeCropMarks } from './crop-marks';
|
||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import {
|
||||
LabelElement,
|
||||
LabelFontFamily,
|
||||
PlaceholderKey,
|
||||
} from './label-element.types';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import { formatDayDe } from '../common/format-day';
|
||||
import { MM_TO_PT, pdfFontName } from './font-metrics';
|
||||
import { lotStartsWithL } from './label-text';
|
||||
import { LabelElement, PlaceholderKey } from './label-element.types';
|
||||
|
||||
const MM_TO_PT = 72 / 25.4;
|
||||
const A4_WIDTH_MM = 210;
|
||||
const A4_HEIGHT_MM = 297;
|
||||
const QR_SOURCE_SIZE_PX = 400;
|
||||
@@ -35,15 +36,18 @@ interface SheetLayout {
|
||||
// not to show if the blade lands a fraction beside it.
|
||||
const CROP_MARK_LINE_WIDTH_PT = 0.3;
|
||||
|
||||
// '2-digit' forces a leading zero for day/month (e.g. "05.03.2026") - the
|
||||
// default 'numeric' formatting drops it ("5.3.2026"), which is what the MHD
|
||||
// on a printed label was showing.
|
||||
/**
|
||||
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
|
||||
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
|
||||
* independent of the process time zone. The label text and the label check
|
||||
* (`expected.bestBefore`) must both come from this function.
|
||||
*/
|
||||
export function labelDayOf(date: Date): string {
|
||||
return berlinDayOf(date);
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
return formatDayDe(labelDayOf(date), true);
|
||||
}
|
||||
|
||||
// Prisma stores `elements` as a plain Json column; the shape is only ever
|
||||
@@ -53,24 +57,6 @@ function elementsOf(template: LabelTemplate): LabelElement[] {
|
||||
return template.elements as unknown as LabelElement[];
|
||||
}
|
||||
|
||||
function pdfFontName(
|
||||
family: LabelFontFamily,
|
||||
bold: boolean,
|
||||
italic: boolean,
|
||||
): string {
|
||||
const base =
|
||||
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
|
||||
if (base === 'Times') {
|
||||
if (bold && italic) return 'Times-BoldItalic';
|
||||
if (bold) return 'Times-Bold';
|
||||
if (italic) return 'Times-Italic';
|
||||
return 'Times-Roman';
|
||||
}
|
||||
const suffix =
|
||||
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
interface GridFit {
|
||||
columns: number;
|
||||
rows: number;
|
||||
@@ -119,22 +105,38 @@ function dataUrlToBuffer(dataUrl: string): Buffer | null {
|
||||
* grams everywhere else in the app, and honey is a food sold by mass, so a
|
||||
* label stating millilitres declares the wrong net quantity.
|
||||
*/
|
||||
export function buildPlaceholderValues(bottlingBatch: {
|
||||
jarSize: number;
|
||||
lotNumber: string;
|
||||
date: Date;
|
||||
bestBeforeDate: Date;
|
||||
honeyBatch: { batchNumber: string; honeyType: string | null };
|
||||
product: { name: string } | null;
|
||||
}): Record<PlaceholderKey, string> {
|
||||
export function buildPlaceholderValues(
|
||||
bottlingBatch: {
|
||||
jarSize: number;
|
||||
lotNumber: string;
|
||||
date: Date;
|
||||
bestBeforeDate: Date;
|
||||
honeyBatch: { batchNumber: string; honeyType: string | null };
|
||||
product: { name: string; legalName: string | null } | null;
|
||||
},
|
||||
settings: {
|
||||
originCountry: string;
|
||||
foodBusinessName: string | null;
|
||||
foodBusinessAddress: string | null;
|
||||
},
|
||||
): Record<PlaceholderKey, string> {
|
||||
const bestBeforeDate = formatDate(bottlingBatch.bestBeforeDate);
|
||||
return {
|
||||
batchNumber: bottlingBatch.honeyBatch.batchNumber,
|
||||
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
|
||||
lotNumber: bottlingBatch.lotNumber,
|
||||
bottlingDate: formatDate(bottlingBatch.date),
|
||||
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
|
||||
bestBeforeDate,
|
||||
jarSize: `${bottlingBatch.jarSize} g`,
|
||||
productName: bottlingBatch.product?.name ?? '',
|
||||
legalName: bottlingBatch.product?.legalName ?? '',
|
||||
originCountry: settings.originCountry,
|
||||
foodBusinessName: settings.foodBusinessName ?? '',
|
||||
foodBusinessAddress: settings.foodBusinessAddress ?? '',
|
||||
bestBeforeLine: `Mindestens haltbar bis ${bestBeforeDate}`,
|
||||
lotLine: lotStartsWithL(bottlingBatch.lotNumber)
|
||||
? bottlingBatch.lotNumber
|
||||
: `L ${bottlingBatch.lotNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ export class LabelTemplatesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly siteSettings: SiteSettingsService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<LabelTemplate[]> {
|
||||
@@ -189,28 +192,39 @@ export class LabelTemplatesService {
|
||||
dto: UpdateLabelTemplateDto,
|
||||
actingUserId: string,
|
||||
): Promise<LabelTemplate> {
|
||||
const before = await this.findById(id);
|
||||
const template = await this.prisma.$transaction(async (tx) => {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// "before" is read after the lock: two concurrent edits queue up and the
|
||||
// audit entry shows the state this edit really replaced.
|
||||
if (!(await lockRowForUpdate(tx, 'LabelTemplate', id))) {
|
||||
throw new NotFoundException('Label template not found');
|
||||
}
|
||||
const before = await tx.labelTemplate.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Label template not found');
|
||||
}
|
||||
if (dto.isDefault) {
|
||||
await tx.labelTemplate.updateMany({
|
||||
where: { id: { not: id } },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
return tx.labelTemplate.update({
|
||||
const template = await tx.labelTemplate.update({
|
||||
where: { id },
|
||||
data: { ...dto, elements: dto.elements as never },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'LabelTemplate',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: template,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return template;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'LabelTemplate',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: template,
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
|
||||
@@ -244,7 +258,10 @@ export class LabelTemplatesService {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
}
|
||||
|
||||
const values = buildPlaceholderValues(bottlingBatch);
|
||||
const values = buildPlaceholderValues(
|
||||
bottlingBatch,
|
||||
await this.siteSettings.get(),
|
||||
);
|
||||
|
||||
const elements = elementsOf(template).map((element) =>
|
||||
element.type === 'text' && element.placeholderKey
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { LabelTextElement } from './label-element.types';
|
||||
import { normalizeForCheck, resolvedTexts } from './label-text';
|
||||
import { PLACEHOLDER_KEYS, PlaceholderKey } from './label-element.types';
|
||||
|
||||
function textElement(
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
overrides: Partial<LabelTextElement> = {},
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x,
|
||||
y,
|
||||
width: 50,
|
||||
height: 10,
|
||||
text: id,
|
||||
fontSize: 10,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const values = Object.fromEntries(
|
||||
PLACEHOLDER_KEYS.map((key) => [key, `value-${key}`]),
|
||||
) as Record<PlaceholderKey, string>;
|
||||
|
||||
describe('normalizeForCheck', () => {
|
||||
it('normalizes umlauts, NFKC compatibility characters and punctuation', () => {
|
||||
expect(normalizeForCheck('Bühlweg 10, 86450 Altenmünster')).toBe(
|
||||
'bühlweg 10 86450 altenmünster',
|
||||
);
|
||||
expect(normalizeForCheck('Bühlweg 10 · 86450\nAltenmünster')).toBe(
|
||||
'bühlweg 10 86450 altenmünster',
|
||||
);
|
||||
// decomposed u + combining diaeresis equals the precomposed letter
|
||||
expect(normalizeForCheck('Bu\u0308hlweg')).toBe('bühlweg');
|
||||
// full-width digits, ligature, non-breaking space
|
||||
expect(normalizeForCheck('500\u00a0g fix')).toBe('500 g fix');
|
||||
});
|
||||
|
||||
it('splits a number glued to its unit', () => {
|
||||
expect(normalizeForCheck('250g')).toBe('250 g');
|
||||
expect(normalizeForCheck('L2026-0001')).toBe('l2026 0001');
|
||||
});
|
||||
|
||||
it('collapses runs of separators and trims', () => {
|
||||
expect(normalizeForCheck(' --L 2026-0001.. ')).toBe('l 2026 0001');
|
||||
expect(normalizeForCheck('...')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvedTexts', () => {
|
||||
it('sorts elements top first, then left', () => {
|
||||
const result = resolvedTexts(
|
||||
[
|
||||
textElement('c', 30, 20),
|
||||
textElement('b', 30, 5),
|
||||
textElement('a', 5, 5),
|
||||
textElement('d', 5, 20),
|
||||
],
|
||||
values,
|
||||
);
|
||||
expect(result.map((entry) => entry.element.id)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'd',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('substitutes the placeholder value and keeps fixed text', () => {
|
||||
const result = resolvedTexts(
|
||||
[
|
||||
textElement('p', 0, 0, { placeholderKey: 'lotLine', text: 'ignored' }),
|
||||
textElement('f', 0, 10, { text: 'Honig' }),
|
||||
textElement('n', 0, 20, { placeholderKey: null, text: 'Fix' }),
|
||||
],
|
||||
values,
|
||||
);
|
||||
expect(result.map((entry) => entry.text)).toEqual([
|
||||
'value-lotLine',
|
||||
'Honig',
|
||||
'Fix',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
|
||||
/**
|
||||
* Text as the label check compares it: NFKC, lower case, every character that
|
||||
* is not a letter or digit becomes a space, runs of spaces collapse. So
|
||||
* "Bühlweg 10, 86450 Altenmünster" also matches "Bühlweg 10 · 86450\nAltenmünster".
|
||||
*/
|
||||
export function normalizeForCheck(text: string): string {
|
||||
return (
|
||||
text
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
// "250g" -> "250 g": a number glued to its unit
|
||||
.replace(/(\p{N})(?=\p{L})/gu, '$1 ')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// Elements whose tops are at most this far apart count as one row.
|
||||
export const ROW_TOLERANCE_MM = 1;
|
||||
|
||||
export interface ResolvedText {
|
||||
element: LabelTextElement;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text each element prints for one bottling (placeholders filled in),
|
||||
* top first, then left.
|
||||
*/
|
||||
export function resolvedTexts(
|
||||
elements: readonly LabelTextElement[],
|
||||
values: Record<PlaceholderKey, string>,
|
||||
): ResolvedText[] {
|
||||
const resolved = elements
|
||||
.map((element) => ({
|
||||
element,
|
||||
text: element.placeholderKey
|
||||
? values[element.placeholderKey]
|
||||
: element.text,
|
||||
}))
|
||||
.sort((a, b) => a.element.y - b.element.y);
|
||||
// Reading order comes from the geometry only, never from the order of the
|
||||
// elements array: rows (tops within the tolerance of the row's first top),
|
||||
// each row from left to right.
|
||||
const rows: ResolvedText[][] = [];
|
||||
let rowTop = Number.NEGATIVE_INFINITY;
|
||||
for (const entry of resolved) {
|
||||
if (entry.element.y - rowTop > ROW_TOLERANCE_MM) {
|
||||
rows.push([]);
|
||||
rowTop = entry.element.y;
|
||||
}
|
||||
rows[rows.length - 1].push(entry);
|
||||
}
|
||||
return rows.flatMap((row) => row.sort((a, b) => a.element.x - b.element.x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the lot number already start with the lot marker "L" (as its own token
|
||||
* or glued to digits, "L-001", "L2026")? Then no further "L" is needed.
|
||||
*/
|
||||
export function lotStartsWithL(lotNumber: string): boolean {
|
||||
return /^l( |\d|$)/.test(normalizeForCheck(lotNumber));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PLACEHOLDER_KEYS } from './label-element.types';
|
||||
|
||||
describe('PLACEHOLDER_KEYS', () => {
|
||||
// The frontend compares its own list with this file.
|
||||
it('the fixture file equals PLACEHOLDER_KEYS', () => {
|
||||
const fixture: unknown = JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, '../../test/fixtures/placeholder-keys.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
expect(fixture).toEqual([...PLACEHOLDER_KEYS]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ProductKind, ShopCategory } from '../../../generated/prisma';
|
||||
@@ -26,6 +27,16 @@ export class CreateProductDto {
|
||||
@IsString()
|
||||
sku?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Verkehrsbezeichnung auf dem Etikett (muss „Honig“ enthalten), zum Beispiel „Blütenhonig“',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
legalName?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ProductsService } from './products.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
describe('ProductsService', () => {
|
||||
let service: ProductsService;
|
||||
@@ -12,10 +17,36 @@ describe('ProductsService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: {
|
||||
product: { findUnique: jest.Mock; update: jest.Mock };
|
||||
bottlingBatch: { count: jest.Mock };
|
||||
productionBatch: { count: jest.Mock };
|
||||
};
|
||||
const order: string[] = [];
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx = {
|
||||
product: {
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve({ id: 'p1', name: 'Sommerhonig' });
|
||||
}),
|
||||
update: jest.fn().mockImplementation(() => {
|
||||
order.push('update');
|
||||
return Promise.resolve({ id: 'p1', name: 'Sommerhonig 500g' });
|
||||
}),
|
||||
},
|
||||
bottlingBatch: { count: jest.fn().mockResolvedValue(0) },
|
||||
productionBatch: { count: jest.fn().mockResolvedValue(0) },
|
||||
};
|
||||
prisma = {
|
||||
product: {
|
||||
findMany: jest.fn(),
|
||||
@@ -23,6 +54,9 @@ describe('ProductsService', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new ProductsService(
|
||||
@@ -53,8 +87,6 @@ describe('ProductsService', () => {
|
||||
it('updates a product and records before/after audit data', async () => {
|
||||
const before = { id: 'p1', name: 'Sommerhonig' };
|
||||
const after = { id: 'p1', name: 'Sommerhonig 500g' };
|
||||
prisma.product.findUnique.mockResolvedValueOnce(before);
|
||||
prisma.product.update.mockResolvedValueOnce(after);
|
||||
|
||||
const result = await service.update(
|
||||
'p1',
|
||||
@@ -69,6 +101,76 @@ describe('ProductsService', () => {
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads before after locking the Product row', async () => {
|
||||
await service.update('p1', { name: 'Sommerhonig 500g' }, 'user-1');
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Product', 'p1');
|
||||
expect(order).toEqual(['lock', 'read', 'update']);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when updating a product that does not exist', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { name: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a kind change while bottlings exist', async () => {
|
||||
tx.product.findUnique.mockResolvedValueOnce({
|
||||
id: 'p1',
|
||||
kind: 'BOTTLED_HONEY',
|
||||
});
|
||||
tx.bottlingBatch.count.mockResolvedValueOnce(2);
|
||||
|
||||
await expect(
|
||||
service.update('p1', { kind: 'MANUFACTURED' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('legalName', () => {
|
||||
it('trims it on create and stores an empty one as null', async () => {
|
||||
prisma.product.create.mockResolvedValue({ id: 'p1' });
|
||||
|
||||
await service.create({ name: 'A', legalName: ' Blütenhonig ' }, 'u');
|
||||
expect(prisma.product.create).toHaveBeenLastCalledWith({
|
||||
data: { name: 'A', legalName: 'Blütenhonig' },
|
||||
});
|
||||
|
||||
await service.create({ name: 'A', legalName: ' ' }, 'u');
|
||||
expect(prisma.product.create).toHaveBeenLastCalledWith({
|
||||
data: { name: 'A', legalName: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves it out of the data when the dto does not carry it', async () => {
|
||||
prisma.product.create.mockResolvedValue({ id: 'p1' });
|
||||
|
||||
await service.create({ name: 'A' }, 'u');
|
||||
|
||||
expect(prisma.product.create).toHaveBeenLastCalledWith({
|
||||
data: { name: 'A' },
|
||||
});
|
||||
});
|
||||
|
||||
it('clears it with null on update and trims on update', async () => {
|
||||
await service.update('p1', { legalName: null }, 'u');
|
||||
expect(tx.product.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'p1' },
|
||||
data: { legalName: null },
|
||||
});
|
||||
|
||||
await service.update('p1', { legalName: ' Waldhonig ' }, 'u');
|
||||
expect(tx.product.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'p1' },
|
||||
data: { legalName: 'Waldhonig' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { Product, ProductStatus } from '../../generated/prisma';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
@@ -13,6 +15,17 @@ export interface ProductFilter {
|
||||
status?: ProductStatus;
|
||||
}
|
||||
|
||||
// legalName is stored trimmed; blank or null clears it. Absent stays absent so
|
||||
// a patch without it leaves the value alone.
|
||||
function withNormalizedLegalName<T extends { legalName?: string | null }>(
|
||||
dto: T,
|
||||
): T {
|
||||
if (dto.legalName === undefined) {
|
||||
return dto;
|
||||
}
|
||||
return { ...dto, legalName: dto.legalName?.trim() || null };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
@@ -36,7 +49,9 @@ export class ProductsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto, actingUserId: string): Promise<Product> {
|
||||
const product = await this.prisma.product.create({ data: { ...dto } });
|
||||
const product = await this.prisma.product.create({
|
||||
data: { ...withNormalizedLegalName(dto) },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Product',
|
||||
entityId: product.id,
|
||||
@@ -52,23 +67,34 @@ export class ProductsService {
|
||||
dto: UpdateProductDto,
|
||||
actingUserId: string,
|
||||
): Promise<Product> {
|
||||
const before = await this.findById(id);
|
||||
if (dto.kind !== undefined && dto.kind !== before.kind) {
|
||||
await this.assertKindChangeable(id);
|
||||
}
|
||||
const product = await this.prisma.product.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (!(await lockRowForUpdate(tx, 'Product', id))) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
const before = await tx.product.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
if (dto.kind !== undefined && dto.kind !== before.kind) {
|
||||
await this.assertKindChangeable(tx, id);
|
||||
}
|
||||
const product = await tx.product.update({
|
||||
where: { id },
|
||||
data: { ...withNormalizedLegalName(dto) },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Product',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: product,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return product;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Product',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: product,
|
||||
});
|
||||
return product;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,10 +102,10 @@ export class ProductsService {
|
||||
* honey, production batches for everything else. Switching the kind while
|
||||
* batches exist would leave that stock in the wrong place.
|
||||
*/
|
||||
private async assertKindChangeable(id: string): Promise<void> {
|
||||
private async assertKindChangeable(db: DbClient, id: string): Promise<void> {
|
||||
const [bottlings, productionBatches] = await Promise.all([
|
||||
this.prisma.bottlingBatch.count({ where: { productId: id } }),
|
||||
this.prisma.productionBatch.count({ where: { productId: id } }),
|
||||
db.bottlingBatch.count({ where: { productId: id } }),
|
||||
db.productionBatch.count({ where: { productId: id } }),
|
||||
]);
|
||||
if (bottlings > 0 || productionBatches > 0) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* The catalogue of veterinary medicines starts empty (ARCHITECTURE
|
||||
* assumption 10): every value comes from the beekeeper's package leaflet or
|
||||
* prescription, the code ships no medicine or legal data.
|
||||
*/
|
||||
describe('prisma/seed.ts', () => {
|
||||
const seed = readFileSync(join(__dirname, '..', 'prisma', 'seed.ts'), 'utf8');
|
||||
|
||||
it('creates no veterinary medicine, pack or movement', () => {
|
||||
expect(seed).not.toMatch(/veterinaryMedicine/i);
|
||||
});
|
||||
|
||||
it('imports only from the known modules and has no sibling seed file', () => {
|
||||
const imported = [
|
||||
...seed.matchAll(/^import\s+(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/gm),
|
||||
].map((match) => match[1]);
|
||||
expect(imported.sort()).toEqual(
|
||||
[
|
||||
'../generated/prisma',
|
||||
'@prisma/adapter-pg',
|
||||
'bcryptjs',
|
||||
'dotenv/config',
|
||||
'node:crypto',
|
||||
].sort(),
|
||||
);
|
||||
const files = readdirSync(join(__dirname, '..', 'prisma'));
|
||||
expect(files.filter((name) => name.endsWith('.ts'))).toEqual(['seed.ts']);
|
||||
});
|
||||
});
|
||||
@@ -42,19 +42,43 @@ export class UpdateSiteSettingsDto {
|
||||
@MaxLength(300)
|
||||
websiteUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Name des Bienenhalters (Bestandsbuch)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Name des Bienenhalters (Bestandsbuch)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
holderName?: string;
|
||||
holderName?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Anschrift des Bienenhalters (Bestandsbuch)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
holderAddress?: string;
|
||||
holderAddress?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Name des Lebensmittelunternehmers auf dem Etikett (null oder leer löscht)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
foodBusinessName?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Anschrift des Lebensmittelunternehmers auf dem Etikett (null oder leer löscht)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
foodBusinessAddress?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Herkunftsland auf der öffentlichen Herkunftsseite',
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { SiteSettingsService } from './site-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
describe('SiteSettingsService', () => {
|
||||
let service: SiteSettingsService;
|
||||
@@ -10,12 +15,31 @@ describe('SiteSettingsService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: {
|
||||
siteSettings: {
|
||||
upsert: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
const order: string[] = [];
|
||||
|
||||
function updateData(): Record<string, unknown> {
|
||||
const call = tx.siteSettings.update.mock.calls[0] as [
|
||||
{ where: { id: string }; data: Record<string, unknown> },
|
||||
];
|
||||
expect(call[0].where).toEqual({ id: 'singleton' });
|
||||
return call[0].data;
|
||||
}
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const existing = {
|
||||
id: 'singleton',
|
||||
youtubeUrl: 'https://youtube.com/@wabenhain',
|
||||
foodBusinessName: null as string | null,
|
||||
foodBusinessAddress: null as string | null,
|
||||
instagramUrl: null as string | null,
|
||||
tiktokUrl: null as string | null,
|
||||
websiteUrl: null as string | null,
|
||||
@@ -23,12 +47,33 @@ describe('SiteSettingsService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx = {
|
||||
siteSettings: {
|
||||
upsert: jest.fn().mockImplementation(() => {
|
||||
order.push('upsert');
|
||||
return Promise.resolve(existing);
|
||||
}),
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(existing);
|
||||
}),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
siteSettings: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new SiteSettingsService(
|
||||
@@ -67,9 +112,8 @@ describe('SiteSettingsService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('stores a valid link and records an audit entry', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue({
|
||||
it('stores a valid link and records an audit entry in the transaction', async () => {
|
||||
tx.siteSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
});
|
||||
@@ -79,14 +123,9 @@ describe('SiteSettingsService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: undefined,
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
tiktokUrl: undefined,
|
||||
websiteUrl: undefined,
|
||||
},
|
||||
expect(updateData()).toMatchObject({
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
youtubeUrl: undefined,
|
||||
});
|
||||
expect(result.instagramUrl).toBe('https://instagram.com/wabenhain');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
@@ -94,44 +133,80 @@ describe('SiteSettingsService', () => {
|
||||
entityType: 'SiteSettings',
|
||||
action: 'UPDATE',
|
||||
userId: 'user-1',
|
||||
beforeData: existing,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('update reads before after locking the SiteSettings row', async () => {
|
||||
tx.siteSettings.update.mockImplementation(() => {
|
||||
order.push('update');
|
||||
return Promise.resolve(existing);
|
||||
});
|
||||
|
||||
await service.update({ youtubeUrl: '' }, 'user-1');
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'SiteSettings',
|
||||
'singleton',
|
||||
);
|
||||
expect(order).toEqual(['upsert', 'lock', 'read', 'update']);
|
||||
});
|
||||
|
||||
it('clears a link when given an empty string', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue({
|
||||
tx.siteSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
youtubeUrl: null,
|
||||
});
|
||||
|
||||
await service.update({ youtubeUrl: '' }, 'user-1');
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: null,
|
||||
instagramUrl: undefined,
|
||||
tiktokUrl: undefined,
|
||||
websiteUrl: undefined,
|
||||
},
|
||||
});
|
||||
expect(updateData()).toMatchObject({ youtubeUrl: null });
|
||||
});
|
||||
|
||||
it('leaves fields untouched when omitted from the dto', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue(existing);
|
||||
tx.siteSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update({}, 'user-1');
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: undefined,
|
||||
instagramUrl: undefined,
|
||||
tiktokUrl: undefined,
|
||||
websiteUrl: undefined,
|
||||
const call = tx.siteSettings.update.mock.calls[0] as [
|
||||
{ data: Record<string, unknown> },
|
||||
];
|
||||
expect(Object.values(call[0].data).every((v) => v === undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('trims foodBusinessName and foodBusinessAddress and stores empty as null', async () => {
|
||||
tx.siteSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update(
|
||||
{
|
||||
foodBusinessName: ' Imkerei Muster ',
|
||||
foodBusinessAddress: ' ',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(updateData()).toMatchObject({
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the food business fields with null', async () => {
|
||||
tx.siteSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update(
|
||||
{ foodBusinessName: null, foodBusinessAddress: null },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(updateData()).toMatchObject({
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettings } from '../../generated/prisma';
|
||||
import { UpdateSiteSettingsDto } from './dto/update-site-settings.dto';
|
||||
@@ -10,6 +11,17 @@ function emptyToNull(value: string | undefined): string | null {
|
||||
return value === '' ? null : (value ?? null);
|
||||
}
|
||||
|
||||
// undefined keeps the stored value, null or blank clears it, anything else is
|
||||
// stored trimmed.
|
||||
function trimmedOrNull(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SiteSettingsService {
|
||||
constructor(
|
||||
@@ -34,43 +46,56 @@ export class SiteSettingsService {
|
||||
dto: UpdateSiteSettingsDto,
|
||||
actingUserId: string,
|
||||
): Promise<SiteSettings> {
|
||||
const before = await this.get();
|
||||
const settings = await this.prisma.siteSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
data: {
|
||||
youtubeUrl:
|
||||
dto.youtubeUrl !== undefined
|
||||
? emptyToNull(dto.youtubeUrl)
|
||||
: undefined,
|
||||
instagramUrl:
|
||||
dto.instagramUrl !== undefined
|
||||
? emptyToNull(dto.instagramUrl)
|
||||
: undefined,
|
||||
tiktokUrl:
|
||||
dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined,
|
||||
websiteUrl:
|
||||
dto.websiteUrl !== undefined
|
||||
? emptyToNull(dto.websiteUrl)
|
||||
: undefined,
|
||||
holderName:
|
||||
dto.holderName !== undefined
|
||||
? emptyToNull(dto.holderName.trim())
|
||||
: undefined,
|
||||
holderAddress:
|
||||
dto.holderAddress !== undefined
|
||||
? emptyToNull(dto.holderAddress.trim())
|
||||
: undefined,
|
||||
originCountry: dto.originCountry?.trim(),
|
||||
},
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// The singleton row must exist to be locked; "before" is read after the
|
||||
// lock so concurrent edits queue up instead of overwriting each other.
|
||||
await tx.siteSettings.upsert({
|
||||
where: { id: SINGLETON_ID },
|
||||
update: {},
|
||||
create: { id: SINGLETON_ID },
|
||||
});
|
||||
await lockRowForUpdate(tx, 'SiteSettings', SINGLETON_ID);
|
||||
const before = await tx.siteSettings.findUnique({
|
||||
where: { id: SINGLETON_ID },
|
||||
});
|
||||
const settings = await tx.siteSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
data: {
|
||||
youtubeUrl:
|
||||
dto.youtubeUrl !== undefined
|
||||
? emptyToNull(dto.youtubeUrl)
|
||||
: undefined,
|
||||
instagramUrl:
|
||||
dto.instagramUrl !== undefined
|
||||
? emptyToNull(dto.instagramUrl)
|
||||
: undefined,
|
||||
tiktokUrl:
|
||||
dto.tiktokUrl !== undefined
|
||||
? emptyToNull(dto.tiktokUrl)
|
||||
: undefined,
|
||||
websiteUrl:
|
||||
dto.websiteUrl !== undefined
|
||||
? emptyToNull(dto.websiteUrl)
|
||||
: undefined,
|
||||
holderName: trimmedOrNull(dto.holderName),
|
||||
holderAddress: trimmedOrNull(dto.holderAddress),
|
||||
foodBusinessName: trimmedOrNull(dto.foodBusinessName),
|
||||
foodBusinessAddress: trimmedOrNull(dto.foodBusinessAddress),
|
||||
originCountry: dto.originCountry?.trim(),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'SiteSettings',
|
||||
entityId: SINGLETON_ID,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: settings,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return settings;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'SiteSettings',
|
||||
entityId: SINGLETON_ID,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: settings,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
MAX_ADDRESS_LENGTH,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_NOTE_LENGTH,
|
||||
} from './create-veterinary-medicine.dto';
|
||||
|
||||
export const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||
|
||||
/** One bought pack. Batch number, supplier, proof and vet apply to every treatment from it. */
|
||||
export class CreateLotDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
medicineId!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Ch.-B.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
batchNumber?: string | null;
|
||||
|
||||
@ApiProperty({ example: '2026-08-01' })
|
||||
@Matches(DAY_PATTERN, { message: `purchasedOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
purchasedOn!: string;
|
||||
|
||||
@ApiProperty({ description: 'Anfangsmenge in der Einheit des Mittels, > 0' })
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
@IsPositive()
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, example: '2028-01-31' })
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `expiresOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
expiresOn?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Name, Anschrift' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
supplier?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'z. B. Rechnung 4711' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
purchaseProof?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
prescribingVet?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export const MANUAL_MOVEMENT_TYPES = ['CORRECTION', 'DISPOSAL'] as const;
|
||||
export type ManualMovementType = (typeof MANUAL_MOVEMENT_TYPES)[number];
|
||||
export const MAX_REASON_LENGTH = 500;
|
||||
const FINITE = { allowNaN: false, allowInfinity: false } as const;
|
||||
|
||||
/**
|
||||
* CORRECTION (inventory): the rest counted in the box. DISPOSAL: a positive
|
||||
* quantity that leaves the lot, with a reason. TREATMENT bookings come only
|
||||
* from a treatment.
|
||||
*/
|
||||
export class CreateMovementDto {
|
||||
@ApiProperty({ enum: MANUAL_MOVEMENT_TYPES })
|
||||
@IsIn(MANUAL_MOVEMENT_TYPES)
|
||||
type!: ManualMovementType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'CORRECTION: der tatsächliche Rest' })
|
||||
@ValidateIf((dto: CreateMovementDto) => dto.type === 'CORRECTION')
|
||||
@IsNumber(FINITE)
|
||||
@Min(0)
|
||||
actualRemaining?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'DISPOSAL: Menge, positiv' })
|
||||
@ValidateIf((dto: CreateMovementDto) => dto.type === 'DISPOSAL')
|
||||
@IsNumber(FINITE)
|
||||
@IsPositive()
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'DISPOSAL: Pflicht' })
|
||||
@ValidateIf(
|
||||
(dto: CreateMovementDto) => dto.type === 'DISPOSAL' || dto.reason != null,
|
||||
)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/\S/, { message: 'reason darf nicht nur aus Leerzeichen bestehen' })
|
||||
@MaxLength(MAX_REASON_LENGTH)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { MedicineUnit } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export const MAX_NAME_LENGTH = 200;
|
||||
export const MAX_ADDRESS_LENGTH = 300;
|
||||
export const MAX_NOTE_LENGTH = 2000;
|
||||
const MAX_DOSE_NOTE_LENGTH = 500;
|
||||
const FINITE = { allowNaN: false, allowInfinity: false } as const;
|
||||
|
||||
/**
|
||||
* Every value comes from the package leaflet or the prescription and is typed
|
||||
* in by the beekeeper - the code ships no medicine data (assumption 10).
|
||||
*/
|
||||
export class CreateVeterinaryMedicineDto {
|
||||
@ApiProperty({ description: 'Handelsname', maxLength: MAX_NAME_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/\S/, { message: 'name darf nicht nur aus Leerzeichen bestehen' })
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: MedicineUnit,
|
||||
description: 'Nur änderbar, solange keine Packung existiert.',
|
||||
})
|
||||
@IsEnum(MedicineUnit)
|
||||
unit!: MedicineUnit;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
activeIngredient?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Zulassungsnummer' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
authorisationNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
prescriptionOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: 'Wartezeit Honig in Tagen, auch 0',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
withdrawalPeriodDays?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'In der Einheit' })
|
||||
@IsOptional()
|
||||
@IsNumber(FINITE)
|
||||
@IsPositive()
|
||||
dosePerColony?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_DOSE_NOTE_LENGTH)
|
||||
doseNote?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
applicationMethod?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumber(FINITE)
|
||||
minTemperatureC?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumber(FINITE)
|
||||
maxTemperatureC?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
defaultSupplier?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ID_PATTERN } from './create-lot.dto';
|
||||
import { booleanQuery } from './medicine-list-query.dto';
|
||||
|
||||
export class LotListQuery {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(64)
|
||||
medicineId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Nur Packungen mit Rest > 0, älteste Haltbarkeit zuerst',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(booleanQuery)
|
||||
@IsBoolean()
|
||||
selectable?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Mit selectable: diese Packung bleibt wählbar, auch wenn sie leer oder ihr Mittel inaktiv ist (Bearbeiten einer Behandlung)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(64)
|
||||
includeLotId?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
/** Query strings arrive as text: only "true" and "false" are booleans. */
|
||||
export function booleanQuery({ value }: TransformFnParams): unknown {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value as unknown;
|
||||
}
|
||||
|
||||
export class MedicineListQuery {
|
||||
@ApiPropertyOptional({ description: 'Nur aktive oder nur inaktive Mittel' })
|
||||
@IsOptional()
|
||||
@Transform(booleanQuery)
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateLotDto } from './create-lot.dto';
|
||||
|
||||
/** `medicineId` is fixed once a movement exists (409, checked in the service). */
|
||||
export class UpdateLotDto extends PartialType(CreateLotDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateVeterinaryMedicineDto } from './create-veterinary-medicine.dto';
|
||||
|
||||
/** Omitted stays, null clears a nullable field, null on a required one is a 400. */
|
||||
export class UpdateVeterinaryMedicineDto extends PartialType(
|
||||
CreateVeterinaryMedicineDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { summarizeLot, treatmentBookingDelta } from './medicine-ledger';
|
||||
|
||||
describe('summarizeLot', () => {
|
||||
it('rounds to thousandths', () => {
|
||||
expect(
|
||||
summarizeLot(10, [{ quantity: -3.3333 }, { quantity: -3.3333 }])
|
||||
.remaining,
|
||||
).toBe(3.333);
|
||||
});
|
||||
|
||||
it('reports what was used', () => {
|
||||
expect(summarizeLot(10, [{ quantity: -4 }, { quantity: 1 }])).toEqual({
|
||||
remaining: 7,
|
||||
used: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the rest go negative', () => {
|
||||
expect(summarizeLot(1, [{ quantity: -2.5 }]).remaining).toBe(-1.5);
|
||||
});
|
||||
|
||||
it('is the initial quantity without movements', () => {
|
||||
expect(summarizeLot(5, [])).toEqual({ remaining: 5, used: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('treatmentBookingDelta', () => {
|
||||
it('books the full dose on create', () => {
|
||||
expect(treatmentBookingDelta([], { lotId: 'A', dose: 5 })).toEqual([
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves the booking when the lot changes', () => {
|
||||
expect(
|
||||
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
|
||||
lotId: 'B',
|
||||
dose: 5,
|
||||
}),
|
||||
).toEqual([
|
||||
{ lotId: 'A', quantity: 5 },
|
||||
{ lotId: 'B', quantity: -5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reverses the booking when dose or lot is cleared', () => {
|
||||
expect(treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], null)).toEqual(
|
||||
[{ lotId: 'A', quantity: 5 }],
|
||||
);
|
||||
});
|
||||
|
||||
it('books nothing when nothing changed', () => {
|
||||
expect(
|
||||
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
|
||||
lotId: 'A',
|
||||
dose: 5,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(treatmentBookingDelta([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('books only the difference when the dose changes on the same lot', () => {
|
||||
expect(
|
||||
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
|
||||
lotId: 'A',
|
||||
dose: 7,
|
||||
}),
|
||||
).toEqual([{ lotId: 'A', quantity: -2 }]);
|
||||
});
|
||||
|
||||
it('sums several earlier bookings of one lot and rounds the difference', () => {
|
||||
expect(
|
||||
treatmentBookingDelta(
|
||||
[
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
{ lotId: 'A', quantity: 5 },
|
||||
{ lotId: 'A', quantity: -0.1 },
|
||||
],
|
||||
{ lotId: 'A', dose: 0.3 },
|
||||
),
|
||||
).toEqual([{ lotId: 'A', quantity: -0.2 }]);
|
||||
});
|
||||
|
||||
it('counts an earlier dose-reduction booking', () => {
|
||||
// dose 5 booked, later reduced to 3 (+2): the lot carries -3 in total
|
||||
const current = [
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
{ lotId: 'A', quantity: 2 },
|
||||
];
|
||||
expect(treatmentBookingDelta(current, { lotId: 'A', dose: 3 })).toEqual([]);
|
||||
expect(treatmentBookingDelta(current, { lotId: 'A', dose: 4 })).toEqual([
|
||||
{ lotId: 'A', quantity: -1 },
|
||||
]);
|
||||
expect(treatmentBookingDelta(current, null)).toEqual([
|
||||
{ lotId: 'A', quantity: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a fully reversed lot as nothing to do', () => {
|
||||
expect(
|
||||
treatmentBookingDelta(
|
||||
[
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
{ lotId: 'A', quantity: 5 },
|
||||
],
|
||||
null,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Pure arithmetic of the veterinary medicine book - no database, so the rules
|
||||
* that decide what is booked can be tested on their own.
|
||||
*
|
||||
* Rest of a lot = its initial quantity + the sum of its signed movements,
|
||||
* rounded to thousandths. It is always derived, never stored, so it cannot
|
||||
* drift from the book. A negative rest is allowed: the treatment happened,
|
||||
* refusing it would only hide the gap.
|
||||
*/
|
||||
|
||||
import type {
|
||||
MedicineUnit,
|
||||
VeterinaryMedicine,
|
||||
VeterinaryMedicineMovementType,
|
||||
} from '../../generated/prisma';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
|
||||
export interface LotLedgerEntry {
|
||||
/** Signed: negative takes medicine out of the lot. */
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export function summarizeLot(
|
||||
initial: number,
|
||||
entries: readonly LotLedgerEntry[],
|
||||
): { remaining: number; used: number } {
|
||||
const remaining = roundToThousandths(
|
||||
entries.reduce((sum, entry) => sum + entry.quantity, initial),
|
||||
);
|
||||
return { remaining, used: roundToThousandths(initial - remaining) };
|
||||
}
|
||||
|
||||
/** The signed sum of the TREATMENT bookings made so far on one lot. */
|
||||
export interface BookedPerLot {
|
||||
lotId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface DesiredBooking {
|
||||
lotId: string;
|
||||
/** > 0: what the treatment takes out of the lot. */
|
||||
dose: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to book so that the treatment's bookings add up to the wanted state:
|
||||
* a counter-booking on every lot that carries too much or too little, and
|
||||
* -dose on the wanted lot. Nothing to do yields an empty list. Bookings are
|
||||
* never changed or deleted, a correction is a new booking.
|
||||
*/
|
||||
export function treatmentBookingDelta(
|
||||
current: readonly BookedPerLot[],
|
||||
desired: DesiredBooking | null,
|
||||
): BookedPerLot[] {
|
||||
const bookedByLot = new Map<string, number>();
|
||||
for (const booking of current) {
|
||||
bookedByLot.set(
|
||||
booking.lotId,
|
||||
(bookedByLot.get(booking.lotId) ?? 0) + booking.quantity,
|
||||
);
|
||||
}
|
||||
if (desired !== null && !bookedByLot.has(desired.lotId)) {
|
||||
bookedByLot.set(desired.lotId, 0);
|
||||
}
|
||||
const deltas: BookedPerLot[] = [];
|
||||
for (const [lotId, booked] of bookedByLot) {
|
||||
const target = desired?.lotId === lotId ? -desired.dose : 0;
|
||||
const quantity = roundToThousandths(target - booked);
|
||||
if (quantity !== 0) {
|
||||
deltas.push({ lotId, quantity });
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
/** Dates without a time of day travel as YYYY-MM-DD. */
|
||||
export type MedicineView = VeterinaryMedicine & {
|
||||
/** Σ rest of all lots of the medicine (a lot below zero counts negative). */
|
||||
totalRemaining: number;
|
||||
/** Earliest expiry of a lot that still has a rest, or null. */
|
||||
nextExpiry: string | null;
|
||||
lotCount: number;
|
||||
};
|
||||
|
||||
export interface LotView {
|
||||
id: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
unit: MedicineUnit;
|
||||
batchNumber: string | null;
|
||||
purchasedOn: string;
|
||||
quantity: number;
|
||||
remaining: number;
|
||||
expiresOn: string | null;
|
||||
supplier: string | null;
|
||||
purchaseProof: string | null;
|
||||
prescribingVet: string | null;
|
||||
notes: string | null;
|
||||
movementCount: number;
|
||||
}
|
||||
|
||||
export interface MovementView {
|
||||
id: string;
|
||||
type: VeterinaryMedicineMovementType;
|
||||
quantity: number;
|
||||
treatmentId: string | null;
|
||||
reason: string | null;
|
||||
userId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LotDetailView extends LotView {
|
||||
movements: MovementView[];
|
||||
}
|
||||
|
||||
export interface MedicineSuggestion {
|
||||
name: string;
|
||||
treatmentCount: number;
|
||||
latest: {
|
||||
activeIngredient: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
method: string | null;
|
||||
amount: string | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
MedicineUnit,
|
||||
VeterinaryMedicine,
|
||||
VeterinaryMedicineLot,
|
||||
} from '../../generated/prisma';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import {
|
||||
summarizeLot,
|
||||
type LotView,
|
||||
type MedicineView,
|
||||
} from './medicine-ledger';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
|
||||
/** Σ and number of the movements of one lot. */
|
||||
export interface LotTotals {
|
||||
sum: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const NO_MOVEMENTS: LotTotals = { sum: 0, count: 0 };
|
||||
|
||||
/** The medicine a lot view names. */
|
||||
export const LOT_MEDICINE_INCLUDE = {
|
||||
medicine: { select: { name: true, unit: true } },
|
||||
} as const;
|
||||
|
||||
export type LotRow = VeterinaryMedicineLot & {
|
||||
medicine: { name: string; unit: MedicineUnit };
|
||||
};
|
||||
|
||||
/** One grouped query for all lots, never one per lot. */
|
||||
export async function lotTotals(
|
||||
db: DbClient,
|
||||
lotIds: readonly string[],
|
||||
): Promise<Map<string, LotTotals>> {
|
||||
if (lotIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows = await db.veterinaryMedicineMovement.groupBy({
|
||||
by: ['lotId'],
|
||||
where: { lotId: { in: [...lotIds] } },
|
||||
_sum: { quantity: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.lotId,
|
||||
{ sum: row._sum.quantity ?? 0, count: row._count._all },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export const dayText = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
export function remainingOf(
|
||||
lot: { quantity: number },
|
||||
totals: LotTotals | undefined,
|
||||
): number {
|
||||
return summarizeLot(lot.quantity, [
|
||||
{ quantity: (totals ?? NO_MOVEMENTS).sum },
|
||||
]).remaining;
|
||||
}
|
||||
|
||||
export function toLotView(lot: LotRow, totals: LotTotals | undefined): LotView {
|
||||
return {
|
||||
id: lot.id,
|
||||
medicineId: lot.medicineId,
|
||||
medicineName: lot.medicine.name,
|
||||
unit: lot.medicine.unit,
|
||||
batchNumber: lot.batchNumber,
|
||||
purchasedOn: dayText(lot.purchasedOn),
|
||||
quantity: lot.quantity,
|
||||
remaining: remainingOf(lot, totals),
|
||||
expiresOn: lot.expiresOn === null ? null : dayText(lot.expiresOn),
|
||||
supplier: lot.supplier,
|
||||
purchaseProof: lot.purchaseProof,
|
||||
prescribingVet: lot.prescribingVet,
|
||||
notes: lot.notes,
|
||||
movementCount: (totals ?? NO_MOVEMENTS).count,
|
||||
};
|
||||
}
|
||||
|
||||
export function toMedicineView(
|
||||
medicine: VeterinaryMedicine,
|
||||
lots: readonly Pick<VeterinaryMedicineLot, 'id' | 'quantity' | 'expiresOn'>[],
|
||||
totals: ReadonlyMap<string, LotTotals>,
|
||||
): MedicineView {
|
||||
let total = 0;
|
||||
let nextExpiry: Date | null = null;
|
||||
for (const lot of lots) {
|
||||
const remaining = remainingOf(lot, totals.get(lot.id));
|
||||
total += remaining;
|
||||
if (
|
||||
remaining > 0 &&
|
||||
lot.expiresOn !== null &&
|
||||
(nextExpiry === null || lot.expiresOn < nextExpiry)
|
||||
) {
|
||||
nextExpiry = lot.expiresOn;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...medicine,
|
||||
totalRemaining: roundToThousandths(total),
|
||||
nextExpiry: nextExpiry === null ? null : dayText(nextExpiry),
|
||||
lotCount: lots.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { CreateLotDto } from './dto/create-lot.dto';
|
||||
import { CreateMovementDto } from './dto/create-movement.dto';
|
||||
import { LotListQuery } from './dto/lot-list-query.dto';
|
||||
import { UpdateLotDto } from './dto/update-lot.dto';
|
||||
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
|
||||
|
||||
// There is deliberately no route to change or delete a movement: the book is
|
||||
// append-only, a mistake is booked back.
|
||||
@ApiTags('veterinary-medicines')
|
||||
@ApiBearerAuth()
|
||||
@Controller('veterinary-medicine-lots')
|
||||
export class VeterinaryMedicineLotsController {
|
||||
constructor(private readonly lots: VeterinaryMedicineLotsService) {}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get()
|
||||
findAll(@Query() query: LotListQuery) {
|
||||
return this.lots.findAll(query);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.lots.findOne(id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(@Body() dto: CreateLotDto, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.lots.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLotDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.lots.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Idempotent()
|
||||
@Post(':id/movements')
|
||||
addMovement(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateMovementDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.lots.addMovement(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
lockRowsForUpdate: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const lot = {
|
||||
id: 'l1',
|
||||
medicineId: 'm1',
|
||||
batchNumber: 'CH-1',
|
||||
purchasedOn: new Date('2026-08-01T00:00:00Z'),
|
||||
quantity: 10,
|
||||
expiresOn: null,
|
||||
supplier: null,
|
||||
purchaseProof: null,
|
||||
prescribingVet: null,
|
||||
notes: null,
|
||||
userId: 'u1',
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
medicine: { name: 'Mittel A', unit: 'ML' },
|
||||
};
|
||||
|
||||
function lotAt(id: string, quantity: number, expiresOn: string | null) {
|
||||
return {
|
||||
...lot,
|
||||
id,
|
||||
quantity,
|
||||
expiresOn: expiresOn === null ? null : new Date(`${expiresOn}T00:00:00Z`),
|
||||
};
|
||||
}
|
||||
|
||||
describe('VeterinaryMedicineLotsService', () => {
|
||||
let service: VeterinaryMedicineLotsService;
|
||||
let tx: {
|
||||
veterinaryMedicine: { findUnique: jest.Mock };
|
||||
veterinaryMedicineLot: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
veterinaryMedicineMovement: {
|
||||
aggregate: jest.Mock;
|
||||
count: jest.Mock;
|
||||
create: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
groupBy: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: {
|
||||
$transaction: jest.Mock;
|
||||
veterinaryMedicineLot: { findMany: jest.Mock };
|
||||
veterinaryMedicineMovement: { groupBy: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
|
||||
(lockRowsForUpdate as jest.Mock).mockResolvedValue(undefined);
|
||||
tx = {
|
||||
veterinaryMedicine: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'm1', unit: 'ML', active: true }),
|
||||
},
|
||||
veterinaryMedicineLot: {
|
||||
findUnique: jest.fn().mockResolvedValue(lot),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve({ ...lot, ...data }),
|
||||
),
|
||||
create: jest.fn().mockResolvedValue(lot),
|
||||
},
|
||||
veterinaryMedicineMovement: {
|
||||
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: -3 } }),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||
veterinaryMedicineLot: { findMany: jest.fn() },
|
||||
veterinaryMedicineMovement: { groupBy: jest.fn() },
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new VeterinaryMedicineLotsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('addMovement', () => {
|
||||
it('CORRECTION books the delta between the rest and the actual rest', async () => {
|
||||
// rest 10 - 3 = 7, counted 5
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 5 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
lotId: 'l1',
|
||||
type: 'CORRECTION',
|
||||
quantity: -2,
|
||||
reason: null,
|
||||
userId: 'u1',
|
||||
},
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'VeterinaryMedicineLot',
|
||||
entityId: 'l1',
|
||||
action: 'UPDATE',
|
||||
beforeData: expect.objectContaining({ remaining: 7 }) as object,
|
||||
afterData: expect.objectContaining({ remaining: 5 }) as object,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('CORRECTION books a positive delta when more is found', async () => {
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 8.5 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ quantity: 1.5 }) as object,
|
||||
});
|
||||
});
|
||||
|
||||
it('CORRECTION books nothing for delta 0', async () => {
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 7 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the lot before it reads the rest', async () => {
|
||||
const order: string[] = [];
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx.veterinaryMedicineMovement.aggregate.mockImplementation(() => {
|
||||
order.push('sum');
|
||||
return Promise.resolve({ _sum: { quantity: 0 } });
|
||||
});
|
||||
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 1 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(order).toEqual(['lock', 'sum']);
|
||||
});
|
||||
|
||||
it('DISPOSAL books the negative quantity and needs a reason', async () => {
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'DISPOSAL', quantity: 2, reason: 'abgelaufen' },
|
||||
'u1',
|
||||
);
|
||||
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
lotId: 'l1',
|
||||
type: 'DISPOSAL',
|
||||
quantity: -2,
|
||||
reason: 'abgelaufen',
|
||||
userId: 'u1',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.addMovement('l1', { type: 'DISPOSAL', quantity: 2 }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown lot', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.addMovement(
|
||||
'x',
|
||||
{ type: 'CORRECTION', actualRemaining: 1 },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('rejects changing medicineId once a movement exists with 409', async () => {
|
||||
tx.veterinaryMedicineMovement.count.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
service.update('l1', { medicineId: 'm2' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(tx.veterinaryMedicineLot.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the medicines before the lot', async () => {
|
||||
const order: string[] = [];
|
||||
(lockRowsForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('medicines');
|
||||
return Promise.resolve();
|
||||
});
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lot');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
await service.update('l1', { medicineId: 'm2' }, 'u1');
|
||||
|
||||
expect(order).toEqual(['medicines', 'lot']);
|
||||
});
|
||||
|
||||
it('rejects a move to a medicine with another unit with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
id: 'm2',
|
||||
unit: 'G',
|
||||
active: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('l1', { medicineId: 'm2' }, 'u1'),
|
||||
).rejects.toThrow('Die Einheit des Mittels passt nicht zur Packung.');
|
||||
expect(tx.veterinaryMedicineLot.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a move to an inactive medicine with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
id: 'm2',
|
||||
unit: 'ML',
|
||||
active: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('l1', { medicineId: 'm2' }, 'u1'),
|
||||
).rejects.toThrow('Das Mittel ist deaktiviert.');
|
||||
});
|
||||
|
||||
it('allows the other fields with movements', async () => {
|
||||
tx.veterinaryMedicineMovement.count.mockResolvedValue(5);
|
||||
|
||||
await service.update(
|
||||
'l1',
|
||||
{ supplier: 'Imkerei Muster', notes: null },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineLot.update).toHaveBeenCalledWith({
|
||||
where: { id: 'l1' },
|
||||
data: { supplier: 'Imkerei Muster', notes: null },
|
||||
include: { medicine: { select: { name: true, unit: true } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('rejects a lot for an inactive medicine with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
id: 'm1',
|
||||
unit: 'ML',
|
||||
active: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ medicineId: 'm1', purchasedOn: '2026-08-01', quantity: 10 },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toThrow('Das Mittel ist deaktiviert.');
|
||||
expect(tx.veterinaryMedicineLot.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the medicine and answers 404 for an unknown one', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ medicineId: 'x', purchasedOn: '2026-08-01', quantity: 10 },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.veterinaryMedicineLot.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
beforeEach(() => {
|
||||
prisma.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
lotAt('late', 10, '2027-06-01'),
|
||||
lotAt('empty', 5, '2026-01-01'),
|
||||
lotAt('early', 10, '2026-09-01'),
|
||||
lotAt('none', 10, null),
|
||||
]);
|
||||
prisma.veterinaryMedicineMovement.groupBy.mockResolvedValue([
|
||||
{ lotId: 'empty', _sum: { quantity: -5 }, _count: { _all: 1 } },
|
||||
{ lotId: 'early', _sum: { quantity: -12 }, _count: { _all: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('selectable lots are ordered oldest expiry first and exclude an empty lot', async () => {
|
||||
const lots = await service.findAll({ selectable: true });
|
||||
|
||||
// 'empty' (rest 0) and 'early' (rest -2) have nothing left to take
|
||||
expect(lots.map((entry) => entry.id)).toEqual(['late', 'none']);
|
||||
});
|
||||
|
||||
it('asks only for lots of active medicines when selectable, plus includeLotId', async () => {
|
||||
await service.findAll({ selectable: true, includeLotId: 'keep' });
|
||||
|
||||
expect(prisma.veterinaryMedicineLot.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
medicineId: undefined,
|
||||
OR: [{ medicine: { active: true } }, { id: 'keep' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the includeLotId lot although it is empty', async () => {
|
||||
const lots = await service.findAll({
|
||||
selectable: true,
|
||||
includeLotId: 'empty',
|
||||
});
|
||||
|
||||
expect(lots.map((entry) => entry.id)).toEqual(['empty', 'late', 'none']);
|
||||
});
|
||||
|
||||
it('a negative rest is allowed and reported in the view', async () => {
|
||||
const lots = await service.findAll({});
|
||||
|
||||
expect(lots.find((entry) => entry.id === 'early')).toMatchObject({
|
||||
remaining: -2,
|
||||
movementCount: 2,
|
||||
});
|
||||
expect(lots).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, type MedicineUnit } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { dayToDate } from '../common/dates';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { fifoOrder } from '../stock/stock-ledger';
|
||||
import { CreateLotDto } from './dto/create-lot.dto';
|
||||
import { CreateMovementDto } from './dto/create-movement.dto';
|
||||
import { UpdateLotDto } from './dto/update-lot.dto';
|
||||
import {
|
||||
summarizeLot,
|
||||
type LotDetailView,
|
||||
type LotView,
|
||||
} from './medicine-ledger';
|
||||
import {
|
||||
LOT_MEDICINE_INCLUDE,
|
||||
lotTotals,
|
||||
remainingOf,
|
||||
toLotView,
|
||||
type LotRow,
|
||||
} from './medicine-views';
|
||||
|
||||
const AUDIT_ENTITY = 'VeterinaryMedicineLot';
|
||||
const MEDICINE_NOT_FOUND = 'Tierarzneimittel nicht gefunden';
|
||||
const LOT_NOT_FOUND = 'Packung nicht gefunden';
|
||||
|
||||
export interface LotListFilter {
|
||||
medicineId?: string;
|
||||
selectable?: boolean;
|
||||
/** With `selectable`: this lot stays in the list even when empty or inactive. */
|
||||
includeLotId?: string;
|
||||
}
|
||||
|
||||
/** The lot row as audited: without the medicine the view joins in. */
|
||||
function auditRow(lot: LotRow, remaining?: number): object {
|
||||
const { id, medicineId, batchNumber, purchasedOn, quantity, expiresOn } = lot;
|
||||
const row = {
|
||||
id,
|
||||
medicineId,
|
||||
batchNumber,
|
||||
purchasedOn,
|
||||
quantity,
|
||||
expiresOn,
|
||||
supplier: lot.supplier,
|
||||
purchaseProof: lot.purchaseProof,
|
||||
prescribingVet: lot.prescribingVet,
|
||||
notes: lot.notes,
|
||||
userId: lot.userId,
|
||||
createdAt: lot.createdAt,
|
||||
updatedAt: lot.updatedAt,
|
||||
};
|
||||
return remaining === undefined ? row : { ...row, remaining };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VeterinaryMedicineLotsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Newest purchase first. `selectable`: lots of active medicines with a rest
|
||||
* > 0, oldest expiry first (FIFO); `includeLotId` (the lot of a treatment
|
||||
* being edited) stays selectable even when empty or its medicine is inactive.
|
||||
*/
|
||||
async findAll(filter: LotListFilter): Promise<LotView[]> {
|
||||
const lots = await this.prisma.veterinaryMedicineLot.findMany({
|
||||
where: {
|
||||
medicineId: filter.medicineId,
|
||||
...(filter.selectable && {
|
||||
OR: [
|
||||
{ medicine: { active: true } },
|
||||
...(filter.includeLotId ? [{ id: filter.includeLotId }] : []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
orderBy: [{ purchasedOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
this.prisma,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
const entries = lots.map((lot) => ({
|
||||
id: lot.id,
|
||||
remaining: remainingOf(lot, totals.get(lot.id)),
|
||||
bestBeforeDate: lot.expiresOn,
|
||||
date: lot.purchasedOn,
|
||||
view: toLotView(lot, totals.get(lot.id)),
|
||||
}));
|
||||
const chosen = filter.selectable
|
||||
? fifoOrder(
|
||||
entries.filter(
|
||||
(entry) => entry.remaining > 0 || entry.id === filter.includeLotId,
|
||||
),
|
||||
)
|
||||
: entries;
|
||||
return chosen.map((entry) => entry.view);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<LotDetailView> {
|
||||
return this.detail(this.prisma, id);
|
||||
}
|
||||
|
||||
async create(dto: CreateLotDto, actingUserId: string): Promise<LotView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// The medicine first: changing its unit counts the lots under this lock.
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicine', dto.medicineId))) {
|
||||
throw new NotFoundException(MEDICINE_NOT_FOUND);
|
||||
}
|
||||
await this.assertMedicineActive(tx, dto.medicineId);
|
||||
const created = await tx.veterinaryMedicineLot.create({
|
||||
data: {
|
||||
...dto,
|
||||
purchasedOn: dayToDate(dto.purchasedOn),
|
||||
expiresOn:
|
||||
dto.expiresOn == null ? dto.expiresOn : dayToDate(dto.expiresOn),
|
||||
userId: actingUserId,
|
||||
},
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: auditRow(created),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return toLotView(created, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The medicine of a lot is fixed once a movement exists (409). Lock order:
|
||||
* medicines (ascending), then the lot - the order of the lock map.
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateLotDto,
|
||||
actingUserId: string,
|
||||
): Promise<LotView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const readMedicineId = await this.lockMedicinesThenLot(tx, id, dto);
|
||||
const before = await tx.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
if (
|
||||
dto.medicineId !== undefined &&
|
||||
dto.medicineId !== before.medicineId
|
||||
) {
|
||||
await this.assertMedicineChangeable(tx, before, readMedicineId, dto);
|
||||
}
|
||||
const { purchasedOn, expiresOn, ...rest } = dto;
|
||||
const updated = await tx.veterinaryMedicineLot.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(purchasedOn !== undefined && {
|
||||
purchasedOn: dayToDate(purchasedOn),
|
||||
}),
|
||||
...(expiresOn !== undefined && {
|
||||
expiresOn: expiresOn === null ? null : dayToDate(expiresOn),
|
||||
}),
|
||||
},
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: auditRow(before),
|
||||
afterData: auditRow(updated),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
const totals = await lotTotals(tx, [id]);
|
||||
return toLotView(updated, totals.get(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CORRECTION books the difference between the rest in the book and the rest
|
||||
* counted (nothing for a difference of 0); DISPOSAL books the quantity as a
|
||||
* negative one. The rest is read under the lot lock, so two inventories at
|
||||
* the same time are applied one after the other.
|
||||
*/
|
||||
async addMovement(
|
||||
id: string,
|
||||
dto: CreateMovementDto,
|
||||
actingUserId: string,
|
||||
): Promise<LotDetailView> {
|
||||
const manual = this.manualBooking(dto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
const lot = await tx.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
if (!lot) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
const booked = await tx.veterinaryMedicineMovement.aggregate({
|
||||
where: { lotId: id },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const { remaining } = summarizeLot(lot.quantity, [
|
||||
{ quantity: booked._sum.quantity ?? 0 },
|
||||
]);
|
||||
const quantity =
|
||||
manual.type === 'CORRECTION'
|
||||
? roundToThousandths(manual.actualRemaining - remaining)
|
||||
: roundToThousandths(-manual.quantity);
|
||||
if (quantity === 0) {
|
||||
if (manual.type === 'DISPOSAL') {
|
||||
throw new BadRequestException('Die Menge ist zu klein');
|
||||
}
|
||||
return this.detail(tx, id);
|
||||
}
|
||||
await tx.veterinaryMedicineMovement.create({
|
||||
data: {
|
||||
lotId: id,
|
||||
type: manual.type,
|
||||
quantity,
|
||||
reason: manual.reason,
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: auditRow(lot, remaining),
|
||||
afterData: auditRow(lot, roundToThousandths(remaining + quantity)),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return this.detail(tx, id);
|
||||
});
|
||||
}
|
||||
|
||||
private manualBooking(
|
||||
dto: CreateMovementDto,
|
||||
):
|
||||
| { type: 'CORRECTION'; actualRemaining: number; reason: string | null }
|
||||
| { type: 'DISPOSAL'; quantity: number; reason: string } {
|
||||
if (dto.type === 'CORRECTION') {
|
||||
if (dto.actualRemaining === undefined || !(dto.actualRemaining >= 0)) {
|
||||
throw new BadRequestException('Der tatsächliche Rest fehlt');
|
||||
}
|
||||
return {
|
||||
type: 'CORRECTION',
|
||||
actualRemaining: dto.actualRemaining,
|
||||
reason: dto.reason ?? null,
|
||||
};
|
||||
}
|
||||
if (dto.quantity === undefined || !(dto.quantity > 0)) {
|
||||
throw new BadRequestException('Die Menge muss größer als 0 sein');
|
||||
}
|
||||
if (dto.reason === undefined || dto.reason.trim() === '') {
|
||||
throw new BadRequestException('Für eine Entsorgung ist ein Grund nötig');
|
||||
}
|
||||
return { type: 'DISPOSAL', quantity: dto.quantity, reason: dto.reason };
|
||||
}
|
||||
|
||||
/** Returns the medicine id read before the locks, to detect a concurrent change. */
|
||||
private async lockMedicinesThenLot(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
dto: UpdateLotDto,
|
||||
): Promise<string | null> {
|
||||
let readMedicineId: string | null = null;
|
||||
if (dto.medicineId !== undefined) {
|
||||
const current = await tx.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
select: { medicineId: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
readMedicineId = current.medicineId;
|
||||
await lockRowsForUpdate(tx, 'VeterinaryMedicine', [
|
||||
current.medicineId,
|
||||
dto.medicineId,
|
||||
]);
|
||||
}
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
return readMedicineId;
|
||||
}
|
||||
|
||||
/** Read under the medicine lock: an inactive medicine takes no new stock. */
|
||||
private async assertMedicineActive(
|
||||
tx: Prisma.TransactionClient,
|
||||
medicineId: string | undefined,
|
||||
): Promise<{ unit: MedicineUnit }> {
|
||||
const medicine = await tx.veterinaryMedicine.findUnique({
|
||||
where: { id: medicineId },
|
||||
select: { unit: true, active: true },
|
||||
});
|
||||
if (!medicine) {
|
||||
throw new NotFoundException(MEDICINE_NOT_FOUND);
|
||||
}
|
||||
if (!medicine.active) {
|
||||
throw new BadRequestException('Das Mittel ist deaktiviert.');
|
||||
}
|
||||
return medicine;
|
||||
}
|
||||
|
||||
private async assertMedicineChangeable(
|
||||
tx: Prisma.TransactionClient,
|
||||
before: LotRow,
|
||||
readMedicineId: string | null,
|
||||
dto: UpdateLotDto,
|
||||
): Promise<void> {
|
||||
if (readMedicineId !== before.medicineId) {
|
||||
throw new ConflictException(
|
||||
'Die Packung wurde gerade geändert. Bitte neu laden.',
|
||||
);
|
||||
}
|
||||
const target = await this.assertMedicineActive(tx, dto.medicineId);
|
||||
if (target.unit !== before.medicine.unit) {
|
||||
throw new BadRequestException(
|
||||
'Die Einheit des Mittels passt nicht zur Packung.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(await tx.veterinaryMedicineMovement.count({
|
||||
where: { lotId: before.id },
|
||||
})) > 0
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'Das Mittel ist festgelegt, sobald Buchungen existieren',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async detail(db: DbClient, id: string): Promise<LotDetailView> {
|
||||
const lot = await db.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
if (!lot) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
const [totals, movements] = await Promise.all([
|
||||
lotTotals(db, [id]),
|
||||
db.veterinaryMedicineMovement.findMany({
|
||||
where: { lotId: id },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
quantity: true,
|
||||
treatmentId: true,
|
||||
reason: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { ...toLotView(lot, totals.get(id)), movements };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
describeNullContracts,
|
||||
NullContract,
|
||||
} from '../common/validation/null-contract.spec-helper';
|
||||
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
|
||||
import { UpdateLotDto } from './dto/update-lot.dto';
|
||||
|
||||
const contracts: Record<string, NullContract> = {
|
||||
UpdateVeterinaryMedicineDto: {
|
||||
dto: UpdateVeterinaryMedicineDto,
|
||||
notNull: ['name', 'unit', 'prescriptionOnly'],
|
||||
clearable: [
|
||||
'activeIngredient',
|
||||
'authorisationNumber',
|
||||
'withdrawalPeriodDays',
|
||||
'dosePerColony',
|
||||
'doseNote',
|
||||
'applicationMethod',
|
||||
'minTemperatureC',
|
||||
'maxTemperatureC',
|
||||
'defaultSupplier',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateLotDto: {
|
||||
dto: UpdateLotDto,
|
||||
notNull: ['medicineId', 'purchasedOn', 'quantity'],
|
||||
clearable: [
|
||||
'batchNumber',
|
||||
'expiresOn',
|
||||
'supplier',
|
||||
'purchaseProof',
|
||||
'prescribingVet',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describeNullContracts('Veterinary medicine update DTOs', contracts);
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { CreateVeterinaryMedicineDto } from './dto/create-veterinary-medicine.dto';
|
||||
import { MedicineListQuery } from './dto/medicine-list-query.dto';
|
||||
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
|
||||
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
|
||||
|
||||
@ApiTags('veterinary-medicines')
|
||||
@ApiBearerAuth()
|
||||
@Controller('veterinary-medicines')
|
||||
export class VeterinaryMedicinesController {
|
||||
constructor(private readonly medicines: VeterinaryMedicinesService) {}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get()
|
||||
findAll(@Query() query: MedicineListQuery) {
|
||||
return this.medicines.findAll(query.active);
|
||||
}
|
||||
|
||||
// Before ':id', so 'suggestions' is not read as an id.
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get('suggestions')
|
||||
suggestions() {
|
||||
return this.medicines.suggestions();
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.medicines.findOne(id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateVeterinaryMedicineDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.medicines.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateVeterinaryMedicineDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.medicines.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Post(':id/deactivate')
|
||||
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.medicines.setActive(id, false, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Post(':id/reactivate')
|
||||
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.medicines.setActive(id, true, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VeterinaryMedicineLotsController } from './veterinary-medicine-lots.controller';
|
||||
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
|
||||
import { VeterinaryMedicinesController } from './veterinary-medicines.controller';
|
||||
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
VeterinaryMedicinesController,
|
||||
VeterinaryMedicineLotsController,
|
||||
],
|
||||
providers: [VeterinaryMedicinesService, VeterinaryMedicineLotsService],
|
||||
exports: [VeterinaryMedicinesService, VeterinaryMedicineLotsService],
|
||||
})
|
||||
export class VeterinaryMedicinesModule {}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const medicine = {
|
||||
id: 'm1',
|
||||
name: 'Mittel A',
|
||||
activeIngredient: null,
|
||||
authorisationNumber: null,
|
||||
prescriptionOnly: false,
|
||||
withdrawalPeriodDays: null,
|
||||
unit: 'ML',
|
||||
dosePerColony: null,
|
||||
doseNote: null,
|
||||
applicationMethod: null,
|
||||
minTemperatureC: null,
|
||||
maxTemperatureC: null,
|
||||
defaultSupplier: null,
|
||||
notes: null,
|
||||
active: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
|
||||
describe('VeterinaryMedicinesService', () => {
|
||||
let service: VeterinaryMedicinesService;
|
||||
let tx: {
|
||||
veterinaryMedicine: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
veterinaryMedicineLot: { count: jest.Mock; findMany: jest.Mock };
|
||||
veterinaryMedicineMovement: { groupBy: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
veterinaryMedicine: { findMany: jest.Mock };
|
||||
veterinaryMedicineLot: { findMany: jest.Mock };
|
||||
veterinaryMedicineMovement: { groupBy: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
|
||||
tx = {
|
||||
veterinaryMedicine: {
|
||||
findUnique: jest.fn().mockResolvedValue(medicine),
|
||||
create: jest.fn().mockResolvedValue(medicine),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve({ ...medicine, ...data }),
|
||||
),
|
||||
},
|
||||
veterinaryMedicineLot: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
veterinaryMedicineMovement: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||
$queryRaw: jest.fn(),
|
||||
veterinaryMedicine: { findMany: jest.fn() },
|
||||
veterinaryMedicineLot: { findMany: jest.fn() },
|
||||
veterinaryMedicineMovement: { groupBy: jest.fn() },
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new VeterinaryMedicinesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects changing the unit once a lot exists with 409', async () => {
|
||||
tx.veterinaryMedicineLot.count.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
service.update('m1', { unit: 'G' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(tx.veterinaryMedicine.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the unit it already has even with lots', async () => {
|
||||
tx.veterinaryMedicineLot.count.mockResolvedValue(1);
|
||||
|
||||
await service.update('m1', { unit: 'ML' }, 'u1');
|
||||
|
||||
expect(tx.veterinaryMedicine.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows editing every other field of a medicine with lots', async () => {
|
||||
tx.veterinaryMedicineLot.count.mockResolvedValue(3);
|
||||
|
||||
const view = await service.update(
|
||||
'm1',
|
||||
{ name: 'Neu', withdrawalPeriodDays: 0, notes: null },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(view.name).toBe('Neu');
|
||||
expect(tx.veterinaryMedicine.update).toHaveBeenCalledWith({
|
||||
where: { id: 'm1' },
|
||||
data: { name: 'Neu', withdrawalPeriodDays: 0, notes: null },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'VeterinaryMedicine',
|
||||
action: 'UPDATE',
|
||||
beforeData: medicine,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the medicine before it reads the row to change', async () => {
|
||||
const order: string[] = [];
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx.veterinaryMedicine.findUnique.mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(medicine);
|
||||
});
|
||||
|
||||
await service.update('m1', { name: 'Neu' }, 'u1');
|
||||
|
||||
expect(order).toEqual(['lock', 'read']);
|
||||
});
|
||||
|
||||
it('setActive without a change returns the view and writes no audit row', async () => {
|
||||
const view = await service.setActive('m1', true, 'u1');
|
||||
|
||||
expect(view.active).toBe(true);
|
||||
expect(tx.veterinaryMedicine.update).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setActive audits a real change', async () => {
|
||||
await service.setActive('m1', false, 'u1');
|
||||
|
||||
expect(tx.veterinaryMedicine.update).toHaveBeenCalledWith({
|
||||
where: { id: 'm1' },
|
||||
data: { active: false },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a minimum above the maximum temperature with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
...medicine,
|
||||
maxTemperatureC: 20,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('m1', { minTemperatureC: 25 }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('creates a medicine with its audit entry in one transaction', async () => {
|
||||
const view = await service.create({ name: 'Mittel A', unit: 'ML' }, 'u1');
|
||||
|
||||
expect(view).toMatchObject({ id: 'm1', totalRemaining: 0, lotCount: 0 });
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'CREATE', entityId: 'm1' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('suggestions skip names already in the catalogue regardless of case and give the latest values', async () => {
|
||||
prisma.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
name: 'Oxuvar',
|
||||
treatmentCount: 4,
|
||||
activeIngredient: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 0,
|
||||
method: 'Träufeln',
|
||||
amount: '5 ml',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.suggestions();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'Oxuvar',
|
||||
treatmentCount: 4,
|
||||
latest: {
|
||||
activeIngredient: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 0,
|
||||
method: 'Träufeln',
|
||||
amount: '5 ml',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const query = (prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0];
|
||||
expect(query.sql).toContain('lower(btrim(');
|
||||
expect(query.sql).toContain('NOT EXISTS');
|
||||
expect(query.sql).toContain('LIMIT 50');
|
||||
});
|
||||
|
||||
it('lists the catalogue with the total rest and the next expiry without a query per medicine', async () => {
|
||||
prisma.veterinaryMedicine.findMany.mockResolvedValue([
|
||||
medicine,
|
||||
{ ...medicine, id: 'm2', name: 'Mittel B' },
|
||||
]);
|
||||
prisma.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'l1',
|
||||
medicineId: 'm1',
|
||||
quantity: 10,
|
||||
expiresOn: new Date('2027-05-01T00:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
medicineId: 'm1',
|
||||
quantity: 4,
|
||||
expiresOn: new Date('2026-12-01T00:00:00Z'),
|
||||
},
|
||||
{ id: 'l3', medicineId: 'm1', quantity: 2, expiresOn: null },
|
||||
]);
|
||||
prisma.veterinaryMedicineMovement.groupBy.mockResolvedValue([
|
||||
{ lotId: 'l1', _sum: { quantity: -10 }, _count: { _all: 2 } },
|
||||
]);
|
||||
|
||||
const list = await service.findAll();
|
||||
|
||||
expect(prisma.veterinaryMedicineMovement.groupBy).toHaveBeenCalledTimes(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
id: 'm1',
|
||||
lotCount: 3,
|
||||
// l1 is used up (0), l2 has 4, l3 has 2
|
||||
totalRemaining: 6,
|
||||
// l1 has nothing left, so the next expiry is l2's
|
||||
nextExpiry: '2026-12-01',
|
||||
});
|
||||
expect(list[1]).toMatchObject({
|
||||
id: 'm2',
|
||||
lotCount: 0,
|
||||
totalRemaining: 0,
|
||||
nextExpiry: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, VeterinaryMedicine } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
toLotView,
|
||||
lotTotals,
|
||||
toMedicineView,
|
||||
LOT_MEDICINE_INCLUDE,
|
||||
} from './medicine-views';
|
||||
import type {
|
||||
LotView,
|
||||
MedicineSuggestion,
|
||||
MedicineView,
|
||||
} from './medicine-ledger';
|
||||
import { CreateVeterinaryMedicineDto } from './dto/create-veterinary-medicine.dto';
|
||||
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
|
||||
|
||||
const AUDIT_ENTITY = 'VeterinaryMedicine';
|
||||
const SUGGESTION_LIMIT = 50;
|
||||
|
||||
interface SuggestionRow {
|
||||
name: string;
|
||||
treatmentCount: number;
|
||||
activeIngredient: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
method: string | null;
|
||||
amount: string | null;
|
||||
}
|
||||
|
||||
function assertTemperatureRange(
|
||||
min: number | null | undefined,
|
||||
max: number | null | undefined,
|
||||
): void {
|
||||
if (min != null && max != null && min > max) {
|
||||
throw new BadRequestException(
|
||||
'Die Mindesttemperatur darf nicht über der Höchsttemperatur liegen',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VeterinaryMedicinesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
/** The catalogue with total rest and next expiry - two grouped queries, no N+1. */
|
||||
async findAll(active?: boolean): Promise<MedicineView[]> {
|
||||
const medicines = await this.prisma.veterinaryMedicine.findMany({
|
||||
where: { active },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const lots = await this.prisma.veterinaryMedicineLot.findMany({
|
||||
where: { medicineId: { in: medicines.map((medicine) => medicine.id) } },
|
||||
select: { id: true, medicineId: true, quantity: true, expiresOn: true },
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
this.prisma,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
return medicines.map((medicine) =>
|
||||
toMedicineView(
|
||||
medicine,
|
||||
lots.filter((lot) => lot.medicineId === medicine.id),
|
||||
totals,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<MedicineView & { lots: LotView[] }> {
|
||||
const medicine = await this.prisma.veterinaryMedicine.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!medicine) {
|
||||
throw new NotFoundException('Tierarzneimittel nicht gefunden');
|
||||
}
|
||||
return this.viewWithLots(this.prisma, medicine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Own product names from treatments that are not in the catalogue yet
|
||||
* (case and surrounding blanks ignored), with the values of the latest
|
||||
* treatment. Nothing is created by looking at them.
|
||||
*/
|
||||
async suggestions(): Promise<MedicineSuggestion[]> {
|
||||
const rows = await this.prisma.$queryRaw<SuggestionRow[]>(Prisma.sql`
|
||||
SELECT s.name, s."treatmentCount", s."activeIngredient",
|
||||
s."withdrawalPeriodDays", s.method, s.amount
|
||||
FROM (
|
||||
SELECT DISTINCT ON (g.key) g.name, g."treatmentCount",
|
||||
g."activeIngredient", g."withdrawalPeriodDays", g.method, g.amount
|
||||
FROM (
|
||||
SELECT lower(btrim(t.product)) AS key, btrim(t.product) AS name,
|
||||
t.date, t."createdAt", t.id, t."activeIngredient",
|
||||
t."withdrawalPeriodDays", t.method, t.amount,
|
||||
count(*) OVER (PARTITION BY lower(btrim(t.product)))::int
|
||||
AS "treatmentCount"
|
||||
FROM "Treatment" t
|
||||
WHERE btrim(t.product) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "VeterinaryMedicine" m
|
||||
WHERE lower(btrim(m.name)) = lower(btrim(t.product))
|
||||
)
|
||||
) g
|
||||
ORDER BY g.key, g.date DESC, g."createdAt" DESC, g.id DESC
|
||||
) s
|
||||
ORDER BY s."treatmentCount" DESC, lower(s.name) ASC
|
||||
LIMIT ${Prisma.raw(String(SUGGESTION_LIMIT))}
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
name: row.name,
|
||||
treatmentCount: Number(row.treatmentCount),
|
||||
latest: {
|
||||
activeIngredient: row.activeIngredient,
|
||||
withdrawalPeriodDays: row.withdrawalPeriodDays,
|
||||
method: row.method,
|
||||
amount: row.amount,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateVeterinaryMedicineDto,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
assertTemperatureRange(dto.minTemperatureC, dto.maxTemperatureC);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.veterinaryMedicine.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return toMedicineView(created, [], new Map());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit is fixed once a lot exists (409). The medicine row is locked
|
||||
* first: creating a lot takes the same lock, so the check cannot be raced.
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateVeterinaryMedicineDto,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockAndRead(tx, id);
|
||||
if (
|
||||
dto.unit !== undefined &&
|
||||
dto.unit !== before.unit &&
|
||||
(await tx.veterinaryMedicineLot.count({ where: { medicineId: id } })) >
|
||||
0
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'Einheit ist festgelegt, sobald Packungen erfasst sind',
|
||||
);
|
||||
}
|
||||
assertTemperatureRange(
|
||||
dto.minTemperatureC === undefined
|
||||
? before.minTemperatureC
|
||||
: dto.minTemperatureC,
|
||||
dto.maxTemperatureC === undefined
|
||||
? before.maxTemperatureC
|
||||
: dto.maxTemperatureC,
|
||||
);
|
||||
const updated = await tx.veterinaryMedicine.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
return this.recordUpdate(tx, before, updated, actingUserId);
|
||||
});
|
||||
}
|
||||
|
||||
/** History instead of deleting: an inactive medicine can no longer be treated with. */
|
||||
async setActive(
|
||||
id: string,
|
||||
active: boolean,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockAndRead(tx, id);
|
||||
if (before.active === active) {
|
||||
// Nothing changes: no write and no audit row.
|
||||
return this.viewOf(tx, before);
|
||||
}
|
||||
const updated = await tx.veterinaryMedicine.update({
|
||||
where: { id },
|
||||
data: { active },
|
||||
});
|
||||
return this.recordUpdate(tx, before, updated, actingUserId);
|
||||
});
|
||||
}
|
||||
|
||||
private async lockAndRead(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
): Promise<VeterinaryMedicine> {
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicine', id))) {
|
||||
throw new NotFoundException('Tierarzneimittel nicht gefunden');
|
||||
}
|
||||
const medicine = await tx.veterinaryMedicine.findUnique({ where: { id } });
|
||||
if (!medicine) {
|
||||
throw new NotFoundException('Tierarzneimittel nicht gefunden');
|
||||
}
|
||||
return medicine;
|
||||
}
|
||||
|
||||
private async recordUpdate(
|
||||
tx: Prisma.TransactionClient,
|
||||
before: VeterinaryMedicine,
|
||||
updated: VeterinaryMedicine,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: updated.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return this.viewOf(tx, updated);
|
||||
}
|
||||
|
||||
private async viewOf(
|
||||
tx: Prisma.TransactionClient,
|
||||
medicine: VeterinaryMedicine,
|
||||
): Promise<MedicineView> {
|
||||
const lots = await tx.veterinaryMedicineLot.findMany({
|
||||
where: { medicineId: medicine.id },
|
||||
select: { id: true, quantity: true, expiresOn: true },
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
tx,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
return toMedicineView(medicine, lots, totals);
|
||||
}
|
||||
|
||||
private async viewWithLots(
|
||||
db: DbClient,
|
||||
medicine: VeterinaryMedicine,
|
||||
): Promise<MedicineView & { lots: LotView[] }> {
|
||||
const lots = await db.veterinaryMedicineLot.findMany({
|
||||
where: { medicineId: medicine.id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
orderBy: [{ purchasedOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
db,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
return {
|
||||
...toMedicineView(medicine, lots, totals),
|
||||
lots: lots.map((lot) => toLotView(lot, totals.get(lot.id))),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,20 @@ describe('WebhookEventHandlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('none of the four new events is forwarded to webhooks', async () => {
|
||||
const herdEvents = [
|
||||
['ColonyCreated', { apiaryId: 'a1' }],
|
||||
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
|
||||
['ApiaryChanged', { change: 'LOCATION_CHANGED' }],
|
||||
['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }],
|
||||
] as const;
|
||||
for (const [type, payload] of herdEvents) {
|
||||
expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type);
|
||||
await forward(envelope(type, 'x1', payload as never));
|
||||
}
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers forward as "webhooks.forward" for every mapped type', () => {
|
||||
// The decorator stores its metadata on the method itself.
|
||||
const method = Reflect.get(WebhookEventHandlers.prototype, 'forward');
|
||||
|
||||
@@ -30,6 +30,15 @@ describe('Apiaries (e2e)', () => {
|
||||
let noColonyToken: string;
|
||||
let fieldApiaryId: string;
|
||||
|
||||
// The ApiaryChanged codes of one apiary, oldest first.
|
||||
async function changesOf(id: string): Promise<string[]> {
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { type: 'ApiaryChanged', aggregateId: id },
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
});
|
||||
return events.map((event) => (event.payload as { change: string }).change);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
@@ -85,6 +94,12 @@ describe('Apiaries (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: { in: [apiaryId, fieldApiaryId].filter(Boolean) },
|
||||
},
|
||||
});
|
||||
if (fieldApiaryId) {
|
||||
await prisma.task.deleteMany({
|
||||
where: { title: { startsWith: 'field-e2e-' } },
|
||||
@@ -154,6 +169,7 @@ describe('Apiaries (e2e)', () => {
|
||||
.send({ notes: 'aktualisiert' })
|
||||
.expect(200);
|
||||
expect((updated.body as ApiaryBody).notes).toBe('aktualisiert');
|
||||
expect(await changesOf(apiaryId)).toEqual(['CREATED']);
|
||||
|
||||
const deactivated = await request(app.getHttpServer())
|
||||
.post(`/api/v1/apiaries/${apiaryId}/deactivate`)
|
||||
@@ -166,6 +182,11 @@ describe('Apiaries (e2e)', () => {
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(201);
|
||||
expect((reactivated.body as ApiaryBody).status).toBe('ACTIVE');
|
||||
expect(await changesOf(apiaryId)).toEqual([
|
||||
'CREATED',
|
||||
'DEACTIVATED',
|
||||
'REACTIVATED',
|
||||
]);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/apiaries/${apiaryId}/reactivate`)
|
||||
@@ -173,6 +194,23 @@ describe('Apiaries (e2e)', () => {
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('updating only the name of an apiary leaves no ApiaryChanged', async () => {
|
||||
const before = await changesOf(apiaryId);
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/apiaries/${apiaryId}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ name: 'Heimstand umbenannt' })
|
||||
.expect(200);
|
||||
expect(await changesOf(apiaryId)).toEqual(before);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/apiaries/${apiaryId}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ address: 'Neuer Weg 3' })
|
||||
.expect(200);
|
||||
expect(await changesOf(apiaryId)).toEqual([...before, 'LOCATION_CHANGED']);
|
||||
});
|
||||
|
||||
it('lets publicLocationLabel be set, changed and cleared with null', async () => {
|
||||
const patch = (body: object) =>
|
||||
request(app.getHttpServer())
|
||||
|
||||
@@ -163,6 +163,15 @@ describe('Colonies (e2e)', () => {
|
||||
colonyId = created.id;
|
||||
});
|
||||
|
||||
it('a new colony leaves one ColonyCreated event', async () => {
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { type: 'ColonyCreated', aggregateId: colonyId },
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].aggregateType).toBe('Colony');
|
||||
expect(events[0].payload).toEqual({ apiaryId: apiaryAId });
|
||||
});
|
||||
|
||||
it('moves the colony to another apiary and records history', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||
@@ -176,6 +185,15 @@ describe('Colonies (e2e)', () => {
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
expect((history.body as ColonyHistoryBody).apiaryHistory).toHaveLength(1);
|
||||
|
||||
const moved = await prisma.domainEvent.findMany({
|
||||
where: { type: 'ColonyMoved', aggregateId: colonyId },
|
||||
});
|
||||
expect(moved).toHaveLength(1);
|
||||
expect(moved[0].payload).toEqual({
|
||||
fromApiaryId: apiaryAId,
|
||||
toApiaryId: apiaryBId,
|
||||
});
|
||||
});
|
||||
|
||||
it('splits the colony into a new nucleus colony', async () => {
|
||||
@@ -289,7 +307,10 @@ describe('Colonies (e2e)', () => {
|
||||
expect((response.body as ColonyBody).status).toBe('DISSOLVED');
|
||||
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: colonyId },
|
||||
where: {
|
||||
aggregateId: colonyId,
|
||||
type: { in: ['ColonyStatusChanged', 'ColonyEnded'] },
|
||||
},
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
});
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
|
||||
@@ -221,3 +221,398 @@ describe('Documents (e2e)', () => {
|
||||
).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Documents of specialist records (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}`;
|
||||
const password = 'SuperSecret123!';
|
||||
const roleIds: string[] = [];
|
||||
const userIds: string[] = [];
|
||||
let apiaryId: string;
|
||||
let medicineId: string;
|
||||
let lotId: string;
|
||||
let noticeId: string;
|
||||
let restrictionId: string;
|
||||
let docsOnlyToken: string;
|
||||
let docsAndManageToken: string;
|
||||
let staffToken: string;
|
||||
let beekeeperToken: string;
|
||||
let lotDocumentId: string;
|
||||
let noticeDocumentId: string;
|
||||
let restrictionDocumentId: string;
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||
|
||||
async function createUser(
|
||||
label: string,
|
||||
options: { permissions?: string[]; roleKey?: string },
|
||||
): Promise<string> {
|
||||
let roleId: string;
|
||||
if (options.roleKey) {
|
||||
roleId = (
|
||||
await prisma.role.findUniqueOrThrow({ where: { key: options.roleKey } })
|
||||
).id;
|
||||
} else {
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `DOC_SPECIAL_${label.toUpperCase()}_${suffix}`,
|
||||
name: `Documents special ${label}`,
|
||||
permissions: {
|
||||
create: (options.permissions ?? []).map((key) => ({
|
||||
permission: { connect: { key } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleIds.push(role.id);
|
||||
roleId = role.id;
|
||||
}
|
||||
const email = `doc-special-${label}-${suffix}@example.com`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: `Documents special ${label}`,
|
||||
role: { connect: { id: roleId } },
|
||||
},
|
||||
});
|
||||
userIds.push(user.id);
|
||||
return (await login(app, email, password)).accessToken;
|
||||
}
|
||||
|
||||
const upload = (
|
||||
token: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
fileName = 'beleg.png',
|
||||
) =>
|
||||
api()
|
||||
.post('/api/v1/documents')
|
||||
.set(auth(token))
|
||||
.field('entityType', entityType)
|
||||
.field('entityId', entityId)
|
||||
.attach('file', PNG_BYTES, {
|
||||
filename: fileName,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const crud = [
|
||||
'documents:read',
|
||||
'documents:upload',
|
||||
'documents:update',
|
||||
'documents:delete',
|
||||
];
|
||||
docsOnlyToken = await createUser('docsonly', { permissions: crud });
|
||||
docsAndManageToken = await createUser('docsmanage', {
|
||||
permissions: [...crud, 'compliance:read', 'compliance:manage'],
|
||||
});
|
||||
staffToken = await createUser('staff', { roleKey: 'STAFF' });
|
||||
beekeeperToken = await createUser('beekeeper', { roleKey: 'BEEKEEPER' });
|
||||
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Dok-Spezial-Stand ${suffix}` },
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
const medicine = await prisma.veterinaryMedicine.create({
|
||||
data: { name: `Dok-Mittel ${suffix}`, unit: 'ML' },
|
||||
});
|
||||
medicineId = medicine.id;
|
||||
const lot = await prisma.veterinaryMedicineLot.create({
|
||||
data: { medicineId, purchasedOn: new Date('2026-05-01'), quantity: 10 },
|
||||
});
|
||||
lotId = lot.id;
|
||||
const notice = await prisma.authorityNotice.create({
|
||||
data: {
|
||||
kind: 'REGISTRATION',
|
||||
referenceDate: new Date('2026-05-01'),
|
||||
countedAt: new Date('2026-05-01'),
|
||||
snapshot: {},
|
||||
totalColonies: 0,
|
||||
},
|
||||
});
|
||||
noticeId = notice.id;
|
||||
const restriction = await prisma.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
disease: 'Test',
|
||||
startsOn: new Date('2026-05-01'),
|
||||
},
|
||||
});
|
||||
restrictionId = restriction.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.document.deleteMany({
|
||||
where: { entityId: { in: [lotId, noticeId, restrictionId] } },
|
||||
});
|
||||
await prisma.apiaryRestriction.deleteMany({ where: { id: restrictionId } });
|
||||
await prisma.authorityNotice.deleteMany({ where: { id: noticeId } });
|
||||
await prisma.veterinaryMedicineLot.deleteMany({ where: { id: lotId } });
|
||||
await prisma.veterinaryMedicine.deleteMany({ where: { id: medicineId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||
await fs.rm('./test-uploads', { recursive: true, force: true });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('STAFF uploads a purchase receipt to a lot and downloads it again', async () => {
|
||||
const response = await upload(staffToken, 'MEDICINE_LOT', lotId).expect(
|
||||
201,
|
||||
);
|
||||
lotDocumentId = (response.body as DocumentBody).id;
|
||||
|
||||
const list = await api()
|
||||
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
|
||||
.set(auth(staffToken))
|
||||
.expect(200);
|
||||
expect(
|
||||
(list.body as DocumentBody[]).some((entry) => entry.id === lotDocumentId),
|
||||
).toBe(true);
|
||||
|
||||
const download = await api()
|
||||
.get(`/api/v1/documents/${lotDocumentId}/download`)
|
||||
.set(auth(staffToken))
|
||||
.expect(200);
|
||||
expect(Buffer.compare(download.body as Buffer, PNG_BYTES)).toBe(0);
|
||||
expect(download.headers['cache-control']).toBe('no-store');
|
||||
expect(list.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document', async () => {
|
||||
await api()
|
||||
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.expect(403);
|
||||
await api()
|
||||
.get(`/api/v1/documents/${lotDocumentId}/download`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('a user with documents:read but without compliance:read gets 403 listing AUTHORITY_NOTICE and APIARY_RESTRICTION documents', async () => {
|
||||
await api()
|
||||
.get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.expect(403);
|
||||
await api()
|
||||
.get(
|
||||
`/api/v1/documents?entityType=APIARY_RESTRICTION&entityId=${restrictionId}`,
|
||||
)
|
||||
.set(auth(docsOnlyToken))
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('a user with documents:upload but without compliance:manage gets 403 uploading to an AUTHORITY_NOTICE', async () => {
|
||||
const before = await prisma.document.count({
|
||||
where: { entityId: noticeId },
|
||||
});
|
||||
await upload(docsOnlyToken, 'AUTHORITY_NOTICE', noticeId).expect(403);
|
||||
await upload(docsOnlyToken, 'APIARY_RESTRICTION', restrictionId).expect(
|
||||
403,
|
||||
);
|
||||
// STAFF has no compliance:manage either
|
||||
await upload(staffToken, 'AUTHORITY_NOTICE', noticeId).expect(403);
|
||||
expect(await prisma.document.count({ where: { entityId: noticeId } })).toBe(
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
it('a user with compliance:manage uploads, lists and downloads authority documents', async () => {
|
||||
const notice = await upload(
|
||||
docsAndManageToken,
|
||||
'AUTHORITY_NOTICE',
|
||||
noticeId,
|
||||
).expect(201);
|
||||
noticeDocumentId = (notice.body as DocumentBody).id;
|
||||
const restriction = await upload(
|
||||
docsAndManageToken,
|
||||
'APIARY_RESTRICTION',
|
||||
restrictionId,
|
||||
).expect(201);
|
||||
restrictionDocumentId = (restriction.body as DocumentBody).id;
|
||||
|
||||
await api()
|
||||
.get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`)
|
||||
.set(auth(docsAndManageToken))
|
||||
.expect(200);
|
||||
await api()
|
||||
.get(`/api/v1/documents/${noticeDocumentId}/download`)
|
||||
.set(auth(docsAndManageToken))
|
||||
.expect(200);
|
||||
// documents:read alone must not reach it
|
||||
await api()
|
||||
.get(`/api/v1/documents/${restrictionDocumentId}/download`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('re-linking a document to an APIARY_RESTRICTION needs compliance:manage', async () => {
|
||||
// a document on a harmless record, owned by the docs-only user
|
||||
const apiaryDoc = await upload(docsOnlyToken, 'APIARY', apiaryId).expect(
|
||||
201,
|
||||
);
|
||||
const id = (apiaryDoc.body as DocumentBody).id;
|
||||
|
||||
await api()
|
||||
.patch(`/api/v1/documents/${id}`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId })
|
||||
.expect(403);
|
||||
expect(
|
||||
(await prisma.document.findUniqueOrThrow({ where: { id } })).entityType,
|
||||
).toBe('APIARY');
|
||||
|
||||
await api()
|
||||
.patch(`/api/v1/documents/${id}`)
|
||||
.set(auth(docsAndManageToken))
|
||||
.send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId })
|
||||
.expect(200);
|
||||
|
||||
// moving it back out needs the right of the OLD type too
|
||||
await api()
|
||||
.patch(`/api/v1/documents/${id}`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.send({ entityType: 'APIARY', entityId: apiaryId })
|
||||
.expect(403);
|
||||
await api()
|
||||
.delete(`/api/v1/documents/${id}`)
|
||||
.set(auth(docsAndManageToken))
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('renaming a restricted document without the right is denied', () => {
|
||||
return api()
|
||||
.patch(`/api/v1/documents/${lotDocumentId}`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.send({ fileName: 'neu.png' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('deleting a MEDICINE_LOT document needs veterinary-medicines:update', async () => {
|
||||
await api()
|
||||
.delete(`/api/v1/documents/${lotDocumentId}`)
|
||||
.set(auth(docsOnlyToken))
|
||||
.expect(403);
|
||||
expect(await prisma.document.count({ where: { id: lotDocumentId } })).toBe(
|
||||
1,
|
||||
);
|
||||
await api()
|
||||
.delete(`/api/v1/documents/${lotDocumentId}`)
|
||||
.set(auth(beekeeperToken))
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('answers 403, not 404, for an unauthorised upload to a missing MEDICINE_LOT record', async () => {
|
||||
await upload(docsOnlyToken, 'MEDICINE_LOT', 'nope').expect(403);
|
||||
});
|
||||
|
||||
describe('read/write split over HTTP', () => {
|
||||
async function userWith(label: string, permissions: string[]) {
|
||||
return createUser(label, { permissions });
|
||||
}
|
||||
|
||||
it('compliance:read without compliance:manage lists (200) but cannot upload (403)', async () => {
|
||||
const token = await userWith('comp-read', [
|
||||
'documents:read',
|
||||
'documents:upload',
|
||||
'compliance:read',
|
||||
]);
|
||||
await api()
|
||||
.get(
|
||||
`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`,
|
||||
)
|
||||
.set(auth(token))
|
||||
.expect(200);
|
||||
await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(403);
|
||||
await upload(token, 'APIARY_RESTRICTION', restrictionId).expect(403);
|
||||
});
|
||||
|
||||
it('veterinary-medicines:read without :update lists and downloads (200) but cannot delete or patch (403)', async () => {
|
||||
const token = await userWith('med-read', [
|
||||
'documents:read',
|
||||
'documents:update',
|
||||
'documents:delete',
|
||||
'veterinary-medicines:read',
|
||||
]);
|
||||
const created = await upload(
|
||||
beekeeperToken,
|
||||
'MEDICINE_LOT',
|
||||
lotId,
|
||||
).expect(201);
|
||||
const id = (created.body as DocumentBody).id;
|
||||
await api()
|
||||
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
|
||||
.set(auth(token))
|
||||
.expect(200);
|
||||
await api()
|
||||
.get(`/api/v1/documents/${id}/download`)
|
||||
.set(auth(token))
|
||||
.expect(200);
|
||||
await api()
|
||||
.delete(`/api/v1/documents/${id}`)
|
||||
.set(auth(token))
|
||||
.expect(403);
|
||||
await api()
|
||||
.patch(`/api/v1/documents/${id}`)
|
||||
.set(auth(token))
|
||||
.send({ fileName: 'x.png' })
|
||||
.expect(403);
|
||||
expect(await prisma.document.count({ where: { id } })).toBe(1);
|
||||
});
|
||||
|
||||
it('compliance:manage without compliance:read uploads (201) but cannot list (403)', async () => {
|
||||
const token = await userWith('comp-manage', [
|
||||
'documents:read',
|
||||
'documents:upload',
|
||||
'compliance:manage',
|
||||
]);
|
||||
await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(201);
|
||||
await api()
|
||||
.get(
|
||||
`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`,
|
||||
)
|
||||
.set(auth(token))
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 404 for a missing record of the new types when the user has the right', async () => {
|
||||
await upload(staffToken, 'MEDICINE_LOT', 'does-not-exist').expect(404);
|
||||
await upload(docsAndManageToken, 'AUTHORITY_NOTICE', 'nope').expect(404);
|
||||
await upload(docsAndManageToken, 'APIARY_RESTRICTION', 'nope').expect(404);
|
||||
});
|
||||
|
||||
it('uploads keep the MIME allowlist and signature check for the new types', async () => {
|
||||
await api()
|
||||
.post('/api/v1/documents')
|
||||
.set(auth(staffToken))
|
||||
.field('entityType', 'MEDICINE_LOT')
|
||||
.field('entityId', lotId)
|
||||
.attach('file', Buffer.from('MZ not allowed'), {
|
||||
filename: 'x.exe',
|
||||
contentType: 'application/octet-stream',
|
||||
})
|
||||
.expect(400);
|
||||
await api()
|
||||
.post('/api/v1/documents')
|
||||
.set(auth(staffToken))
|
||||
.field('entityType', 'MEDICINE_LOT')
|
||||
.field('entityId', lotId)
|
||||
.attach('file', Buffer.from('plain text, not a png'), {
|
||||
filename: 'fake.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
"batchNumber",
|
||||
"honeyType",
|
||||
"lotNumber",
|
||||
"bottlingDate",
|
||||
"bestBeforeDate",
|
||||
"jarSize",
|
||||
"productName",
|
||||
"legalName",
|
||||
"originCountry",
|
||||
"foodBusinessName",
|
||||
"foodBusinessAddress",
|
||||
"bestBeforeLine",
|
||||
"lotLine"
|
||||
]
|
||||
@@ -70,6 +70,15 @@ describe('Import (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const colonyIds = (
|
||||
await prisma.colony.findMany({
|
||||
where: { apiaryId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((colony) => colony.id);
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { apiaryId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.apiary.deleteMany({
|
||||
@@ -143,6 +152,13 @@ describe('Import (e2e)', () => {
|
||||
const firstReport = firstResponse.body as ImportReportBody;
|
||||
expect(firstReport.created).toBe(1);
|
||||
|
||||
const colony = await prisma.colony.findUniqueOrThrow({ where: { code } });
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { type: 'ColonyCreated', aggregateId: colony.id },
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].payload).toEqual({ apiaryId });
|
||||
|
||||
const secondResponse = await request(app.getHttpServer())
|
||||
.post('/api/v1/import/colonies')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
@@ -154,6 +170,12 @@ describe('Import (e2e)', () => {
|
||||
const secondReport = secondResponse.body as ImportReportBody;
|
||||
expect(secondReport.created).toBe(0);
|
||||
expect(secondReport.skipped).toBe(1);
|
||||
// The skipped re-import leaves no second event.
|
||||
expect(
|
||||
await prisma.domainEvent.count({
|
||||
where: { type: 'ColonyCreated', aggregateId: colony.id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('reports an error when the referenced apiary is unknown', async () => {
|
||||
|
||||
@@ -135,6 +135,46 @@ describe('Products (e2e)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('stores legalName and clears it with null', async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/api/v1/products')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ name: `Rechtsname ${Date.now()}`, legalName: ' Blütenhonig ' })
|
||||
.expect(201);
|
||||
const id = (created.body as ProductBody).id;
|
||||
try {
|
||||
expect((created.body as { legalName: string | null }).legalName).toBe(
|
||||
'Blütenhonig',
|
||||
);
|
||||
|
||||
const changed = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/products/${id}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ legalName: 'Waldhonig' })
|
||||
.expect(200);
|
||||
expect((changed.body as { legalName: string | null }).legalName).toBe(
|
||||
'Waldhonig',
|
||||
);
|
||||
|
||||
const cleared = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/products/${id}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ legalName: null })
|
||||
.expect(200);
|
||||
expect(
|
||||
(cleared.body as { legalName: string | null }).legalName,
|
||||
).toBeNull();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/products/${id}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ legalName: 'x'.repeat(201) })
|
||||
.expect(400);
|
||||
} finally {
|
||||
await prisma.product.deleteMany({ where: { id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a shelf life outside 1 to 60 months', async () => {
|
||||
for (const shelfLifeMonths of [0, 61, 1.5]) {
|
||||
await request(app.getHttpServer())
|
||||
|
||||
@@ -31,6 +31,8 @@ describe('Public batch origin page (e2e)', () => {
|
||||
let bottlingBatchId: string;
|
||||
const lotNumber = `WH-E2E-${Date.now()}`;
|
||||
const apiaryName = `Stand Wald ${Date.now()}`;
|
||||
const legalName = `Rechtsname-${Date.now()}-honig`;
|
||||
const foodBusinessAddress = `Geheimweg ${Date.now()}, 86450 Altenmünster`;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
@@ -90,7 +92,7 @@ describe('Public batch origin page (e2e)', () => {
|
||||
});
|
||||
honeyBatchId = honeyBatch.id;
|
||||
const product = await prisma.product.create({
|
||||
data: { name: 'Sommerhonig 500g' },
|
||||
data: { name: 'Sommerhonig 500g', legalName: legalName },
|
||||
});
|
||||
productId = product.id;
|
||||
const bottlingBatch = await prisma.bottlingBatch.create({
|
||||
@@ -139,6 +141,40 @@ describe('Public batch origin page (e2e)', () => {
|
||||
expect(body.harvestDates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('the public origin page exposes neither foodBusinessAddress nor legalName', async () => {
|
||||
const before = await prisma.siteSettings.findUnique({
|
||||
where: { id: 'singleton' },
|
||||
});
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: { foodBusinessName: 'Imkerei Geheim', foodBusinessAddress },
|
||||
create: {
|
||||
id: 'singleton',
|
||||
foodBusinessName: 'Imkerei Geheim',
|
||||
foodBusinessAddress,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/public/batches/${lotNumber}`)
|
||||
.expect(200);
|
||||
const text = JSON.stringify(response.body);
|
||||
expect(text).not.toContain(foodBusinessAddress);
|
||||
expect(text).not.toContain('Imkerei Geheim');
|
||||
expect(text).not.toContain(legalName);
|
||||
expect(response.body).not.toHaveProperty('legalName');
|
||||
expect(response.body).not.toHaveProperty('foodBusinessAddress');
|
||||
} finally {
|
||||
await prisma.siteSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
foodBusinessName: before?.foodBusinessName ?? null,
|
||||
foodBusinessAddress: before?.foodBusinessAddress ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('never leaks the apiary name, location, address or coordinates', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/public/batches/${lotNumber}`)
|
||||
|
||||
@@ -103,7 +103,11 @@ describe('Shop sync (e2e)', () => {
|
||||
},
|
||||
});
|
||||
const product = await prisma.product.create({
|
||||
data: { name: `Sommertracht 500 g ${suffix}`, sku: `SKU-${suffix}` },
|
||||
data: {
|
||||
name: `Sommertracht 500 g ${suffix}`,
|
||||
sku: `SKU-${suffix}`,
|
||||
legalName: `Rechtsname-${suffix}`,
|
||||
},
|
||||
});
|
||||
productId = product.id;
|
||||
const bottling = await prisma.bottlingBatch.create({
|
||||
@@ -163,6 +167,22 @@ describe('Shop sync (e2e)', () => {
|
||||
expect(raw).not.toContain('"userId"');
|
||||
});
|
||||
|
||||
it('carries no label master data in the product payload', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/shop-sync')
|
||||
.set('Authorization', `Bearer ${syncToken}`)
|
||||
.expect(200);
|
||||
const body = response.body as ShopSyncFeedBody;
|
||||
const product = body.products.find((p) => p.id === productId);
|
||||
|
||||
expect(product).toBeDefined();
|
||||
expect(product).not.toHaveProperty('legalName');
|
||||
const raw = JSON.stringify(response.body);
|
||||
expect(raw).not.toContain('legalName');
|
||||
expect(raw).not.toContain('foodBusiness');
|
||||
expect(raw).not.toContain(`Rechtsname-${suffix}`);
|
||||
});
|
||||
|
||||
it('grants the sync account nothing beyond the feed', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/harvests')
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
interface MedicineBody {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
active: boolean;
|
||||
totalRemaining: number;
|
||||
nextExpiry: string | null;
|
||||
lotCount: number;
|
||||
lots?: LotBody[];
|
||||
}
|
||||
|
||||
interface MovementBody {
|
||||
id: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
treatmentId: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
interface LotBody {
|
||||
id: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
remaining: number;
|
||||
movementCount: number;
|
||||
batchNumber: string | null;
|
||||
expiresOn: string | null;
|
||||
movements?: MovementBody[];
|
||||
}
|
||||
|
||||
interface SuggestionBody {
|
||||
name: string;
|
||||
treatmentCount: number;
|
||||
latest: {
|
||||
activeIngredient: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
method: string | null;
|
||||
amount: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
describe('Veterinary medicines (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}`;
|
||||
const password = 'SuperSecret123!';
|
||||
const tokens: Record<string, string> = {};
|
||||
const medicineIds: string[] = [];
|
||||
let colonyId: string;
|
||||
let apiaryId: string;
|
||||
let userId: string;
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const auth = (role: string) => ({ Authorization: `Bearer ${tokens[role]}` });
|
||||
|
||||
async function createMedicine(
|
||||
name: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<MedicineBody> {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/veterinary-medicines')
|
||||
.set(auth('STAFF'))
|
||||
.send({ name: `${name}-${suffix}`, unit: 'ML', ...extra })
|
||||
.expect(201);
|
||||
const body = response.body as MedicineBody;
|
||||
medicineIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createLot(
|
||||
medicineId: string,
|
||||
quantity = 10,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<LotBody> {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/veterinary-medicine-lots')
|
||||
.set(auth('STAFF'))
|
||||
.send({ medicineId, purchasedOn: '2026-08-01', quantity, ...extra })
|
||||
.expect(201);
|
||||
return response.body as LotBody;
|
||||
}
|
||||
|
||||
async function lotOf(id: string): Promise<LotBody> {
|
||||
const response = await request(server())
|
||||
.get(`/api/v1/veterinary-medicine-lots/${id}`)
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
return response.body as LotBody;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
for (const role of ['STAFF', 'READONLY', 'API_CLIENT']) {
|
||||
const email = `vetmed-e2e-${role.toLowerCase()}-${suffix}@example.com`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name: role,
|
||||
role: { connect: { key: role } },
|
||||
},
|
||||
});
|
||||
if (role === 'STAFF') {
|
||||
userId = user.id;
|
||||
}
|
||||
tokens[role] = (await login(app, email, password)).accessToken;
|
||||
}
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `VetMed Stand ${suffix}` },
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `VM-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const lots = await prisma.veterinaryMedicineLot.findMany({
|
||||
where: { medicineId: { in: medicineIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const lotIds = lots.map((lot) => lot.id);
|
||||
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||
where: { lotId: { in: lotIds } },
|
||||
});
|
||||
await prisma.veterinaryMedicineLot.deleteMany({
|
||||
where: { id: { in: lotIds } },
|
||||
});
|
||||
await prisma.veterinaryMedicine.deleteMany({
|
||||
where: { id: { in: medicineIds } },
|
||||
});
|
||||
await prisma.treatment.deleteMany({ where: { colonyId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('lot -> rest: a correction books the delta and the rest follows', async () => {
|
||||
const medicine = await createMedicine('Rest');
|
||||
const lot = await createLot(medicine.id, 10, { batchNumber: 'CH-REST' });
|
||||
expect(lot).toMatchObject({
|
||||
remaining: 10,
|
||||
medicineName: `Rest-${suffix}`,
|
||||
unit: 'ML',
|
||||
movementCount: 0,
|
||||
});
|
||||
|
||||
const afterCorrection = await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 6.5 })
|
||||
.expect(201);
|
||||
|
||||
const detail = afterCorrection.body as LotBody;
|
||||
expect(detail.remaining).toBe(6.5);
|
||||
expect(detail.movements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'CORRECTION',
|
||||
quantity: -3.5,
|
||||
treatmentId: null,
|
||||
}) as MovementBody,
|
||||
]);
|
||||
const catalogue = await request(server())
|
||||
.get(`/api/v1/veterinary-medicines/${medicine.id}`)
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
expect(catalogue.body).toMatchObject({
|
||||
lotCount: 1,
|
||||
totalRemaining: 6.5,
|
||||
lots: [expect.objectContaining({ id: lot.id, remaining: 6.5 })],
|
||||
});
|
||||
});
|
||||
|
||||
it('a correction to the same rest books nothing', async () => {
|
||||
const medicine = await createMedicine('NoDelta');
|
||||
const lot = await createLot(medicine.id, 4);
|
||||
|
||||
const response = await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 4 })
|
||||
.expect(201);
|
||||
|
||||
expect((response.body as LotBody).movements).toEqual([]);
|
||||
});
|
||||
|
||||
it('a disposal books the negative quantity and may drive the rest negative', async () => {
|
||||
const medicine = await createMedicine('Disposal');
|
||||
const lot = await createLot(medicine.id, 10);
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'DISPOSAL', quantity: 15, reason: 'Abgelaufen' })
|
||||
.expect(201);
|
||||
|
||||
const detail = await lotOf(lot.id);
|
||||
expect(detail.remaining).toBe(-5);
|
||||
expect(detail.movements?.[0]).toMatchObject({
|
||||
type: 'DISPOSAL',
|
||||
quantity: -15,
|
||||
reason: 'Abgelaufen',
|
||||
});
|
||||
const catalogue = await request(server())
|
||||
.get(`/api/v1/veterinary-medicines?active=true`)
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
expect(
|
||||
(catalogue.body as MedicineBody[]).find((m) => m.id === medicine.id),
|
||||
).toMatchObject({ totalRemaining: -5 });
|
||||
});
|
||||
|
||||
it('a disposal without a reason is a 400', async () => {
|
||||
const medicine = await createMedicine('NoReason');
|
||||
const lot = await createLot(medicine.id, 10);
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'DISPOSAL', quantity: 1 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('two parallel inventories on one lot: the second computes with the new rest', async () => {
|
||||
const medicine = await createMedicine('Parallel');
|
||||
const lot = await createLot(medicine.id, 10);
|
||||
|
||||
const responses = await Promise.all(
|
||||
[4, 7].map((actualRemaining) =>
|
||||
request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(responses.map((response) => response.status)).toEqual([201, 201]);
|
||||
const detail = await lotOf(lot.id);
|
||||
// Both ran against the rest the other left behind: the deltas are
|
||||
// {-6, +3} or {-3, -3}, never {-6, -3} (both against 10).
|
||||
expect([4, 7]).toContain(detail.remaining);
|
||||
const deltas = (detail.movements ?? []).map((m) => m.quantity).sort();
|
||||
expect(deltas).toEqual(detail.remaining === 7 ? [-6, 3] : [-3, -3]);
|
||||
});
|
||||
|
||||
it('three parallel inventories end on the target of the last one booked', async () => {
|
||||
const medicine = await createMedicine('Parallel3');
|
||||
const lot = await createLot(medicine.id, 10);
|
||||
const targets = [2, 5, 8];
|
||||
|
||||
const responses = await Promise.all(
|
||||
targets.map((actualRemaining) =>
|
||||
request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(responses.map((response) => response.status)).toEqual([
|
||||
201, 201, 201,
|
||||
]);
|
||||
const detail = await lotOf(lot.id);
|
||||
const rows = await prisma.veterinaryMedicineMovement.findMany({
|
||||
where: { lotId: lot.id },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
// Replaying the book in booking order: every delta started from the rest
|
||||
// the one before left, so each step ends on one of the targets and the
|
||||
// last one is the final rest.
|
||||
let rest = 10;
|
||||
const reached = rows.map((row) => {
|
||||
rest = Math.round((rest + row.quantity) * 1000) / 1000;
|
||||
return rest;
|
||||
});
|
||||
expect(reached.every((value) => targets.includes(value))).toBe(true);
|
||||
expect(detail.remaining).toBe(reached[reached.length - 1]);
|
||||
});
|
||||
|
||||
it('rejects changing the unit once a lot exists with 409, other fields stay editable', async () => {
|
||||
const medicine = await createMedicine('Unit');
|
||||
await createLot(medicine.id, 5);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ unit: 'G' })
|
||||
.expect(409);
|
||||
const renamed = await request(server())
|
||||
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ notes: 'Kühl lagern', withdrawalPeriodDays: 0 })
|
||||
.expect(200);
|
||||
expect(renamed.body).toMatchObject({
|
||||
notes: 'Kühl lagern',
|
||||
withdrawalPeriodDays: 0,
|
||||
unit: 'ML',
|
||||
});
|
||||
});
|
||||
|
||||
it('changes the unit of a medicine without lots and clears a field with null', async () => {
|
||||
const medicine = await createMedicine('Free', { notes: 'x' });
|
||||
|
||||
const response = await request(server())
|
||||
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ unit: 'PIECE', notes: null })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({ unit: 'PIECE', notes: null });
|
||||
});
|
||||
|
||||
it('rejects changing the medicine of a lot once a movement exists with 409', async () => {
|
||||
const first = await createMedicine('Move-A');
|
||||
const second = await createMedicine('Move-B');
|
||||
const lot = await createLot(first.id, 5);
|
||||
const free = await createLot(first.id, 5);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/veterinary-medicine-lots/${free.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ medicineId: second.id })
|
||||
.expect(200);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 3 })
|
||||
.expect(201);
|
||||
await request(server())
|
||||
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ medicineId: second.id })
|
||||
.expect(409);
|
||||
await request(server())
|
||||
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ supplier: 'Imkerei Muster', expiresOn: null })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('selectable lots come oldest expiry first and leave out an empty lot', async () => {
|
||||
const medicine = await createMedicine('Fifo');
|
||||
const late = await createLot(medicine.id, 5, { expiresOn: '2028-01-01' });
|
||||
const early = await createLot(medicine.id, 5, { expiresOn: '2027-01-01' });
|
||||
const empty = await createLot(medicine.id, 5, { expiresOn: '2026-12-01' });
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${empty.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 0 })
|
||||
.expect(201);
|
||||
|
||||
const response = await request(server())
|
||||
.get(
|
||||
`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true`,
|
||||
)
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
|
||||
expect((response.body as LotBody[]).map((lot) => lot.id)).toEqual([
|
||||
early.id,
|
||||
late.id,
|
||||
]);
|
||||
const all = await request(server())
|
||||
.get(`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}`)
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
expect(all.body as LotBody[]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('selectable leaves out the lots of an inactive medicine, includeLotId keeps one', async () => {
|
||||
const medicine = await createMedicine('Inactive');
|
||||
const lot = await createLot(medicine.id, 5);
|
||||
const emptyLot = await createLot(medicine.id, 5);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${emptyLot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 0 })
|
||||
.expect(201);
|
||||
const list = async (query: string) =>
|
||||
(
|
||||
(
|
||||
await request(server())
|
||||
.get(
|
||||
`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true${query}`,
|
||||
)
|
||||
.set(auth('READONLY'))
|
||||
.expect(200)
|
||||
).body as LotBody[]
|
||||
).map((entry) => entry.id);
|
||||
|
||||
expect(await list('')).toEqual([lot.id]);
|
||||
// The lot of an edited treatment stays selectable although it is empty.
|
||||
expect(await list(`&includeLotId=${emptyLot.id}`)).toEqual(
|
||||
expect.arrayContaining([lot.id, emptyLot.id]),
|
||||
);
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicines/${medicine.id}/deactivate`)
|
||||
.set(auth('STAFF'))
|
||||
.expect(201);
|
||||
|
||||
expect(await list('')).toEqual([]);
|
||||
expect(await list(`&includeLotId=${emptyLot.id}`)).toEqual([emptyLot.id]);
|
||||
await request(server())
|
||||
.get(
|
||||
`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true&includeLotId=bad%20id`,
|
||||
)
|
||||
.set(auth('READONLY'))
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('an inactive medicine takes no new stock: no new lot, no move to it', async () => {
|
||||
const off = await createMedicine('NoStock');
|
||||
const other = await createMedicine('NoStockOther');
|
||||
const lot = await createLot(other.id, 5);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicines/${off.id}/deactivate`)
|
||||
.set(auth('STAFF'))
|
||||
.expect(201);
|
||||
|
||||
const created = await request(server())
|
||||
.post('/api/v1/veterinary-medicine-lots')
|
||||
.set(auth('STAFF'))
|
||||
.send({ medicineId: off.id, purchasedOn: '2026-08-01', quantity: 1 })
|
||||
.expect(400);
|
||||
expect((created.body as { message: string }).message).toBe(
|
||||
'Das Mittel ist deaktiviert.',
|
||||
);
|
||||
await request(server())
|
||||
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ medicineId: off.id })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('moving a lot to a medicine with another unit is a 400', async () => {
|
||||
const ml = await createMedicine('UnitMl');
|
||||
const grams = await createMedicine('UnitG', { unit: 'G' });
|
||||
const lot = await createLot(ml.id, 5);
|
||||
|
||||
const response = await request(server())
|
||||
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ medicineId: grams.id })
|
||||
.expect(400);
|
||||
|
||||
expect((response.body as { message: string }).message).toBe(
|
||||
'Die Einheit des Mittels passt nicht zur Packung.',
|
||||
);
|
||||
});
|
||||
|
||||
it('setActive without a change writes no audit row', async () => {
|
||||
const medicine = await createMedicine('NoChange');
|
||||
const countAudit = () =>
|
||||
prisma.auditLog.count({
|
||||
where: { entityType: 'VeterinaryMedicine', entityId: medicine.id },
|
||||
});
|
||||
const before = await countAudit();
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicines/${medicine.id}/reactivate`)
|
||||
.set(auth('STAFF'))
|
||||
.expect(201);
|
||||
|
||||
expect(await countAudit()).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects a whitespace-only name or disposal reason with 400', async () => {
|
||||
const medicine = await createMedicine('Blank');
|
||||
const lot = await createLot(medicine.id, 5);
|
||||
|
||||
await request(server())
|
||||
.post('/api/v1/veterinary-medicines')
|
||||
.set(auth('STAFF'))
|
||||
.send({ name: ' ', unit: 'ML' })
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ name: ' \t ' })
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'DISPOSAL', quantity: 1, reason: ' ' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('suggestions list own product names that are not in the catalogue yet', async () => {
|
||||
const product = `Sugg-${suffix}`;
|
||||
const known = `SuggKnown-${suffix}`;
|
||||
for (const [name, day, ingredient] of [
|
||||
[product, '2026-05-01', 'Alt'],
|
||||
[product.toUpperCase(), '2026-06-01', 'Neu'],
|
||||
[known.toLowerCase(), '2026-06-02', 'Egal'],
|
||||
]) {
|
||||
await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId,
|
||||
date: new Date(`${day}T00:00:00Z`),
|
||||
product: name,
|
||||
activeIngredient: ingredient,
|
||||
withdrawalPeriodDays: 0,
|
||||
method: 'Träufeln',
|
||||
amount: '5 ml',
|
||||
},
|
||||
});
|
||||
}
|
||||
await createMedicine('SuggKnown');
|
||||
|
||||
const response = await request(server())
|
||||
.get('/api/v1/veterinary-medicines/suggestions')
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
|
||||
const suggestions = response.body as SuggestionBody[];
|
||||
expect(
|
||||
suggestions.find(
|
||||
(entry) => entry.name.toLowerCase() === known.toLowerCase(),
|
||||
),
|
||||
).toBeUndefined();
|
||||
const mine = suggestions.find(
|
||||
(entry) => entry.name.toLowerCase() === product.toLowerCase(),
|
||||
);
|
||||
expect(mine).toMatchObject({
|
||||
treatmentCount: 2,
|
||||
latest: {
|
||||
activeIngredient: 'Neu',
|
||||
withdrawalPeriodDays: 0,
|
||||
method: 'Träufeln',
|
||||
amount: '5 ml',
|
||||
},
|
||||
});
|
||||
// nothing was created by looking at them
|
||||
expect(
|
||||
await prisma.veterinaryMedicine.count({
|
||||
where: { name: { equals: product, mode: 'insensitive' } },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('STAFF may create a lot, READONLY gets 403 on every write', async () => {
|
||||
const medicine = await createMedicine('Perm');
|
||||
const lot = await createLot(medicine.id, 3);
|
||||
|
||||
for (const [method, path, body] of [
|
||||
['post', '/api/v1/veterinary-medicines', { name: 'x', unit: 'ML' }],
|
||||
['patch', `/api/v1/veterinary-medicines/${medicine.id}`, { notes: 'x' }],
|
||||
['post', `/api/v1/veterinary-medicines/${medicine.id}/deactivate`, {}],
|
||||
['post', `/api/v1/veterinary-medicines/${medicine.id}/reactivate`, {}],
|
||||
[
|
||||
'post',
|
||||
'/api/v1/veterinary-medicine-lots',
|
||||
{ medicineId: medicine.id, purchasedOn: '2026-08-01', quantity: 1 },
|
||||
],
|
||||
['patch', `/api/v1/veterinary-medicine-lots/${lot.id}`, { notes: 'x' }],
|
||||
[
|
||||
'post',
|
||||
`/api/v1/veterinary-medicine-lots/${lot.id}/movements`,
|
||||
{ type: 'CORRECTION', actualRemaining: 1 },
|
||||
],
|
||||
] as const) {
|
||||
await request(server())
|
||||
[method](path)
|
||||
.set(auth('READONLY'))
|
||||
.send(body)
|
||||
.expect(403);
|
||||
}
|
||||
await request(server())
|
||||
.get('/api/v1/veterinary-medicines')
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('API_CLIENT gets 403 on every route', async () => {
|
||||
const medicine = await createMedicine('Client');
|
||||
const lot = await createLot(medicine.id, 3);
|
||||
const routes = [
|
||||
['get', '/api/v1/veterinary-medicines'],
|
||||
['get', '/api/v1/veterinary-medicines/suggestions'],
|
||||
['get', `/api/v1/veterinary-medicines/${medicine.id}`],
|
||||
['post', '/api/v1/veterinary-medicines'],
|
||||
['patch', `/api/v1/veterinary-medicines/${medicine.id}`],
|
||||
['post', `/api/v1/veterinary-medicines/${medicine.id}/deactivate`],
|
||||
['post', `/api/v1/veterinary-medicines/${medicine.id}/reactivate`],
|
||||
['get', '/api/v1/veterinary-medicine-lots'],
|
||||
['get', `/api/v1/veterinary-medicine-lots/${lot.id}`],
|
||||
['post', '/api/v1/veterinary-medicine-lots'],
|
||||
['patch', `/api/v1/veterinary-medicine-lots/${lot.id}`],
|
||||
['post', `/api/v1/veterinary-medicine-lots/${lot.id}/movements`],
|
||||
] as const;
|
||||
|
||||
for (const [method, path] of routes) {
|
||||
await request(server())
|
||||
[method](path)
|
||||
.set(auth('API_CLIENT'))
|
||||
.send({})
|
||||
.expect(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('deactivates and reactivates a medicine instead of deleting it', async () => {
|
||||
const medicine = await createMedicine('Active');
|
||||
|
||||
const off = await request(server())
|
||||
.post(`/api/v1/veterinary-medicines/${medicine.id}/deactivate`)
|
||||
.set(auth('STAFF'))
|
||||
.expect(201);
|
||||
expect(off.body).toMatchObject({ id: medicine.id, active: false });
|
||||
const inactive = await request(server())
|
||||
.get('/api/v1/veterinary-medicines?active=false')
|
||||
.set(auth('READONLY'))
|
||||
.expect(200);
|
||||
expect((inactive.body as MedicineBody[]).map((m) => m.id)).toContain(
|
||||
medicine.id,
|
||||
);
|
||||
const on = await request(server())
|
||||
.post(`/api/v1/veterinary-medicines/${medicine.id}/reactivate`)
|
||||
.set(auth('STAFF'))
|
||||
.expect(201);
|
||||
expect(on.body).toMatchObject({ active: true });
|
||||
});
|
||||
|
||||
it('a POST with a repeated Idempotency-Key creates one lot', async () => {
|
||||
const medicine = await createMedicine('Idem');
|
||||
const key = `idem-${randomUUID()}`;
|
||||
const body = {
|
||||
medicineId: medicine.id,
|
||||
purchasedOn: '2026-08-01',
|
||||
quantity: 2,
|
||||
batchNumber: `IDEM-${suffix}`,
|
||||
};
|
||||
|
||||
const first = await request(server())
|
||||
.post('/api/v1/veterinary-medicine-lots')
|
||||
.set(auth('STAFF'))
|
||||
.set('Idempotency-Key', key)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
const second = await request(server())
|
||||
.post('/api/v1/veterinary-medicine-lots')
|
||||
.set(auth('STAFF'))
|
||||
.set('Idempotency-Key', key)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
expect((second.body as LotBody).id).toBe((first.body as LotBody).id);
|
||||
expect(
|
||||
await prisma.veterinaryMedicineLot.count({
|
||||
where: { medicineId: medicine.id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a quantity of 0 or NaN with 400', async () => {
|
||||
const medicine = await createMedicine('Qty');
|
||||
const lot = await createLot(medicine.id, 5);
|
||||
|
||||
for (const quantity of [0, -1, 'NaN', null, Infinity]) {
|
||||
await request(server())
|
||||
.post('/api/v1/veterinary-medicine-lots')
|
||||
.set(auth('STAFF'))
|
||||
.send({ medicineId: medicine.id, purchasedOn: '2026-08-01', quantity })
|
||||
.expect(400);
|
||||
}
|
||||
await request(server())
|
||||
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ quantity: 0 })
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'DISPOSAL', quantity: 0, reason: 'x' })
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 'NaN' })
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'TREATMENT', quantity: 1 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('validates the medicine: temperatures, withdrawal days, name length', async () => {
|
||||
await request(server())
|
||||
.post('/api/v1/veterinary-medicines')
|
||||
.set(auth('STAFF'))
|
||||
.send({
|
||||
name: `Temp-${suffix}`,
|
||||
unit: 'ML',
|
||||
minTemperatureC: 30,
|
||||
maxTemperatureC: 10,
|
||||
})
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.post('/api/v1/veterinary-medicines')
|
||||
.set(auth('STAFF'))
|
||||
.send({ name: `Days-${suffix}`, unit: 'ML', withdrawalPeriodDays: -1 })
|
||||
.expect(400);
|
||||
await request(server())
|
||||
.post('/api/v1/veterinary-medicines')
|
||||
.set(auth('STAFF'))
|
||||
.send({ name: 'x'.repeat(201), unit: 'ML' })
|
||||
.expect(400);
|
||||
const zero = await createMedicine('Zero', { withdrawalPeriodDays: 0 });
|
||||
expect(zero).toMatchObject({ lotCount: 0, totalRemaining: 0 });
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown medicine, lot or lot of an unknown medicine', async () => {
|
||||
await request(server())
|
||||
.get('/api/v1/veterinary-medicines/does-not-exist')
|
||||
.set(auth('READONLY'))
|
||||
.expect(404);
|
||||
await request(server())
|
||||
.get('/api/v1/veterinary-medicine-lots/does-not-exist')
|
||||
.set(auth('READONLY'))
|
||||
.expect(404);
|
||||
await request(server())
|
||||
.post('/api/v1/veterinary-medicine-lots')
|
||||
.set(auth('STAFF'))
|
||||
.send({
|
||||
medicineId: 'does-not-exist',
|
||||
purchasedOn: '2026-08-01',
|
||||
quantity: 1,
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('a movement can neither be changed nor deleted (no such routes)', async () => {
|
||||
const medicine = await createMedicine('Immutable');
|
||||
const lot = await createLot(medicine.id, 5);
|
||||
const detail = await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 1 })
|
||||
.expect(201);
|
||||
const movementId = (detail.body as LotBody).movements?.[0].id ?? '';
|
||||
const base = `/api/v1/veterinary-medicine-lots/${lot.id}/movements`;
|
||||
|
||||
await request(server())
|
||||
.patch(`${base}/${movementId}`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ quantity: 9 })
|
||||
.expect(404);
|
||||
await request(server())
|
||||
.delete(`${base}/${movementId}`)
|
||||
.set(auth('STAFF'))
|
||||
.expect(404);
|
||||
await request(server())
|
||||
.delete(`/api/v1/veterinary-medicine-lots/${lot.id}`)
|
||||
.set(auth('STAFF'))
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('writes an audit entry for a correction and keeps the book of the lot', async () => {
|
||||
const medicine = await createMedicine('Audit');
|
||||
const lot = await createLot(medicine.id, 8);
|
||||
await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
|
||||
.set(auth('STAFF'))
|
||||
.send({ type: 'CORRECTION', actualRemaining: 5 })
|
||||
.expect(201);
|
||||
|
||||
const entries = await prisma.auditLog.findMany({
|
||||
where: { entityType: 'VeterinaryMedicineLot', entityId: lot.id },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.action)).toEqual(['CREATE', 'UPDATE']);
|
||||
expect(entries[1].afterData).toMatchObject({ remaining: 5 });
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,10 @@ export const DOMAIN_EVENT_LABELS: Record<string, string> = {
|
||||
PhenologyRecomputeRequested: "Saison neu berechnen angefordert",
|
||||
WinterCheckRecorded: "Winterkontrolle erfasst",
|
||||
SeasonPhaseChanged: "Saisonabschnitt gewechselt",
|
||||
ColonyCreated: "Volk angelegt",
|
||||
ColonyMoved: "Volk umgesetzt",
|
||||
ApiaryChanged: "Stand geändert",
|
||||
TskReferenceDayReached: "TSK-Stichtag erreicht",
|
||||
};
|
||||
|
||||
/** The oldest open event is this old (seconds) before the worker counts as stuck. */
|
||||
|
||||
Reference in new issue
Block a user