feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit, bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot, FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export needs veterinary-medicines:read. - Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with watermark, PRINT audit before the PDF, check endpoints; digit rules only from the seeded ComplianceSettings row (fail closed). - Restricted zone (Berlin day): create, update, move, merge and CSV import blocked inside an active restriction; override with reason and compliance:manage, audited, never stored on the colony; split, harvest and dissolve stay free; restriction details only with compliance:read. - Authority notices: settings, herd snapshot, registration/herd change/TSK notices with one open draft per kind under the settings lock. - Label check refinements (geometric reading order, number|unit pairs). - ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
+69
-12
@@ -23,7 +23,9 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
|
|||||||
| `ExtractionsModule` | Schleuderungen | Harvests |
|
| `ExtractionsModule` | Schleuderungen | Harvests |
|
||||||
| `HoneyBatchesModule` | Honigchargen, Honigmessungen | Extractions |
|
| `HoneyBatchesModule` | Honigchargen, Honigmessungen | Extractions |
|
||||||
| `StorageModule` | Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) | HoneyBatches |
|
| `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) | – |
|
| `VeterinaryMedicinesModule` | Tierarzneimittel-Katalog, Packungen, Bewegungsbuch (T6, Abschnitt 12.2); exportiert `VeterinaryMedicinesService` und `VeterinaryMedicineLotsService`; die Behandlung bucht direkt über ihr `tx` und importiert das Modul nicht (12.2) | – |
|
||||||
|
| `ComplianceModule` | Compliance-Einstellungen, Bestandsbild und Behördenanzeigen (T6, Abschnitt 12.8); `restricted-zone.ts` für den Sperrbezirk (12.7); ohne Provider an `PermissionResolverService` | – |
|
||||||
|
| `ApiaryRestrictionsModule` | Sperrbezirke je Stand (T6, Abschnitt 12.7); von `ColoniesModule` importiert | – |
|
||||||
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
|
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
|
||||||
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
|
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
|
||||||
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
||||||
@@ -129,7 +131,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
12. **Automatische Hinweise sind Vorschläge, keine Aufgaben**: Automatisierungsregeln legen keine Aufgaben mehr an, sondern schreiben Vorschläge (`Suggestion`, Abschnitt 11.1). Erst „Übernehmen“ erzeugt die Aufgabe (Quelle „Aus Vorschlag“: `Task.sourceSuggestionId`, `sourceAutomationTrigger` = Regel); bestehende Automatik-Aufgaben bleiben Aufgaben. Je Regel und Objekt existiert höchstens eine offene Sache (Vorschlag oder Aufgabe). Geprüft wird unter der Volkssperre und der Zeilensperre der Regel (`SELECT … FOR UPDATE`, Reihenfolge Volk, dann Regel), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel, gerechnet ab dem Berliner Tag. Ohne sie ist die Aufgabe beim Übernehmen heute fällig. Anker je Regel (ein verworfener Anker kommt nie wieder): `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` die Durchsicht-ID; `VARROA_THRESHOLD` die Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4); `SWARM_MOOD_HIGH` die Kontroll-ID; `COLONY_HEALTH_CRITICAL` die Ereignis-ID; `STOCK_ITEM_LOW` die jüngste IN-Bewegung (sonst `none`); `RAW_MATERIAL_LOW` die jüngste Charge der Sorte (sonst `none`); `TREATMENT_FOLLOW_UP` und `FEEDING_FOLLOW_UP` die Behandlungs- bzw. Fütterungs-ID. Die Tagesrate des natürlichen Milbenfalls wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Vorschlag, Meldung und der Audit-Eintrag der Regel (`AutomationRule CREATE`, mit `suggestionId`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (Kategorie `SUGGESTION`, WARNUNG, bei `URGENT` KRITISCH) schreibt ein Vorschlag nur, wenn die Regel `config.notify` hat. Empfänger sind Inhaber von `tasks:read` und des Leserechts des Bereichs; der Text lautet „<Volkscode>: <Titel>“ (bewusste Abweichung vom Spec-Wortlaut, bei dem das Präfix der Regelcode war; ohne Volk nur der Titel). Der Kalendereintrag (`calendar-sync.task`) und der Webhook `task.created` (`webhooks.forward`) folgen erst aus `TaskCreated` beim Übernehmen; die Regel ruft `WebhooksService` nicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge von `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` nach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der Vorschlag `RESOLVED` („Bedingung entfällt“); besteht der Befund, bleibt es bei einem Vorschlag mit aktuellem Beleg (der Anker bleibt der alte); eine rückdatierte Durchsicht ändert nichts, eine Korrektur schlägt nie neu vor, führt aber Offenes nach. Dasselbe gilt für `VARROA_THRESHOLD` bei `VarroaCorrected`. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`, überspringen beendete Völker und prüfen unter der Volkssperre erneut. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie.
|
12. **Automatische Hinweise sind Vorschläge, keine Aufgaben**: Automatisierungsregeln legen keine Aufgaben mehr an, sondern schreiben Vorschläge (`Suggestion`, Abschnitt 11.1). Erst „Übernehmen“ erzeugt die Aufgabe (Quelle „Aus Vorschlag“: `Task.sourceSuggestionId`, `sourceAutomationTrigger` = Regel); bestehende Automatik-Aufgaben bleiben Aufgaben. Je Regel und Objekt existiert höchstens eine offene Sache (Vorschlag oder Aufgabe). Geprüft wird unter der Volkssperre und der Zeilensperre der Regel (`SELECT … FOR UPDATE`, Reihenfolge Volk, dann Regel), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel, gerechnet ab dem Berliner Tag. Ohne sie ist die Aufgabe beim Übernehmen heute fällig. Anker je Regel (ein verworfener Anker kommt nie wieder): `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` die Durchsicht-ID; `VARROA_THRESHOLD` die Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4); `SWARM_MOOD_HIGH` die Kontroll-ID; `COLONY_HEALTH_CRITICAL` die Ereignis-ID; `STOCK_ITEM_LOW` die jüngste IN-Bewegung (sonst `none`); `RAW_MATERIAL_LOW` die jüngste Charge der Sorte (sonst `none`); `TREATMENT_FOLLOW_UP` und `FEEDING_FOLLOW_UP` die Behandlungs- bzw. Fütterungs-ID. Die Tagesrate des natürlichen Milbenfalls wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Vorschlag, Meldung und der Audit-Eintrag der Regel (`AutomationRule CREATE`, mit `suggestionId`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (Kategorie `SUGGESTION`, WARNUNG, bei `URGENT` KRITISCH) schreibt ein Vorschlag nur, wenn die Regel `config.notify` hat. Empfänger sind Inhaber von `tasks:read` und des Leserechts des Bereichs; der Text lautet „<Volkscode>: <Titel>“ (bewusste Abweichung vom Spec-Wortlaut, bei dem das Präfix der Regelcode war; ohne Volk nur der Titel). Der Kalendereintrag (`calendar-sync.task`) und der Webhook `task.created` (`webhooks.forward`) folgen erst aus `TaskCreated` beim Übernehmen; die Regel ruft `WebhooksService` nicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge von `QUEENLESS_NO_EGGS` und `FEED_STATUS_CRITICAL` nach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der Vorschlag `RESOLVED` („Bedingung entfällt“); besteht der Befund, bleibt es bei einem Vorschlag mit aktuellem Beleg (der Anker bleibt der alte); eine rückdatierte Durchsicht ändert nichts, eine Korrektur schlägt nie neu vor, führt aber Offenes nach. Dasselbe gilt für `VARROA_THRESHOLD` bei `VarroaCorrected`. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`, überspringen beendete Völker und prüfen unter der Volkssperre erneut. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie.
|
||||||
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30.
|
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30.
|
||||||
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
||||||
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
|
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). Ergänzung T6: Wird eine Behandlung aus einer Packung erfasst, kommen Lieferant, Beleg, Ch.-B. und Tierarzt live aus der Packung und stehen nie als Kopie auf der Behandlung (Abschnitt 12.2).
|
||||||
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. Ein Verlust mit `markColonyDeceased` beendet auch die Königin-Zuordnung und schreibt `ColonyEnded` mit `via: WINTER_CHECK` (Abschnitt 11.7).
|
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. Ein Verlust mit `markColonyDeceased` beendet auch die Königin-Zuordnung und schreibt `ColonyEnded` mit `via: WINTER_CHECK` (Abschnitt 11.7).
|
||||||
|
|
||||||
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
|
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
|
||||||
@@ -168,7 +170,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.
|
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.
|
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.
|
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), `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).
|
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 Aufrufer (`ColoniesService.create/update/move/merge`, `ImportService`, `ApiariesService`, `ApiaryRestrictionsService`) sind umgebaut (Abschnitt 12.7); 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.
|
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.
|
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.
|
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.
|
||||||
@@ -183,12 +185,19 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
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).
|
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.
|
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`).
|
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.
|
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; ein Quelltext-Wächter sichert das). Eine Zahl direkt vor der Menge („1,250 g“) gilt nicht als Treffer für 250 g; der Ziffern-Wächter gilt je Element, nie im verbundenen Text. Eine Anschrift wird auch erkannt, wenn sie auf mehrere Elemente verteilt ist (Annahme 75), 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.
|
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.
|
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.
|
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.
|
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.
|
70. **Dokumente gelöschter Datensätze bleiben bestehen**: Sie bleiben durch das Recht der Art geschützt und werden nicht mitgelöscht; bisher löscht kein Pfad einen Datensatz mit Dokumenten (Behandlungen, Sperrbezirke und Behördenanzeigen haben keine Löschroute; Sperrbezirke enden über das Enddatum, Entwürfe werden verworfen, Annahme 76). Ein künftiger Löschpfad entfernt die Dokumente seines Datensatzes selbst. Kosten bei falscher Annahme: Dateien ohne Datensatz bleiben im Speicher.
|
||||||
|
71. **Der CSV-Import befolgt den Sperrbezirk ohne Ausnahme**: Der Import legt in einem Stand mit aktivem Sperrbezirk kein Volk an; die Zeile wird als Fehlerzeile gemeldet (Text des Sperrbezirks), nichts wird für sie geschrieben. Der Import kennt keine Ausnahmebegründung. Kosten bei falscher Annahme: Wer Altbestand in einen gesperrten Stand importieren muss, legt die Völker einzeln mit Begründung an. Abschnitt 12.7.
|
||||||
|
72. **Eine Ausnahmebegründung ohne `compliance:manage` ist immer 403**: Wer `restrictionOverrideReason` sendet, ohne `compliance:manage` zu halten, bekommt 403, bevor gelesen oder gesperrt wird, auch ohne aktive Sperre. Mit dem Recht wird eine Begründung ohne aktive Sperre ignoriert. Kosten bei falscher Annahme: Ein Client, der die Begründung immer mitsendet, braucht das Recht. Abschnitt 12.7.
|
||||||
|
73. **Der Bestandsbuch-Export braucht zusätzlich `veterinary-medicines:read`**: `GET /export/treatment-log.pdf` verlangt `export:read` und `veterinary-medicines:read`, weil es Lieferant, Beleg und Tierarzt aus der Packung zeigt (die Prüfung steckt im `ExportController`, nur für diesen Bericht). Kosten bei falscher Annahme: Ein Nutzer mit nur `export:read` verliert das Bestandsbuch; das Frontend (Task 15) blendet den Export ohne das Recht aus. Abschnitt 12.2.
|
||||||
|
74. **„500“ und „g“ in zwei Elementen zählen nur als anliegendes Paar**: Die Füllmenge gilt in zwei Elementen nur, wenn sich die Boxen senkrecht überlappen, die Einheit rechts der Zahl liegt (`x` nicht kleiner) und der waagerechte Abstand höchstens 10 mm beträgt; vor der Zahl darf keine Ziffer stehen. Sonst ist es eine Lücke (sperrt den Druck, der Probedruck bleibt möglich). Abschnitt 12.4.
|
||||||
|
75. **Eine verteilte Anschrift zählt nur mit höchstens 10 mm Abstand**: Name und Anschrift des Lebensmittelunternehmers dürfen über mehrere Elemente verteilt sein, wenn der senkrechte Abstand jedes Elements zur bisherigen Gruppe höchstens 10 mm beträgt (symmetrisch, gegen die laufende Ober- und Unterkante der Gruppe; die Lesereihenfolge kommt allein aus den Positionen). Der waagerechte Abstand zählt dort nicht. Kosten bei falscher Annahme: Eine sehr weit auseinandergezogene Anschrift gilt als Lücke; zwei nahe, fremde Elemente mit der passenden Wortfolge gelten als Anschrift. Abschnitt 12.4.
|
||||||
|
76. **Verwerfen einer Behördenanzeige behält die Dokumente**: `cancel` setzt den Entwurf auf `CANCELLED` und gibt den `openKey` frei; es gibt keine Löschroute (Spec 7), angehängte Dokumente (`AUTHORITY_NOTICE`) bleiben bestehen und durch das Recht der Art geschützt (Annahme 70). Kosten bei falscher Annahme: Wer einen Entwurf „löschen“ erwartet, findet ihn als verworfen in der Liste. Abschnitt 12.8.
|
||||||
|
77. **Etikett-Rechte: derzeit nur ADMIN (Frage an den Inhaber offen)**: `label-templates:read|create|update|deactivate|print` vergibt keine Migration, nur die Katalog-Synchronisation an die damaligen Systemrollen; in der Test-Datenbank halten sie nur ADMIN. Prüfen und Drucken brauchen zusätzlich `bottling:read`. Offen ist, ob BEEKEEPER (und für `read` STAFF und READONLY) Etiketten ansehen und drucken dürfen; vor dem Rollout die tatsächliche Rollenvergabe in Produktion prüfen. Abschnitt 12.6.
|
||||||
|
|
||||||
## 7. Kubernetes
|
## 7. Kubernetes
|
||||||
|
|
||||||
@@ -712,11 +721,11 @@ Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.
|
|||||||
|
|
||||||
Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von `compliance:read` bzw. `veterinary-medicines:read`; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie `COMPLIANCE`, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk.
|
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.
|
**Stand:** Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I). Task 10 bis 19 (Anzeige-PDF, Prüfbereitschaft, Handler und Meldungen, Tagesregeln, Oberfläche) folgen und ergänzen diesen Abschnitt.
|
||||||
|
|
||||||
### 12.1 Schema und Rechte
|
### 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.
|
**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` und `…100500_t6_compliance_settings_row` (Datenmigration: legt die Zeile `ComplianceSettings` mit der Id `singleton` an). 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 die Migration `…100500` an; `ComplianceSettingsService` und `herdStatusIn` behalten einen idempotenten `upsert` nur als Rückfall, der Etikettendruck dagegen schließt ohne Zeile (12.6). Es gibt keine Löschroute für Behandlungen; die RESTRICT-Schlüssel schützen eine Behandlung mit Buchung weiter. Ein künftiger Löschpfad müsste `P2003` auf 409 „Diese Behandlung hat eine Buchung aus einer Packung und kann nicht gelöscht werden.“ abbilden oder gegenbuchen.
|
||||||
|
|
||||||
**Rechte** (Katalog `permission-catalog.ts`; die Migrationen vergeben, der Katalog-Sync berechtigt nur System-Rollen):
|
**Rechte** (Katalog `permission-catalog.ts`; die Migrationen vergeben, der Katalog-Sync berechtigt nur System-Rollen):
|
||||||
|
|
||||||
@@ -727,9 +736,9 @@ Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (F
|
|||||||
| `compliance:read` | Prüfbereitschaft & Behörden (3800) | ✓ | ✓ | ✓ | ✓ | – |
|
| `compliance:read` | Prüfbereitschaft & Behörden (3800) | ✓ | ✓ | ✓ | ✓ | – |
|
||||||
| `compliance:manage` | Prüfbereitschaft & Behörden (3801) | ✓ | ✓ | – | – | – |
|
| `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).
|
Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein eigenes Recht. Sperrbezirke lesen mit `apiaries:read` (Einzelheiten nur mit `compliance:read`, 12.7) und schreiben mit `compliance:manage`. `AuditAction` kennt `PRINT` (Etikettendruck, 12.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.
|
**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 stehen in 12.7. 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).
|
**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).
|
||||||
|
|
||||||
@@ -746,7 +755,13 @@ Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein
|
|||||||
- `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 }`.
|
- `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.
|
- 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/`).
|
**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). 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/`).
|
||||||
|
|
||||||
|
**Behandlung aus Packung (Task 3).** `POST /treatments`, `POST /treatments/bulk` und `PATCH /treatments/:id` nehmen zusätzlich `medicineId`, `medicineLotId` und `doseQuantity` (`null` leert); `product` entfällt, wenn Mittel oder Packung gewählt sind, `PATCH` kennt außerdem `apiaryId` (Stand am Behandlungstag, 404 bei unbekanntem Stand). Die Logik steckt in `treatment-medicine.ts` (freie Funktionen mit `tx`); `TreatmentsModule` importiert `VeterinaryMedicinesModule` nicht, die Buchung braucht nur `treatments:create`.
|
||||||
|
- **Prüfung** (`resolveMedicineIn`, am zusammengeführten Zustand): Eine Packung schließt `supplier`, `purchaseProof`, `batchNumber` und `prescribingVet` auf der Behandlung aus (400 „Diese Angabe kommt aus der Packung“); `medicineId: null` bei gesetzter Packung, eine Packung eines anderen Mittels, ein abweichendes `product` (ohne Groß-/Kleinschreibung, getrimmt) und ein inaktives Mittel beim Anlegen sind 400, ein unbekanntes Mittel oder eine unbekannte Packung 404. Die Dosis wird auf Tausendstel gerundet, unter 0,001 ist 400. Das Mittel wird `FOR SHARE` gesperrt. `activeIngredient`, `withdrawalPeriodDays` und `method` füllt der Server nie aus dem Katalog nach; `product` wird zum Handelsnamen und folgt dem Katalog nur, wenn der Patch `product`, `medicineId` oder `medicineLotId` anfasst. `update` prüft und bucht nur, wenn der Patch Mittel, Packung, Dosis, `product` oder eines der vier Packungs-Textfelder berührt; eine reine Notizänderung gelingt auch, wenn das Mittel der Packung inzwischen gewechselt hat.
|
||||||
|
- **Delta-Buchung** (`bookTreatmentIn`): Eine Behandlung mit Packung und Dosis bucht `TREATMENT` mit −Dosis in derselben Transaktion wie Behandlung, Audit und `TreatmentRecorded` (Sammelerfassung: je Volk eine Bewegung, alles oder nichts). Beim Bearbeiten bucht der Server nur das Delta je Packung aus `treatmentBookingDelta` über die Summe der bisherigen Bewegungen dieser Behandlung (Gegenbuchung auf der alten, neue Buchung auf der neuen Packung; Bewegungen werden nie geändert). Ein Umhängen auf ein anderes Volk lässt den Bestand unberührt. Eine Dosis ohne Packung wird gespeichert und bucht nichts; eine später gesetzte Packung bucht die volle Dosis nach. Leere oder abgelaufene Packungen blockieren nicht (der Rest wird negativ, die Prüfbereitschaft meldet es).
|
||||||
|
- **Sperr-Reihenfolge:** Volk (wenn `colonyId` im Patch steht) → Behandlung → Mittel (`FOR SHARE`) → Packungen aufsteigend in einer Anweisung (`lockRowsForUpdate`), also Colony vor Treatment wie in der Karte `LOCKABLE_TABLES`. Nach der Packungssperre prüft die Buchung, dass die Packung noch zum Mittel der Behandlung gehört (sonst 409 „Die Packung wurde gerade geändert. Bitte neu laden.“). Eine Inventur auf derselben Packung wartet auf dieselbe Sperre.
|
||||||
|
- **Bestandsbuch** (`export/treatment-log.ts`, `GET /export/treatment-log.pdf`): `resolveSourceValues` liest die Spalten aus genau einer Quelle (Packung, wenn gesetzt, sonst die Behandlung). Die Menge ist der Text der Behandlung, sonst die Dosis mit Einheit des Mittels („30 ml“, auch ohne Packung, sobald ein Katalogmittel da ist). `treatmentLogGaps` (`SUPPLIER`, `PROOF`, `AMOUNT`, `WITHDRAWAL` mit 0 Tagen als erfasst, `VET` nur bei verschreibungspflichtigem Mittel) ist die **einzige Quelle**: `treatmentLogCells` druckt „FEHLT“ genau an diesen Stellen (die Tierarzt-Lücke in der Spalte „a) Lieferant …“ als „; Tierarzt: FEHLT“), und die Prüfbereitschaft nutzt dieselbe Funktion; FEHLT und Lücken sind also gleich. Zeilen mit gleichen aufgelösten Werten gruppiert das Buch nur bei gleicher Packung und gleichem `prescriptionOnly`. Fußnote: „Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.“ **Der Export braucht `export:read` und zusätzlich `veterinary-medicines:read`** (Annahme 73); ohne dieses Recht 403, die Stammkarte bleibt mit `export:read` erreichbar.
|
||||||
|
|
||||||
### 12.3 Dokumente mit Rechten je Art
|
### 12.3 Dokumente mit Rechten je Art
|
||||||
|
|
||||||
@@ -756,11 +771,18 @@ Für `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` gilt zusätzlic
|
|||||||
|
|
||||||
### 12.4 Etikett-Pflichtangaben
|
### 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.
|
`checkLabel` (`src/label-templates/label-particulars.ts`) ist rein und ohne Datenbank; Regeln und Grenzen: Annahmen 65 bis 68. **Pflichtangaben** (sperren den Druck: 409 `LABEL_PARTICULARS_MISSING` mit `{ gaps }`, Abschnitt 12.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). **Verfeinerungen (Runden 2 bis 4 der Task 5).**
|
||||||
|
- **Ziffern-Wächter je Element.** „Zahl direkt vor der Menge“ („1,250 g“ ist kein Treffer für 250 g) gilt nur im Text eines Elements, nie im verbundenen Text: Eine Losnummer oder ein Datum in einem Nachbarelement macht „500 g“ nicht ungültig. „250g“ gilt als „250 g“, „L2026“ bleibt.
|
||||||
|
- **Lesereihenfolge** kommt allein aus den Positionen (nach y, Zeilen mit 1 mm Toleranz gegen die erste Oberkante, je Zeile von links nach rechts), nie aus der Reihenfolge der Elemente in der Vorlage.
|
||||||
|
- **Verteilte Angaben** (Name und Anschrift): Zeilen, Blöcke in Lesereihenfolge oder Ketten wortgleicher Elemente, jeweils mit **symmetrischem** senkrechtem Abstand von höchstens 10 mm (`max(0, y − Unterkante, Oberkante − (y + h))`, gemessen an der laufenden Ober- und Unterkante der ganzen Gruppe, nicht am Vorgänger, `groupByDistance`). Elemente mit leerem normalisiertem Text werden ignoriert und überbrücken nichts; eine hohe Textbox überbrückt Zeilen um ihre Boxhöhe. Nur sichtbare (nicht abgeschnittene) Teile zählen (Annahme 75).
|
||||||
|
- **Zahl | Einheit** („500“ | „g“): zwei Elemente gelten als Paar, wenn sich die Boxen senkrecht überlappen, die Einheit rechts liegt und der waagerechte Abstand höchstens 10 mm beträgt (Annahme 74). Beide Teile zählen für `X_HEIGHT` und `NET_QUANTITY_HEIGHT`.
|
||||||
|
- **Träger** einer verteilten Angabe sind nur die Elemente, die sich mit dem Treffer im verbundenen Text überlappen (`matchParts`), nicht jedes Element mit einem gemeinsamen Wort; abgeschnittene Träger erscheinen über dieselbe Überlappung in `elementIds` der Lücke und von `TEXT_CLIPPED` (`clippedHolders`).
|
||||||
|
- **Zeitzonen-Unabhängigkeit.** Der Datumstag kommt aus `labelDayOf` (Berliner Kalendertag). Ein Quelltext-Wächter liest `label-templates.service.ts`, `label-particulars.ts`, `label-text.ts`, `font-metrics.ts`, `src/common/dates.ts` und `format-day.ts` und scheitert bei zeitzonenabhängigen Aufrufen (`getDate`, `getHours`, `toLocaleDateString` ohne `timeZone`, `Intl.DateTimeFormat(` mit oder ohne `new` ohne `timeZone`, mehrargumentiges `new Date(y, m, …)` u. a.); ein Selbsttest prüft die Muster an schlechten und guten Beispielen. Die Spec setzt keine Prozess-Zeitzone, sondern prüft wörtliche Berliner Tage.
|
||||||
|
- **Dokumentierte Grenzen:** Ein fremdes Element mit einem gemeinsamen Wort kann in eine Kette geraten; der waagerechte Abstand zählt bei Anschriften nicht; „honig“ in einem Wort genügt (Annahme 67); `NET_QUANTITY_HEIGHT` zählt die größte Nennung (Annahme 66). Eine Füllmenge, die auf drei oder mehr Elemente verteilt ist oder deren Einheit links steht, ist eine Lücke.
|
||||||
|
|
||||||
**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.
|
**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()`).
|
**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). Der Druck (12.6) ruft `labelDayOf(bottling.bestBeforeDate)` für `expected.bestBefore` und nutzt einen gemeinsamen Messer je Druck (`pdfTextMeasurer()`); `labelDayOf` wohnt in `label-text.ts` (vermeidet einen Importzyklus) und wird vom Service weiter exportiert.
|
||||||
|
|
||||||
### 12.5 Ereignisse für Völker und Stände
|
### 12.5 Ereignisse für Völker und Stände
|
||||||
|
|
||||||
@@ -771,3 +793,38 @@ Vier neue Typen im Katalog (`domain-event-types.ts`, 38 Typen), Aggregattyp `Com
|
|||||||
- `TskReferenceDayReached { year, referenceDay }` (Aggregat `Compliance`, `referenceDay` als `JJJJ-MM-TT`, tagesbezogen über `dailyDedupeKey`): nur im Katalog; der Erzeuger ist die Tagesauswertung (Task 11).
|
- `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.
|
`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.
|
||||||
|
|
||||||
|
### 12.6 Etikettendruck: Sperre, Probedruck, Audit
|
||||||
|
|
||||||
|
`LabelTemplatesService.renderLabelsPdf(templateId, bottlingBatchId, options, actingUserId)` mit `options = { startPosition?, proof? }` ersetzt die alte Signatur mit `startPosition`. Der Druck ruft vor dem Rendern dieselbe Prüfung `checkLabel` wie die Prüf-Endpunkte (eine private Methode `check`, dieselbe Aufbereitung der Platzhalter, dieselbe Füllmenge, derselbe MHD-Tag aus `labelDayOf`, dieselben Ziffernregeln).
|
||||||
|
- **Sperre.** Fehlt eine Pflichtangabe und ist `proof` nicht gesetzt, antwortet der Druck mit 409 `{ code: 'LABEL_PARTICULARS_MISSING', message, gaps }` und erzeugt kein PDF. Warnungen sperren nie, sie landen im Audit. `?proof=false` mit Lücke ist 409, `?proof=maybe` und `?proof=0` sind 400 (`ParseBoolPipe`).
|
||||||
|
- **Probedruck** (`?proof=true`): immer möglich; jedes Etikett trägt den Stempel „MUSTER – nicht verkaufsfähig“ (`label-proof-watermark.ts`: diagonal, 50 % Grau, halbtransparent, 80 % der Diagonale, höchstens 50 % der Etikettenhöhe; im gedrehten Bogen mit den Etikettenmaßen), damit ein Probeabzug nie als verkaufsfähig umläuft.
|
||||||
|
- **Audit vor dem PDF.** Jeder Druck, auch der Probedruck, schreibt vor der Auslieferung `PRINT` am `BottlingBatch` mit `{ templateId, copies, proof, gapCodes, warningCodes }`; `copies` ist immer `jarCount` der Abfüllung, unabhängig von `startPosition`. Schlägt das Audit fehl, schlägt der Druck fehl (fail closed; das Audit steht außerhalb einer Transaktion, es gibt keinen zu schützenden Datensatz).
|
||||||
|
- **Prüf-Endpunkte.** `GET /label-templates/:id/check/:bottlingBatchId` prüft eine gespeicherte Vorlage gegen eine Abfüllung, `POST /label-templates/check` (200, vor `:id` registriert) einen **ungespeicherten Entwurf**: nur Text-Elemente (`@Equals('text')`, andere Arten 400), höchstens 100, Element-Ids eindeutig, Breite und Höhe 0,1 bis 1000 mm, `x`/`y` −1000 bis 1000, `fontSize` höchstens 500, `minXHeightMm` 0,5 bis 5; ohne `bottlingBatchId` mit Beispielwerten (`sample: true`). `widthMm`/`heightMm` des Entwurfs werden nur auf Grenzen geprüft (10 bis 1000), maßgeblich sind die Feldgrößen der Elemente. `startPosition` liegt zwischen 1 und 1000, sonst 400.
|
||||||
|
- **Rechte.** Prüfen: `label-templates:read`, Drucken: `label-templates:print`; **zusätzlich `bottling:read`** bei `GET check`, `GET print` und bei `POST check` mit `bottlingBatchId` (sonst 403; der Controller prüft das über `PermissionResolverService.hasAny`, weil `@RequirePermission` nur „oder“ kennt). Der Entwurfs-Check mit Beispielwerten braucht nur `label-templates:read`. Zur Vergabe der Etikett-Rechte: Annahme 77.
|
||||||
|
- **Ziffernregeln nur aus `ComplianceSettings`.** Der Code kennt keine Rechtswerte und keine Rückfall-Vorgabe. `loadDigitRules` liest `ComplianceSettings.netQuantityDigitRules` der Zeile `singleton` (Migration `20261016100500`); fehlt die Zeile oder sind die Regeln unbrauchbar (`parseDigitRules`: leer, Höhe nicht größer als 0, nicht endlich, nicht genau eine Regel ohne Obergrenze), antworten Druck **und** Prüfung mit 409 `{ code: 'COMPLIANCE_SETTINGS_INVALID', message: 'Compliance-Einstellungen fehlen oder sind ungültig.' }` (fail closed; geloggt über `loggableError`, ohne Werte). Auch dann entsteht kein Audit.
|
||||||
|
- Alle Antworten (Prüfung wie PDF) sind `no-store` (die globale Middleware, Abschnitt 9); die Prüfantworten enthalten die Anschrift des Lebensmittelunternehmers. Das Frontend (Task 17) liest `GET`/`POST check` und die Lückenliste `gaps` des 409.
|
||||||
|
|
||||||
|
### 12.7 Sperrbezirk
|
||||||
|
|
||||||
|
`ApiaryRestriction` (Seuche, Behörde, Aktenzeichen, Beginn, Ende, Notiz) sperrt Bewegungen von Völkern in und aus einem Stand. **Aktiv** ist eine Sperre vom Beginn bis einschließlich Endtag, ohne Endtag bis auf Weiteres. Maßgeblich ist der **Berliner Kalendertag** (`berlinToday()` der Anwendung, nicht der UTC-Tag und kein Datenbank-`now()`; Entscheidung des Inhabers 2026-10-15). Eine Sperre wird nie gelöscht, sie endet über das Enddatum. Paragraphen und Fristen (BienSeuchV) sind Hinweise und vor dem Rollout gegen die Quelle zu prüfen.
|
||||||
|
- **Geschützte Pfade:** `ColoniesService.create` (Volk im Stand anlegen), `update` mit Standwechsel (alter oder neuer Stand; eine reine Namensänderung prüft nichts und sperrt keinen Stand), `move` (Quell- oder Zielstand), `merge` über verschiedene Stände und der **CSV-Import** (`ImportService.importColonyRow` sperrt den Stand `FOR SHARE`, prüft `ACTIVE` über `tx` und die Sperre ohne Ausnahme; Treffer sind Fehlerzeilen, Annahme 71).
|
||||||
|
- **Freie Pfade:** Ableger (`split`, im selben Stand), Auflösen (`dissolve`), Ernte (warnt nur, Task 13) und die Wiederherstellung aus dem Backup (prüft bewusst nichts); ebenso ein Umsetzen in den eigenen Stand und ein Vereinigen innerhalb eines Standes.
|
||||||
|
- **Antwort:** 409 `{ code: 'RESTRICTED_ZONE', message, restrictionIds, restrictions: [{ id, apiaryId, apiaryName, disease, startsOn }] }`; die Meldung nennt Stand, Seuche und Beginn `TT.MM.JJJJ`.
|
||||||
|
- **Ausnahme.** `restrictionOverrideReason` (getrimmt 10 bis 500 Zeichen) bei Anlegen, Bearbeiten, Umsetzen und Vereinigen. Ohne `compliance:manage` ist jede Begründung 403, **vorab** (vor Lesen und Sperren, auch ohne aktive Sperre; Annahme 72). Kein Step-up (Entscheidung des Inhabers). Mit dem Recht und aktiver Sperre landet die Begründung im Audit (`afterData.restrictionOverride = { reason, restrictionIds }`) und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“, vor einer vorhandenen Notiz; bei Anlegen und Vereinigen gibt es keine Historienzeile, dort nur das Audit). Die Begründung steht **nie am Volk**: sie wird vor jedem Prisma-Aufruf abgetrennt. Ohne aktive Sperre wird sie (mit dem Recht) ignoriert, `null` zählt wie weggelassen.
|
||||||
|
- **Sperrmuster und 409 „neu laden“.** Die Pfade lesen die Stand-Ids ungesperrt, sperren die Stände `FOR SHARE` aufsteigend, dann die Völker (beim Vereinigen Überlebendes und Aufgelöstes in einer Anweisung), vergleichen unter der Sperre den Stand des Volkes mit dem gelesenen (sonst 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, kein automatischer Neuversuch; ein verschwundenes Volk ist 404), lesen `assertApiaryActive` über `tx` und prüfen erst dann die Sperre. `ApiaryRestrictionsService` (`POST`, `PATCH`) sperrt die Stände `FOR UPDATE`: Ein laufendes Umsetzen und eine neu beginnende Sperre reihen sich hintereinander ein, eine Sperrenänderung wartet auf laufende Umsetzungen. `updateIn` nimmt selbst keinen Standlock; ein Patch mit Standwechsel ohne den Wächter aus `update()` wirft (Programmierfehler). Die Durchsicht-Übernahme (ohne `apiaryId`) sperrt nur das Volk.
|
||||||
|
- **Endpunkte.** `POST /apiary-restrictions` (`compliance:manage`, `@Idempotent`, 1 bis 50 verschiedene Stände, je Stand eine Zeile und ein Audit, eine Transaktion, unbekannter Stand 404 mit Rollback, `endsOn >= startsOn` sonst 400), `PATCH /apiary-restrictions/:id` (Stand `FOR UPDATE`, dann Sperre, `before` danach, `endsOn: null` öffnet wieder), `GET /apiary-restrictions?active=true|false`, `GET /apiaries/:id/restrictions` (`apiaries:read`).
|
||||||
|
- **Einzelheiten nur mit `compliance:read`:** Behörde, Aktenzeichen, Notiz und Ersteller liefern beide GET-Pfade nur dann; mit nur `apiaries:read` besteht eine Zeile aus `id`, `apiaryId`, `disease`, `startsOn`, `endsOn`. `active=true` liefert für alle die schlanke `ActiveRestrictionInfo` (ohne diese Felder). Öffentliche Seiten und der Shop-Feed nennen weder Seuche noch Sperre (e2e mit echter öffentlicher Charge).
|
||||||
|
- Das Frontend (Task 18/19) bietet den Ausnahmedialog nur mit `compliance:manage` an und sendet `restrictionOverrideReason`.
|
||||||
|
|
||||||
|
### 12.8 Behördenpaket I: Einstellungen, Bestandsbild, Anzeigen
|
||||||
|
|
||||||
|
`ComplianceModule` (`src/compliance/`, in `AppModule`; der Import im Worker folgt mit Task 11) enthält `ComplianceSettingsService`, `AuthorityNoticesService` und die reinen Helfer `tsk-day.ts`, `herd-snapshot.ts`, `herd-status.ts`. Kein Provider hängt am `PermissionResolverService`; das Modul exportiert beide Services.
|
||||||
|
- **Einstellungen.** `GET`/`PATCH /compliance/settings` (`compliance:read` / `compliance:manage`). `update` upsertet, sperrt `ComplianceSettings`, liest `before` danach und schreibt das Audit in derselben Transaktion. Prüfungen (400): `tskReferenceDay` und `tskReportDueDay` als `MM-TT` (02-29 und „1-1“ abgelehnt), `tskExcludedColonyTypes` ohne Doppelte, `netQuantityDigitRules` mit 1 bis 10 Regeln, Obergrenzen streng aufsteigend, nur die letzte ohne Grenze, `minMm` 0,5 bis 20; ein fehlender `maxGrams` wird vor der Prüfung zu `null` normalisiert und so gespeichert. Alle Rechts- und Fristwerte sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“.
|
||||||
|
- **Bestandsbild** (`herdStatusIn(db, now)`): lebende Völker (`LIVING_COLONY_WHERE`) an aktiven Ständen, bei der TSK-Zählung ohne `tskExcludedColonyTypes`; zwei ungesperrte Abfragen genügen für eine Anzeige. `herdDeviation` vergleicht `baseline.totalColonies` mit der aktuellen Zahl und erkennt neue, aufgegebene und verlegte Stände (Adresse, Ort, Koordinaten); ein Umsetzen zwischen Ständen löst nichts aus. `noticeOnColonyCountChange` und `noticeOnApiaryChange` schalten nur `triggered`, die Tatsachen stehen immer da.
|
||||||
|
- **Bezugsstand-Regeln.** Bezugsstand ist die jüngste `SENT`-Anzeige der Art `REGISTRATION` oder `HERD_CHANGE`, sortiert nach `sentOn`, dann `createdAt`, `id`; TSK-Zählnachweise zählen nicht. Ohne Bezugsstand ist `deviation = null` (Punkt „Erstanzeige fehlt“). `REGISTRATION` entsteht nur ohne Bezugsstand, `HERD_CHANGE` nur mit („Erst die Erstanzeige erfassen.“, 409); `TSK_REPORT` braucht `year`, andere Arten dürfen keines haben (400). Eine wiedergeöffnete Erstanzeige lässt sich erneut senden.
|
||||||
|
- **Lebenszyklus.** `DRAFT` → `SENT` (`mark-sent`) → `DRAFT` (`reopen`); `DRAFT` → `CANCELLED` (`cancel`). `recount` nur im Entwurf. `createDraftIn(tx, { kind, year?, referenceDate }, userId | null)` ist der gemeinsame Kern von `create` und dem des Tagesjobs (Task 11); `referenceDate` und `sentOn` beim Senden sind standardmäßig `berlinToday()`. Jeder Wechsel sperrt die Anzeige, liest `before` danach, prüft den Übergang (409, deutsche Meldung) und schreibt Audit `UPDATE` mit `beforeData`/`afterData` in derselben Transaktion. Die POST-Wechsel antworten wie die übrigen POST-Aktionen mit 201.
|
||||||
|
- **`openKey` und Mutex.** Anlegen, `mark-sent` und `reopen` sperren zuerst `ComplianceSettings` (`singleton`), dann die Anzeige (Reihenfolge der Karte). `openKey` (`REGISTRATION` | `HERD_CHANGE` | `TSK_REPORT:<Jahr>`, nur bei `DRAFT`, nullbar eindeutig) lässt höchstens einen offenen Entwurf je Art und Jahr zu; ein zweiter, auch im Rennen, antwortet 409 `{ code: 'DRAFT_EXISTS', existingId }`. Ein `P2002` ohne erklärenden Entwurf wird unverändert weitergeworfen. Die Prüfung auf einen offenen Entwurf entfällt, wenn die Anzeige mit `sentOn` angelegt wird.
|
||||||
|
- **`sentOn`-Regeln.** Mit `sentOn` beim Anlegen entsteht die Anzeige gleich als `SENT` ohne `openKey` (Weg „bereits angezeigt am …“ für Altbestand). `reopen` setzt `sentOn = null` und `openKey`, scheitert aber bei vorhandenem Entwurf (409 `DRAFT_EXISTS`). `PATCH` (`totalColonies`, `referenceDate`, `sentOn`, `sentVia`, `reference`, `notes`): `sentOn` auf einem Entwurf ist 400 „Ein Entwurf hat kein Sendedatum.“, das Sendedatum einer `SENT`-Anzeige ist nicht löschbar (400), eine `CANCELLED`-Anzeige nicht änderbar (409); fasst ein Patch `sentOn` an, nimmt `update` zuerst den Settings-Mutex.
|
||||||
|
- **Verwerfen statt Löschen.** `cancel` verwirft nur einen Entwurf (`CANCELLED`, `openKey` frei); es gibt keine Löschroute (Spec 7), angehängte Dokumente bleiben (Annahme 76). `GET /authority-notices?kind=&year=` liefert höchstens die 200 neuesten Einträge (`take: 200`), eine Seitenaufteilung fehlt noch.
|
||||||
|
- **Endpunkte.** `GET /authority-notices`, `GET …/:id`, `POST /authority-notices` (`@Idempotent`), `PATCH …/:id`, `POST …/:id/recount|mark-sent|reopen|cancel`; Lesen mit `compliance:read`, Schreiben mit `compliance:manage` (Nutzer ohne die Rechte: 403 auf Liste, Einzelanzeige und Einstellungen). Der PDF-Endpunkt `…/:id/pdf` folgt mit Task 10.
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-- T6 migration 6/6: the ComplianceSettings singleton row exists from the
|
||||||
|
-- start. The label check reads the net-quantity digit rules from it and fails
|
||||||
|
-- closed when the row is missing, so no legal value lives in the code. The
|
||||||
|
-- column defaults (digit rules, notice switches) fill the row; the owner has
|
||||||
|
-- to check them (ARCHITECTURE).
|
||||||
|
|
||||||
|
INSERT INTO "ComplianceSettings" ("id", "updatedAt")
|
||||||
|
VALUES ('singleton', now())
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
describeNullContracts,
|
||||||
|
NullContract,
|
||||||
|
} from '../common/validation/null-contract.spec-helper';
|
||||||
|
import { UpdateColonyDto } from '../colonies/dto/update-colony.dto';
|
||||||
|
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||||
|
|
||||||
|
const contracts: Record<string, NullContract> = {
|
||||||
|
UpdateApiaryRestrictionDto: {
|
||||||
|
dto: UpdateApiaryRestrictionDto,
|
||||||
|
notNull: ['disease', 'startsOn'],
|
||||||
|
clearable: ['endsOn', 'authority', 'reference', 'notes'],
|
||||||
|
},
|
||||||
|
// The reason is only read, never stored: null means "no override".
|
||||||
|
UpdateColonyDto: {
|
||||||
|
dto: UpdateColonyDto,
|
||||||
|
notNull: ['status'],
|
||||||
|
clearable: ['restrictionOverrideReason'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describeNullContracts('Apiary restriction DTOs', contracts);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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 { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||||
|
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||||
|
import { CreateApiaryRestrictionDto } from './dto/create-apiary-restriction.dto';
|
||||||
|
import { RestrictionListQuery } from './dto/restriction-list-query.dto';
|
||||||
|
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||||
|
|
||||||
|
// Full paths on an empty prefix: the list lives under /apiary-restrictions,
|
||||||
|
// the per-apiary read under /apiaries/:id/restrictions.
|
||||||
|
@ApiTags('apiary-restrictions')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller()
|
||||||
|
export class ApiaryRestrictionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly restrictions: ApiaryRestrictionsService,
|
||||||
|
private readonly permissions: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Authority, file reference and notes are for compliance:read only.
|
||||||
|
private async mayReadDetails(actor: AuthenticatedUser): Promise<boolean> {
|
||||||
|
return (await this.permissions.getPermissions(actor.id)).has(
|
||||||
|
'compliance:read',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
|
@Get('apiary-restrictions')
|
||||||
|
async findAll(
|
||||||
|
@Query() query: RestrictionListQuery,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.list(
|
||||||
|
query.active,
|
||||||
|
await this.mayReadDetails(actor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
|
@Get('apiaries/:id/restrictions')
|
||||||
|
async findForApiary(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.findForApiary(
|
||||||
|
id,
|
||||||
|
await this.mayReadDetails(actor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Idempotent()
|
||||||
|
@Post('apiary-restrictions')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateApiaryRestrictionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.create(dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Patch('apiary-restrictions/:id')
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateApiaryRestrictionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.restrictions.update(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApiaryRestrictionsController } from './apiary-restrictions.controller';
|
||||||
|
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not needed in WorkerModule: the service injects no permission resolver and
|
||||||
|
* the guard helper (compliance/restricted-zone.ts) is a plain function.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [ApiaryRestrictionsController],
|
||||||
|
providers: [ApiaryRestrictionsService],
|
||||||
|
exports: [ApiaryRestrictionsService],
|
||||||
|
})
|
||||||
|
export class ApiaryRestrictionsModule {}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||||
|
|
||||||
|
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('ApiaryRestrictionsService', () => {
|
||||||
|
let service: ApiaryRestrictionsService;
|
||||||
|
let tx: {
|
||||||
|
$queryRaw: jest.Mock;
|
||||||
|
apiary: { findMany: jest.Mock };
|
||||||
|
apiaryRestriction: {
|
||||||
|
create: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let prisma: {
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
apiaryRestriction: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
};
|
||||||
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx = {
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
apiary: { findMany: jest.fn() },
|
||||||
|
apiaryRestriction: {
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
apiary: { findUnique: jest.fn() },
|
||||||
|
apiaryRestriction: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
|
callback(tx),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new ApiaryRestrictionsService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
auditLog as unknown as AuditLogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function lockStatements(): string[] {
|
||||||
|
return (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map(
|
||||||
|
([query]) => query.sql,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const dto = {
|
||||||
|
apiaryIds: ['a2', 'a1'],
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates one row per apiary in one transaction, audits each, locks the apiaries first', async () => {
|
||||||
|
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
||||||
|
tx.apiaryRestriction.create.mockImplementation(
|
||||||
|
({ data }: { data: { apiaryId: string } }) =>
|
||||||
|
Promise.resolve({ id: `r-${data.apiaryId}`, ...data }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await service.create(dto, 'user-1');
|
||||||
|
|
||||||
|
expect(created.map((row) => row.apiaryId)).toEqual(['a1', 'a2']);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(lockStatements()).toEqual([
|
||||||
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||||
|
]);
|
||||||
|
const [[firstRow]] = tx.apiaryRestriction.create.mock.calls as [
|
||||||
|
[{ data: Record<string, unknown> }],
|
||||||
|
];
|
||||||
|
expect(firstRow.data).toMatchObject({
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
createdByUserId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(auditLog.record).toHaveBeenCalledTimes(2);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'ApiaryRestriction',
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
tx.apiaryRestriction.create.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls all back when one apiary is unknown (404 after the lock, nothing written)', async () => {
|
||||||
|
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
|
||||||
|
|
||||||
|
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.apiaryRestriction.create).not.toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an endsOn before startsOn with 400 before any lock', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({ ...dto, endsOn: '2026-09-30' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects no apiary and more than 50 apiaries with 400', async () => {
|
||||||
|
const ids = Array.from({ length: 51 }, (_, index) => `a${index}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ ...dto, apiaryIds: ids }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
await expect(
|
||||||
|
service.create({ ...dto, apiaryIds: [] }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
const stored = {
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
endsOn: null as Date | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||||
|
tx.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||||
|
tx.apiaryRestriction.update.mockImplementation(
|
||||||
|
({ data }: { data: object }) => Promise.resolve({ ...stored, ...data }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the apiary FOR UPDATE, then the restriction, then reads before', async () => {
|
||||||
|
await service.update('r1', { endsOn: '2026-10-20' }, 'user-1');
|
||||||
|
|
||||||
|
expect(lockStatements()).toEqual([
|
||||||
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||||
|
'SELECT id FROM "ApiaryRestriction" WHERE id = ? FOR UPDATE',
|
||||||
|
]);
|
||||||
|
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||||
|
tx.apiaryRestriction.findUnique.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ending a restriction is an update of endsOn, nothing is deleted', async () => {
|
||||||
|
const result = await service.update(
|
||||||
|
'r1',
|
||||||
|
{ endsOn: '2026-10-20' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'r1' },
|
||||||
|
data: { endsOn: day('2026-10-20') },
|
||||||
|
});
|
||||||
|
expect(result.endsOn).toEqual(day('2026-10-20'));
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'ApiaryRestriction',
|
||||||
|
entityId: 'r1',
|
||||||
|
action: 'UPDATE',
|
||||||
|
beforeData: stored,
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null clears endsOn', async () => {
|
||||||
|
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||||
|
...stored,
|
||||||
|
endsOn: day('2026-10-20'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update('r1', { endsOn: null }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'r1' },
|
||||||
|
data: { endsOn: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks endsOn >= startsOn on the merged state (400)', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('r1', { endsOn: '2026-09-30' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
await expect(
|
||||||
|
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||||
|
...stored,
|
||||||
|
endsOn: day('2026-10-20'),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 for an unknown restriction without locking', async () => {
|
||||||
|
prisma.apiaryRestriction.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reads', () => {
|
||||||
|
it('findActive lists only restrictions active today, with the apiary name', async () => {
|
||||||
|
prisma.apiaryRestriction.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
apiary: { name: 'Nord' },
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2000-01-01'),
|
||||||
|
endsOn: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2',
|
||||||
|
apiaryId: 'a2',
|
||||||
|
apiary: { name: 'Sued' },
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2000-01-01'),
|
||||||
|
endsOn: day('2000-01-02'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.findActive()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
apiaryName: 'Nord',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: '2000-01-01',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without compliance:read the lists carry no authority, reference, notes or creator', async () => {
|
||||||
|
const row = {
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
authority: 'Veterinaeramt',
|
||||||
|
reference: 'AZ 12/3',
|
||||||
|
notes: 'intern',
|
||||||
|
createdByUserId: 'u1',
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
endsOn: null,
|
||||||
|
};
|
||||||
|
prisma.apiaryRestriction.findMany.mockResolvedValue([row]);
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue({ id: 'a1' });
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
endsOn: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(service.list(undefined, false)).resolves.toEqual(expected);
|
||||||
|
await expect(service.findForApiary('a1', false)).resolves.toEqual(
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
await expect(service.list(undefined, true)).resolves.toEqual([row]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findForApiary answers 404 for an unknown apiary', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findForApiary('missing')).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiaryRestriction } from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
|
import { optionalDate } from '../common/patch-values';
|
||||||
|
import {
|
||||||
|
ActiveRestrictionInfo,
|
||||||
|
isRestrictionActive,
|
||||||
|
startedRestrictions,
|
||||||
|
toActiveRestrictionInfo,
|
||||||
|
} from '../compliance/restricted-zone';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
|
import {
|
||||||
|
CreateApiaryRestrictionDto,
|
||||||
|
MAX_RESTRICTION_APIARIES,
|
||||||
|
} from './dto/create-apiary-restriction.dto';
|
||||||
|
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||||
|
|
||||||
|
const AUDIT_ENTITY = 'ApiaryRestriction';
|
||||||
|
|
||||||
|
/** What a reader without compliance:read sees: no authority, reference, notes or creator. */
|
||||||
|
export interface RestrictionSummary {
|
||||||
|
id: string;
|
||||||
|
apiaryId: string;
|
||||||
|
disease: string;
|
||||||
|
startsOn: string;
|
||||||
|
endsOn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(row: ApiaryRestriction): RestrictionSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
apiaryId: row.apiaryId,
|
||||||
|
disease: row.disease,
|
||||||
|
startsOn: row.startsOn.toISOString().slice(0, 10),
|
||||||
|
endsOn: row.endsOn ? row.endsOn.toISOString().slice(0, 10) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEndNotBeforeStart(startsOn: Date, endsOn: Date | null): void {
|
||||||
|
if (endsOn !== null && endsOn < startsOn) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Das Ende der Sperre darf nicht vor ihrem Beginn liegen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricted zones (Sperrbezirke) of apiaries. A restriction is never deleted:
|
||||||
|
* it ends by an endsOn date, so the colony history stays explainable.
|
||||||
|
*
|
||||||
|
* Lock order (Task 1): the apiary rows FOR UPDATE first, then the restriction
|
||||||
|
* row. A colony path holds its apiaries FOR SHARE until it commits, so a
|
||||||
|
* restriction and a move into that apiary queue up behind each other.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApiaryRestrictionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** One row per apiary, all in one transaction; an unknown apiary rolls all back. */
|
||||||
|
async create(
|
||||||
|
dto: CreateApiaryRestrictionDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ApiaryRestriction[]> {
|
||||||
|
const apiaryIds = [...new Set(dto.apiaryIds)].sort();
|
||||||
|
if (apiaryIds.length < 1 || apiaryIds.length > MAX_RESTRICTION_APIARIES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Es sind 1 bis ${MAX_RESTRICTION_APIARIES} Stände möglich`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const startsOn = dayToDate(dto.startsOn);
|
||||||
|
const endsOn = dto.endsOn ? dayToDate(dto.endsOn) : null;
|
||||||
|
assertEndNotBeforeStart(startsOn, endsOn);
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowsForUpdate(tx, 'Apiary', apiaryIds);
|
||||||
|
const known = await tx.apiary.findMany({
|
||||||
|
where: { id: { in: apiaryIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (known.length !== apiaryIds.length) {
|
||||||
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||||
|
}
|
||||||
|
const created: ApiaryRestriction[] = [];
|
||||||
|
for (const apiaryId of apiaryIds) {
|
||||||
|
const restriction = await tx.apiaryRestriction.create({
|
||||||
|
data: {
|
||||||
|
apiaryId,
|
||||||
|
disease: dto.disease,
|
||||||
|
authority: dto.authority,
|
||||||
|
reference: dto.reference,
|
||||||
|
startsOn,
|
||||||
|
endsOn,
|
||||||
|
notes: dto.notes,
|
||||||
|
createdByUserId: actingUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: AUDIT_ENTITY,
|
||||||
|
entityId: restriction.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: restriction,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
created.push(restriction);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateApiaryRestrictionDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ApiaryRestriction> {
|
||||||
|
const seen = await this.prisma.apiaryRestriction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { apiaryId: true },
|
||||||
|
});
|
||||||
|
if (!seen) {
|
||||||
|
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||||
|
}
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowsForUpdate(tx, 'Apiary', [seen.apiaryId]);
|
||||||
|
await lockRowForUpdate(tx, 'ApiaryRestriction', id);
|
||||||
|
const before = await tx.apiaryRestriction.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||||
|
}
|
||||||
|
const startsOnPatch =
|
||||||
|
dto.startsOn === undefined ? undefined : dayToDate(dto.startsOn);
|
||||||
|
const startsOn = startsOnPatch ?? before.startsOn;
|
||||||
|
const endsOn = optionalDate(dto.endsOn);
|
||||||
|
assertEndNotBeforeStart(
|
||||||
|
startsOn,
|
||||||
|
endsOn === undefined ? before.endsOn : endsOn,
|
||||||
|
);
|
||||||
|
const updated = await tx.apiaryRestriction.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
disease: dto.disease,
|
||||||
|
authority: dto.authority,
|
||||||
|
reference: dto.reference,
|
||||||
|
startsOn: startsOnPatch,
|
||||||
|
endsOn,
|
||||||
|
notes: dto.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: AUDIT_ENTITY,
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The restrictions in force today (Berlin day), with the apiary name. */
|
||||||
|
async findActive(): Promise<ActiveRestrictionInfo[]> {
|
||||||
|
const today = berlinToday();
|
||||||
|
const started = await startedRestrictions(this.prisma, undefined, today);
|
||||||
|
return started
|
||||||
|
.filter((row) => isRestrictionActive(row, today))
|
||||||
|
.map(toActiveRestrictionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every restriction (newest first); `active` narrows to in force / ended.
|
||||||
|
* Authority, file reference, notes and the creator are for callers with
|
||||||
|
* compliance:read (`detailed`); everybody else gets the summary.
|
||||||
|
*/
|
||||||
|
async list(
|
||||||
|
active?: boolean,
|
||||||
|
detailed = true,
|
||||||
|
): Promise<
|
||||||
|
ApiaryRestriction[] | ActiveRestrictionInfo[] | RestrictionSummary[]
|
||||||
|
> {
|
||||||
|
if (active === true) {
|
||||||
|
return this.findActive();
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||||
|
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
const wanted =
|
||||||
|
active === false
|
||||||
|
? rows.filter((row) => !isRestrictionActive(row, berlinToday()))
|
||||||
|
: rows;
|
||||||
|
return detailed ? wanted : wanted.map(toSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findForApiary(
|
||||||
|
apiaryId: string,
|
||||||
|
detailed = true,
|
||||||
|
): Promise<ApiaryRestriction[] | RestrictionSummary[]> {
|
||||||
|
const apiary = await this.prisma.apiary.findUnique({
|
||||||
|
where: { id: apiaryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||||
|
where: { apiaryId },
|
||||||
|
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return detailed ? rows : rows.map(toSummary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const MAX_RESTRICTION_APIARIES = 50;
|
||||||
|
const MAX_ID_LENGTH = 64;
|
||||||
|
const MAX_TEXT_LENGTH = 200;
|
||||||
|
const MAX_NOTE_LENGTH = 2000;
|
||||||
|
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||||
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||||
|
|
||||||
|
/** A restricted zone declared by the authority, for one or several apiaries. */
|
||||||
|
export class CreateApiaryRestrictionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
description: 'Ein Datensatz je Stand, 1 bis 50 Stände',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(MAX_RESTRICTION_APIARIES)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Matches(ID_PATTERN, { each: true })
|
||||||
|
@MaxLength(MAX_ID_LENGTH, { each: true })
|
||||||
|
apiaryIds!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Seuche, z. B. Amerikanische Faulbrut' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/\S/, { message: 'disease darf nicht nur aus Leerzeichen bestehen' })
|
||||||
|
@MaxLength(MAX_TEXT_LENGTH)
|
||||||
|
disease!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, description: 'Anordnende Behörde' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_TEXT_LENGTH)
|
||||||
|
authority?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_TEXT_LENGTH)
|
||||||
|
reference?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-10-01', description: 'Erster Tag der Sperre' })
|
||||||
|
@Matches(DAY_PATTERN, { message: `startsOn ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
startsOn!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
example: '2026-12-01',
|
||||||
|
description: 'Letzter Tag der Sperre; leer = bis auf Weiteres',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(DAY_PATTERN, { message: `endsOn ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
endsOn?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NOTE_LENGTH)
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { booleanQuery } from '../../veterinary-medicines/dto/medicine-list-query.dto';
|
||||||
|
|
||||||
|
export class RestrictionListQuery {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'true: nur heute geltende Sperren; false: nur beendete',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(booleanQuery)
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateApiaryRestrictionDto } from './create-apiary-restriction.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The apiary of a restriction is fixed: a restriction for another stand is a
|
||||||
|
* new restriction. Omitted stays, null clears a nullable field (endsOn ends
|
||||||
|
* the restriction as open again), null on disease or startsOn is a 400.
|
||||||
|
*/
|
||||||
|
export class UpdateApiaryRestrictionDto extends PartialType(
|
||||||
|
OmitType(CreateApiaryRestrictionDto, ['apiaryIds'] as const),
|
||||||
|
{ skipNullProperties: false },
|
||||||
|
) {}
|
||||||
@@ -61,6 +61,7 @@ import { AutopilotModule } from './autopilot/autopilot.module';
|
|||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||||
|
import { ComplianceModule } from './compliance/compliance.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
@@ -134,6 +135,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
|||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
VeterinaryMedicinesModule,
|
VeterinaryMedicinesModule,
|
||||||
|
ComplianceModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module';
|
||||||
import { ColoniesController } from './colonies.controller';
|
import { ColoniesController } from './colonies.controller';
|
||||||
import { ColoniesService } from './colonies.service';
|
import { ColoniesService } from './colonies.service';
|
||||||
|
|
||||||
|
// ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the
|
||||||
|
// colony paths that enforce them (AppModule imports ColoniesModule).
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ApiaryRestrictionsModule],
|
||||||
controllers: [ColoniesController],
|
controllers: [ColoniesController],
|
||||||
providers: [ColoniesService],
|
providers: [ColoniesService],
|
||||||
exports: [ColoniesService],
|
exports: [ColoniesService],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { berlinToday } from '../common/dates';
|
import { berlinToday } from '../common/dates';
|
||||||
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ describe('ColoniesService', () => {
|
|||||||
colonyLineage: { create: jest.Mock };
|
colonyLineage: { create: jest.Mock };
|
||||||
queenColonyHistory: { updateMany: jest.Mock };
|
queenColonyHistory: { updateMany: jest.Mock };
|
||||||
suggestion: { updateMany: jest.Mock };
|
suggestion: { updateMany: jest.Mock };
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
apiaryRestriction: { findMany: jest.Mock };
|
||||||
};
|
};
|
||||||
let prisma: {
|
let prisma: {
|
||||||
colony: {
|
colony: {
|
||||||
@@ -32,6 +35,7 @@ describe('ColoniesService', () => {
|
|||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let events: { record: jest.Mock; recordAll: jest.Mock };
|
let events: { record: jest.Mock; recordAll: jest.Mock };
|
||||||
|
let resolver: { getPermissions: jest.Mock };
|
||||||
|
|
||||||
const parentColony = {
|
const parentColony = {
|
||||||
id: 'colony-1',
|
id: 'colony-1',
|
||||||
@@ -49,10 +53,12 @@ describe('ColoniesService', () => {
|
|||||||
colonyLineage: { create: jest.fn() },
|
colonyLineage: { create: jest.fn() },
|
||||||
queenColonyHistory: { updateMany: jest.fn() },
|
queenColonyHistory: { updateMany: jest.fn() },
|
||||||
suggestion: { updateMany: jest.fn() },
|
suggestion: { updateMany: jest.fn() },
|
||||||
|
apiary: { findUnique: jest.fn() },
|
||||||
|
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
colony: {
|
colony: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -69,6 +75,10 @@ describe('ColoniesService', () => {
|
|||||||
callback(tx),
|
callback(tx),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
// The apiary is read inside the transaction now: same answers as before.
|
||||||
|
tx.apiary.findUnique.mockImplementation(
|
||||||
|
(args: unknown) => prisma.apiary.findUnique(args) as Promise<unknown>,
|
||||||
|
);
|
||||||
// The locked re-read sees what the pre-read saw unless a test says otherwise.
|
// The locked re-read sees what the pre-read saw unless a test says otherwise.
|
||||||
tx.colony.findUnique.mockImplementation(
|
tx.colony.findUnique.mockImplementation(
|
||||||
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
|
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
|
||||||
@@ -79,11 +89,17 @@ describe('ColoniesService', () => {
|
|||||||
record: jest.fn().mockResolvedValue(undefined),
|
record: jest.fn().mockResolvedValue(undefined),
|
||||||
recordAll: jest.fn().mockResolvedValue(0),
|
recordAll: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
|
resolver = {
|
||||||
|
getPermissions: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Set(['compliance:manage'])),
|
||||||
|
};
|
||||||
service = new ColoniesService(
|
service = new ColoniesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
events as unknown as DomainEventsService,
|
events as unknown as DomainEventsService,
|
||||||
|
resolver as unknown as PermissionResolverService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,17 +253,23 @@ describe('ColoniesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
|
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
|
||||||
prisma.colony.findUnique
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
.mockResolvedValueOnce(parentColony)
|
prisma.colony.findMany.mockResolvedValue([
|
||||||
.mockResolvedValueOnce({
|
{ id: 'colony-3', apiaryId: 'apiary-1' },
|
||||||
...parentColony,
|
]);
|
||||||
id: 'colony-1',
|
tx.colony.findUnique.mockImplementation(
|
||||||
});
|
({ where }: { where: { id: string } }) =>
|
||||||
tx.colony.findUnique.mockResolvedValue({
|
Promise.resolve(
|
||||||
id: 'colony-3',
|
where.id === 'colony-1'
|
||||||
status: 'ACTIVE',
|
? parentColony
|
||||||
currentQueenId: 'queen-2',
|
: {
|
||||||
});
|
id: 'colony-3',
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentQueenId: 'queen-2',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
tx.colony.update.mockResolvedValue({
|
tx.colony.update.mockResolvedValue({
|
||||||
id: 'colony-3',
|
id: 'colony-3',
|
||||||
status: 'MERGED',
|
status: 'MERGED',
|
||||||
@@ -725,10 +747,15 @@ describe('ColoniesService', () => {
|
|||||||
|
|
||||||
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
|
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
|
||||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
prisma.colony.findMany.mockResolvedValue([
|
||||||
|
{ id: 'colony-3', apiaryId: 'apiary-1' },
|
||||||
|
{ id: 'colony-4', apiaryId: 'apiary-1' },
|
||||||
|
]);
|
||||||
tx.colony.findUnique.mockImplementation(
|
tx.colony.findUnique.mockImplementation(
|
||||||
({ where }: { where: { id: string } }) =>
|
({ where }: { where: { id: string } }) =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
id: where.id,
|
id: where.id,
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
healthStatus: null,
|
healthStatus: null,
|
||||||
currentQueenId: null,
|
currentQueenId: null,
|
||||||
@@ -809,7 +836,8 @@ describe('ColoniesService', () => {
|
|||||||
});
|
});
|
||||||
await service.reactivate('colony-1', 'user-1');
|
await service.reactivate('colony-1', 'user-1');
|
||||||
|
|
||||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
|
// update 1, move 2 (apiary share + colony), dissolve 1, reactivate 1.
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
|
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
|
||||||
@@ -860,9 +888,18 @@ describe('ColoniesService', () => {
|
|||||||
|
|
||||||
it('locks every merged colony', async () => {
|
it('locks every merged colony', async () => {
|
||||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
prisma.colony.findMany.mockResolvedValue([
|
||||||
|
{ id: 'colony-3', apiaryId: 'apiary-1' },
|
||||||
|
{ id: 'colony-4', apiaryId: 'apiary-1' },
|
||||||
|
]);
|
||||||
tx.colony.findUnique.mockImplementation(
|
tx.colony.findUnique.mockImplementation(
|
||||||
({ where }: { where: { id: string } }) =>
|
({ where }: { where: { id: string } }) =>
|
||||||
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }),
|
Promise.resolve({
|
||||||
|
id: where.id,
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
healthStatus: null,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
tx.colony.update.mockImplementation(
|
tx.colony.update.mockImplementation(
|
||||||
({ where }: { where: { id: string } }) =>
|
({ where }: { where: { id: string } }) =>
|
||||||
@@ -875,10 +912,12 @@ describe('ColoniesService', () => {
|
|||||||
'user-1',
|
'user-1',
|
||||||
);
|
);
|
||||||
|
|
||||||
// One multi-row statement, not one per colony.
|
// The apiary rows (share), then ONE multi-row statement for the colonies.
|
||||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
|
||||||
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
|
const [, [query]] = tx.$queryRaw.mock.calls as [unknown, [{ sql: string }]];
|
||||||
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
|
expect(query.sql).toContain(
|
||||||
|
'"Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('a colony that ends through an edit', () => {
|
describe('a colony that ends through an edit', () => {
|
||||||
@@ -932,4 +971,521 @@ describe('ColoniesService', () => {
|
|||||||
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
|
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('restricted zone', () => {
|
||||||
|
const reason = 'Amtstierarzt hat die Verbringung genehmigt';
|
||||||
|
const reloadMessage = 'Der Stand wurde gerade geändert. Bitte neu laden.';
|
||||||
|
const startedLongAgo = new Date('2000-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// An open restriction on each of the given apiaries.
|
||||||
|
function restrict(...apiaryIds: string[]): void {
|
||||||
|
tx.apiaryRestriction.findMany.mockImplementation(
|
||||||
|
({ where }: { where: { apiaryId: { in: string[] } } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
apiaryIds
|
||||||
|
.filter((apiaryId) => where.apiaryId.in.includes(apiaryId))
|
||||||
|
.map((apiaryId) => ({
|
||||||
|
id: `r-${apiaryId}`,
|
||||||
|
apiaryId,
|
||||||
|
apiary: { name: `Stand ${apiaryId}` },
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: startedLongAgo,
|
||||||
|
endsOn: null,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockSql(): string[] {
|
||||||
|
return (tx.$queryRaw.mock.calls as [{ sql: string }][]).map(
|
||||||
|
([query]) => query.sql,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMove(): void {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.update.mockResolvedValue({
|
||||||
|
...parentColony,
|
||||||
|
apiaryId: 'apiary-2',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMerge(mergedApiaryId = 'apiary-2'): void {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
prisma.colony.findMany.mockResolvedValue([
|
||||||
|
{ id: 'colony-3', apiaryId: mergedApiaryId },
|
||||||
|
]);
|
||||||
|
tx.colony.findUnique.mockImplementation(
|
||||||
|
({ where }: { where: { id: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
where.id === 'colony-1'
|
||||||
|
? parentColony
|
||||||
|
: {
|
||||||
|
id: 'colony-3',
|
||||||
|
apiaryId: mergedApiaryId,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentQueenId: null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tx.colony.update.mockResolvedValue({
|
||||||
|
id: 'colony-3',
|
||||||
|
status: 'MERGED',
|
||||||
|
healthStatus: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDto = {
|
||||||
|
code: 'V-9',
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
colonyType: 'PRODUCTION_COLONY' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('split inside the apiary is allowed', async () => {
|
||||||
|
restrict('apiary-1');
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.create.mockResolvedValue({
|
||||||
|
id: 'colony-2',
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
});
|
||||||
|
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.split(
|
||||||
|
'colony-1',
|
||||||
|
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create in a restricted apiary is refused', async () => {
|
||||||
|
restrict('apiary-1');
|
||||||
|
|
||||||
|
await expect(service.create(createDto, 'user-1')).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
response: { code: 'RESTRICTED_ZONE' },
|
||||||
|
});
|
||||||
|
expect(tx.colony.create).not.toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['into', 'apiary-2'],
|
||||||
|
['out of', 'apiary-1'],
|
||||||
|
])('move %s a restricted apiary is refused', async (_way, restricted) => {
|
||||||
|
setupMove();
|
||||||
|
restrict(restricted);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
response: { code: 'RESTRICTED_ZONE' },
|
||||||
|
});
|
||||||
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('move to the same apiary moves nothing and checks nothing', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.update.mockResolvedValue(parentColony);
|
||||||
|
restrict('apiary-1');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.move('colony-1', { apiaryId: 'apiary-1' }, 'user-1'),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['the old apiary', 'apiary-1'],
|
||||||
|
['the new apiary', 'apiary-2'],
|
||||||
|
])('update with a changed apiary checks %s', async (_which, restricted) => {
|
||||||
|
setupMove();
|
||||||
|
restrict(restricted);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
response: { code: 'RESTRICTED_ZONE' },
|
||||||
|
});
|
||||||
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a name change checks nothing and takes no apiary lock', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.update.mockResolvedValue(parentColony);
|
||||||
|
restrict('apiary-1', 'apiary-2');
|
||||||
|
|
||||||
|
await service.update('colony-1', { code: 'V-neu' }, 'user-1');
|
||||||
|
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(lockSql().every((sql) => !sql.includes('"Apiary"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge across apiaries is refused when one is restricted', async () => {
|
||||||
|
setupMerge('apiary-2');
|
||||||
|
restrict('apiary-2');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
response: { code: 'RESTRICTED_ZONE' },
|
||||||
|
});
|
||||||
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.colonyLineage.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge within one apiary is allowed even in a restricted apiary', async () => {
|
||||||
|
setupMerge('apiary-1');
|
||||||
|
restrict('apiary-1');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenarios: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
run: (restrictionOverrideReason: string) => Promise<unknown>;
|
||||||
|
setup: () => void;
|
||||||
|
restricted: string[];
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
create: {
|
||||||
|
setup: () => {
|
||||||
|
tx.colony.create.mockResolvedValue({
|
||||||
|
id: 'colony-9',
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
restricted: ['apiary-1'],
|
||||||
|
run: (restrictionOverrideReason) =>
|
||||||
|
service.create({ ...createDto, restrictionOverrideReason }, 'user-1'),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
setup: setupMove,
|
||||||
|
restricted: ['apiary-2'],
|
||||||
|
run: (restrictionOverrideReason) =>
|
||||||
|
service.update(
|
||||||
|
'colony-1',
|
||||||
|
{ apiaryId: 'apiary-2', restrictionOverrideReason },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
move: {
|
||||||
|
setup: setupMove,
|
||||||
|
restricted: ['apiary-2'],
|
||||||
|
run: (restrictionOverrideReason) =>
|
||||||
|
service.move(
|
||||||
|
'colony-1',
|
||||||
|
{ apiaryId: 'apiary-2', restrictionOverrideReason },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
merge: {
|
||||||
|
setup: () => setupMerge('apiary-2'),
|
||||||
|
restricted: ['apiary-2'],
|
||||||
|
run: (restrictionOverrideReason) =>
|
||||||
|
service.merge(
|
||||||
|
'colony-1',
|
||||||
|
{ mergedColonyIds: ['colony-3'], restrictionOverrideReason },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(Object.keys(scenarios))(
|
||||||
|
'the override reason is never part of the data written to the colony (%s)',
|
||||||
|
async (name) => {
|
||||||
|
const scenario = scenarios[name];
|
||||||
|
scenario.setup();
|
||||||
|
restrict(...scenario.restricted);
|
||||||
|
|
||||||
|
await scenario.run(reason);
|
||||||
|
|
||||||
|
const written = JSON.stringify([
|
||||||
|
tx.colony.create.mock.calls,
|
||||||
|
tx.colony.update.mock.calls,
|
||||||
|
]);
|
||||||
|
expect(written).not.toContain(reason);
|
||||||
|
expect(written).not.toContain('restrictionOverrideReason');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(Object.keys(scenarios))(
|
||||||
|
'an override writes the reason and the restriction ids into the audit entry (%s)',
|
||||||
|
async (name) => {
|
||||||
|
const scenario = scenarios[name];
|
||||||
|
scenario.setup();
|
||||||
|
restrict(...scenario.restricted);
|
||||||
|
|
||||||
|
await scenario.run(reason);
|
||||||
|
|
||||||
|
const audits = (
|
||||||
|
auditLog.record.mock.calls as [
|
||||||
|
{ afterData: { restrictionOverride?: unknown } },
|
||||||
|
][]
|
||||||
|
).filter(([entry]) => entry.afterData.restrictionOverride);
|
||||||
|
expect(audits).toHaveLength(1);
|
||||||
|
expect(audits[0][0].afterData.restrictionOverride).toEqual({
|
||||||
|
reason,
|
||||||
|
restrictionIds: [`r-${scenario.restricted[0]}`],
|
||||||
|
});
|
||||||
|
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('an override writes the reason into ColonyApiaryHistory.notes, in front of an existing note', async () => {
|
||||||
|
setupMove();
|
||||||
|
restrict('apiary-2');
|
||||||
|
|
||||||
|
await service.move(
|
||||||
|
'colony-1',
|
||||||
|
{
|
||||||
|
apiaryId: 'apiary-2',
|
||||||
|
notes: 'Wanderung',
|
||||||
|
restrictionOverrideReason: reason,
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
await service.update(
|
||||||
|
'colony-1',
|
||||||
|
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const notes = (
|
||||||
|
tx.colonyApiaryHistory.create.mock.calls as [
|
||||||
|
{ data: { notes: string } },
|
||||||
|
][]
|
||||||
|
).map(([call]) => call.data.notes);
|
||||||
|
expect(notes).toEqual([
|
||||||
|
`Ausnahme Sperrbezirk: ${reason}\nWanderung`,
|
||||||
|
`Ausnahme Sperrbezirk: ${reason}\nKorrektur über Volk bearbeiten`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an override without compliance:manage answers 403 and writes nothing', async () => {
|
||||||
|
setupMove();
|
||||||
|
restrict('apiary-2');
|
||||||
|
resolver.getPermissions.mockResolvedValue(new Set(['colonies:update']));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.move(
|
||||||
|
'colony-1',
|
||||||
|
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 403 });
|
||||||
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reason without an active restriction is ignored for a user with the right: no note, no audit field', async () => {
|
||||||
|
setupMove();
|
||||||
|
|
||||||
|
await service.move(
|
||||||
|
'colony-1',
|
||||||
|
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
JSON.stringify(tx.colonyApiaryHistory.create.mock.calls),
|
||||||
|
).not.toContain('Ausnahme');
|
||||||
|
expect(JSON.stringify(auditLog.record.mock.calls)).not.toContain(
|
||||||
|
'restrictionOverride',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(Object.keys(scenarios))(
|
||||||
|
'a reason without compliance:manage answers 403 up front, even without any restriction (%s)',
|
||||||
|
async (name) => {
|
||||||
|
const scenario = scenarios[name];
|
||||||
|
scenario.setup();
|
||||||
|
resolver.getPermissions.mockResolvedValue(new Set());
|
||||||
|
|
||||||
|
await expect(scenario.run(reason)).rejects.toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.colony.create).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('merge answers 404, not "moved meanwhile", when a merged colony vanished between read and lock', async () => {
|
||||||
|
setupMerge('apiary-2');
|
||||||
|
tx.colony.findUnique.mockImplementation(
|
||||||
|
({ where }: { where: { id: string } }) =>
|
||||||
|
Promise.resolve(where.id === 'colony-1' ? parentColony : null),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a null reason counts as omitted and asks for no permission', async () => {
|
||||||
|
setupMove();
|
||||||
|
restrict('apiary-2');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.move(
|
||||||
|
'colony-1',
|
||||||
|
{ apiaryId: 'apiary-2', restrictionOverrideReason: null },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
expect(resolver.getPermissions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the restriction check runs inside the transaction after the apiary rows (share, ascending) and then the colony row are locked', async () => {
|
||||||
|
setupMove();
|
||||||
|
|
||||||
|
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||||
|
|
||||||
|
expect(lockSql()).toEqual([
|
||||||
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
|
||||||
|
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
|
||||||
|
]);
|
||||||
|
const [[shareQuery]] = tx.$queryRaw.mock.calls as [
|
||||||
|
[{ values: unknown[] }],
|
||||||
|
];
|
||||||
|
expect(shareQuery.values).toEqual([['apiary-1', 'apiary-2']]);
|
||||||
|
const order = [
|
||||||
|
tx.$queryRaw.mock.invocationCallOrder[0],
|
||||||
|
tx.$queryRaw.mock.invocationCallOrder[1],
|
||||||
|
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||||
|
tx.colony.update.mock.invocationCallOrder[0],
|
||||||
|
];
|
||||||
|
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||||
|
expect(tx.apiary.findUnique.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||||
|
tx.$queryRaw.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create locks the apiary (share) before it checks and writes', async () => {
|
||||||
|
tx.colony.create.mockResolvedValue({
|
||||||
|
id: 'colony-9',
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.create(createDto, 'user-1');
|
||||||
|
|
||||||
|
expect(lockSql()).toEqual([
|
||||||
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
|
||||||
|
]);
|
||||||
|
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||||
|
).toBeLessThan(tx.colony.create.mock.invocationCallOrder[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge locks the apiaries (share), then survivor and merged colonies in one statement', async () => {
|
||||||
|
setupMerge('apiary-2');
|
||||||
|
|
||||||
|
await service.merge(
|
||||||
|
'colony-1',
|
||||||
|
{ mergedColonyIds: ['colony-3'] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lockSql()).toEqual([
|
||||||
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
|
||||||
|
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||||
|
).toBeGreaterThan(tx.$queryRaw.mock.invocationCallOrder[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update, move and merge answer 409 when the colony changed its apiary between the unlocked read and the lock', async () => {
|
||||||
|
const movedAway = { ...parentColony, apiaryId: 'apiary-3' };
|
||||||
|
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.findUnique.mockResolvedValue(movedAway);
|
||||||
|
await expect(
|
||||||
|
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||||
|
await expect(
|
||||||
|
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||||
|
|
||||||
|
setupMerge('apiary-2');
|
||||||
|
tx.colony.findUnique.mockImplementation(
|
||||||
|
({ where }: { where: { id: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
where.id === 'colony-1'
|
||||||
|
? parentColony
|
||||||
|
: { id: 'colony-3', apiaryId: 'apiary-3', status: 'ACTIVE' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||||
|
tx.colony.findUnique.mockImplementation(
|
||||||
|
({ where }: { where: { id: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
where.id === 'colony-1'
|
||||||
|
? movedAway
|
||||||
|
: { id: 'colony-3', apiaryId: 'apiary-2', status: 'ACTIVE' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||||
|
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||||
|
|
||||||
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an update without an apiary change still works when the colony was moved meanwhile', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.findUnique.mockResolvedValue({
|
||||||
|
...parentColony,
|
||||||
|
apiaryId: 'apiary-3',
|
||||||
|
});
|
||||||
|
tx.colony.update.mockResolvedValue(parentColony);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('colony-1', { notes: 'x' }, 'user-1'),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateIn called from an inspection takeover locks no apiary', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.update.mockResolvedValue(parentColony);
|
||||||
|
|
||||||
|
await service.updateIn(tx as never, 'colony-1', { notes: 'x' }, 'user-1');
|
||||||
|
|
||||||
|
expect(lockSql()).toEqual([
|
||||||
|
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
|
||||||
|
]);
|
||||||
|
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateIn refuses to move the apiary without the locks update() takes', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateIn(
|
||||||
|
tx as never,
|
||||||
|
'colony-1',
|
||||||
|
{ apiaryId: 'apiary-2' },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow('moving the apiary');
|
||||||
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -21,9 +22,21 @@ import {
|
|||||||
isLivingColonyStatus,
|
isLivingColonyStatus,
|
||||||
TERMINAL_COLONY_STATUSES,
|
TERMINAL_COLONY_STATUSES,
|
||||||
} from './colony-status';
|
} from './colony-status';
|
||||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
import { colonyTransitionEvents, lockColony } from './colony-events';
|
import { colonyTransitionEvents, lockColony } from './colony-events';
|
||||||
import { berlinToday, dayToDate } from '../common/dates';
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import {
|
||||||
|
assertNoActiveRestriction,
|
||||||
|
RestrictionCheck,
|
||||||
|
RestrictionOverride,
|
||||||
|
} from '../compliance/restricted-zone';
|
||||||
|
import {
|
||||||
|
apiaryChangedMeanwhile,
|
||||||
|
NOT_OVERRIDDEN,
|
||||||
|
overrideNote,
|
||||||
|
overrideTrace,
|
||||||
|
} from './restriction-override';
|
||||||
|
|
||||||
function colonyMovedEvent(
|
function colonyMovedEvent(
|
||||||
colonyId: string,
|
colonyId: string,
|
||||||
@@ -40,6 +53,21 @@ function colonyMovedEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The fields update() writes: the override reason is read, never stored. */
|
||||||
|
type ColonyPatch = Omit<UpdateColonyDto, 'restrictionOverrideReason'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What update() hands to updateIn when it moves the colony to another apiary:
|
||||||
|
* the apiary the unlocked read saw (the apiary rows were locked for it) and
|
||||||
|
* the override the user may use.
|
||||||
|
*/
|
||||||
|
export interface ApiaryMoveGuard {
|
||||||
|
seenApiaryId: string;
|
||||||
|
override: RestrictionOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERRIDE_PERMISSION = 'compliance:manage';
|
||||||
|
|
||||||
export interface ColonyFilter {
|
export interface ColonyFilter {
|
||||||
status?: ColonyStatus;
|
status?: ColonyStatus;
|
||||||
apiaryId?: string;
|
apiaryId?: string;
|
||||||
@@ -52,6 +80,7 @@ export class ColoniesService {
|
|||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly events: DomainEventsService,
|
private readonly events: DomainEventsService,
|
||||||
|
private readonly permissions: PermissionResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
||||||
@@ -84,11 +113,14 @@ export class ColoniesService {
|
|||||||
// this, a colony could be created or moved into an apiary that either
|
// this, a colony could be created or moved into an apiary that either
|
||||||
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
|
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
|
||||||
// 404) or is deactivated (silently orphaning the colony the same way an
|
// 404) or is deactivated (silently orphaning the colony the same way an
|
||||||
// apiary deactivated out from under an existing colony would).
|
// apiary deactivated out from under an existing colony would). Read inside
|
||||||
private async assertApiaryActive(apiaryId: string): Promise<void> {
|
// the transaction, after the apiary rows are locked (FOR SHARE): the
|
||||||
const apiary = await this.prisma.apiary.findUnique({
|
// apiary cannot be deactivated before this colony path commits.
|
||||||
where: { id: apiaryId },
|
private async assertApiaryActiveIn(
|
||||||
});
|
tx: Prisma.TransactionClient,
|
||||||
|
apiaryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const apiary = await tx.apiary.findUnique({ where: { id: apiaryId } });
|
||||||
if (!apiary) {
|
if (!apiary) {
|
||||||
throw new NotFoundException('Bienenstand nicht gefunden');
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||||
}
|
}
|
||||||
@@ -99,17 +131,49 @@ export class ColoniesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asked once per request, and only when a reason was sent (null counts as
|
||||||
|
// omitted): the right to override is compliance:manage.
|
||||||
|
private async overrideOf(
|
||||||
|
reason: string | null | undefined,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<RestrictionOverride> {
|
||||||
|
if (!reason) {
|
||||||
|
return { reason: null, canOverride: false };
|
||||||
|
}
|
||||||
|
const held = await this.permissions.getPermissions(actingUserId);
|
||||||
|
// Spec 5.6: a reason without compliance:manage is a 403 - always, also
|
||||||
|
// when no restriction is active. Checked before anything is read or locked.
|
||||||
|
if (!held.has(OVERRIDE_PERMISSION)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Eine Begründung für eine Ausnahme vom Sperrbezirk darf nur mit dem Recht Prüfbereitschaft verwalten (compliance:manage) angegeben werden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { reason, canOverride: true };
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
||||||
await this.assertApiaryActive(dto.apiaryId);
|
const { restrictionOverrideReason, ...data } = dto;
|
||||||
|
const override = await this.overrideOf(
|
||||||
|
restrictionOverrideReason,
|
||||||
|
actingUserId,
|
||||||
|
);
|
||||||
const colony = await this.prisma.$transaction(async (tx) => {
|
const colony = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.colony.create({ data: { ...dto } });
|
await lockRowsForShare(tx, 'Apiary', [data.apiaryId]);
|
||||||
|
await this.assertApiaryActiveIn(tx, data.apiaryId);
|
||||||
|
const check = await assertNoActiveRestriction(
|
||||||
|
tx,
|
||||||
|
[data.apiaryId],
|
||||||
|
berlinToday(),
|
||||||
|
override,
|
||||||
|
);
|
||||||
|
const created = await tx.colony.create({ data });
|
||||||
await this.auditLog.record(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: created,
|
afterData: { ...created, ...overrideTrace(check, override.reason) },
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -126,9 +190,25 @@ export class ColoniesService {
|
|||||||
dto: UpdateColonyDto,
|
dto: UpdateColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Colony> {
|
): Promise<Colony> {
|
||||||
const { after } = await this.prisma.$transaction((tx) =>
|
const { restrictionOverrideReason, ...patch } = dto;
|
||||||
this.updateIn(tx, id, dto, actingUserId),
|
const override = await this.overrideOf(
|
||||||
|
restrictionOverrideReason,
|
||||||
|
actingUserId,
|
||||||
);
|
);
|
||||||
|
const seen = await this.findById(id);
|
||||||
|
// Only a change of the apiary involves apiaries: the old and the new one
|
||||||
|
// are locked first (FOR SHARE, ascending), then the colony (Task 1 order).
|
||||||
|
const movesApiary =
|
||||||
|
patch.apiaryId !== undefined && patch.apiaryId !== seen.apiaryId;
|
||||||
|
const { after } = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (movesApiary) {
|
||||||
|
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, patch.apiaryId!]);
|
||||||
|
}
|
||||||
|
return this.updateIn(tx, id, patch, actingUserId, {
|
||||||
|
seenApiaryId: seen.apiaryId,
|
||||||
|
override,
|
||||||
|
});
|
||||||
|
});
|
||||||
return after;
|
return after;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,22 +217,44 @@ export class ColoniesService {
|
|||||||
* inspection handing values over to the colony card). Locks the colony row
|
* inspection handing values over to the colony card). Locks the colony row
|
||||||
* and reads "before" only afterwards (404 when missing); writes the apiary
|
* and reads "before" only afterwards (404 when missing); writes the apiary
|
||||||
* history, the status events and the audit entry with the change.
|
* history, the status events and the audit entry with the change.
|
||||||
|
*
|
||||||
|
* It locks no apiary itself. A patch that moves the colony to another
|
||||||
|
* apiary needs `guard`: update() has then locked both apiaries (FOR SHARE)
|
||||||
|
* before the colony, so the restriction check and the write cannot be
|
||||||
|
* overtaken by a restriction or a deactivation. The inspection takeover
|
||||||
|
* passes no apiary and no guard.
|
||||||
*/
|
*/
|
||||||
async updateIn(
|
async updateIn(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateColonyDto,
|
dto: ColonyPatch,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
|
guard?: ApiaryMoveGuard,
|
||||||
): Promise<{ before: Colony; after: Colony }> {
|
): Promise<{ before: Colony; after: Colony }> {
|
||||||
const before = await lockColony(tx, id);
|
const before = await lockColony(tx, id);
|
||||||
|
const today = berlinToday();
|
||||||
const movesApiary =
|
const movesApiary =
|
||||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||||
|
let check: RestrictionCheck = NOT_OVERRIDDEN;
|
||||||
if (movesApiary) {
|
if (movesApiary) {
|
||||||
await this.assertApiaryActive(dto.apiaryId!);
|
if (!guard) {
|
||||||
|
throw new Error(
|
||||||
|
'ColoniesService.updateIn: moving the apiary needs update()',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (before.apiaryId !== guard.seenApiaryId) {
|
||||||
|
throw apiaryChangedMeanwhile();
|
||||||
|
}
|
||||||
|
await this.assertApiaryActiveIn(tx, dto.apiaryId!);
|
||||||
|
check = await assertNoActiveRestriction(
|
||||||
|
tx,
|
||||||
|
[before.apiaryId, dto.apiaryId!],
|
||||||
|
today,
|
||||||
|
guard.override,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// An edit into an ended status ends the queen assignment like dissolve
|
// An edit into an ended status ends the queen assignment like dissolve
|
||||||
// does, before the status is written (the pointer is read under the lock).
|
// does, before the status is written (the pointer is read under the lock).
|
||||||
const today = berlinToday();
|
|
||||||
const endsColony =
|
const endsColony =
|
||||||
dto.status !== undefined &&
|
dto.status !== undefined &&
|
||||||
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
|
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
|
||||||
@@ -173,7 +275,11 @@ export class ColoniesService {
|
|||||||
colonyId: id,
|
colonyId: id,
|
||||||
apiaryId: updated.apiaryId,
|
apiaryId: updated.apiaryId,
|
||||||
movedByUserId: actingUserId,
|
movedByUserId: actingUserId,
|
||||||
notes: 'Korrektur über Volk bearbeiten',
|
notes: overrideNote(
|
||||||
|
check,
|
||||||
|
guard?.override.reason,
|
||||||
|
'Korrektur über Volk bearbeiten',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.events.record(
|
await this.events.record(
|
||||||
@@ -196,7 +302,10 @@ export class ColoniesService {
|
|||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated,
|
afterData: {
|
||||||
|
...updated,
|
||||||
|
...overrideTrace(check, guard?.override.reason),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -208,21 +317,41 @@ export class ColoniesService {
|
|||||||
dto: MoveColonyDto,
|
dto: MoveColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Colony> {
|
): Promise<Colony> {
|
||||||
this.assertActive(await this.findById(id));
|
const { restrictionOverrideReason, ...data } = dto;
|
||||||
await this.assertApiaryActive(dto.apiaryId);
|
const override = await this.overrideOf(
|
||||||
|
restrictionOverrideReason,
|
||||||
|
actingUserId,
|
||||||
|
);
|
||||||
|
const seen = await this.findById(id);
|
||||||
|
this.assertActive(seen);
|
||||||
const colony = await this.prisma.$transaction(async (tx) => {
|
const colony = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, data.apiaryId]);
|
||||||
const before = await lockColony(tx, id);
|
const before = await lockColony(tx, id);
|
||||||
this.assertActive(before);
|
this.assertActive(before);
|
||||||
|
if (before.apiaryId !== seen.apiaryId) {
|
||||||
|
throw apiaryChangedMeanwhile();
|
||||||
|
}
|
||||||
|
await this.assertApiaryActiveIn(tx, data.apiaryId);
|
||||||
|
// A "move" within one apiary leaves the herd where it is.
|
||||||
|
const check =
|
||||||
|
before.apiaryId === data.apiaryId
|
||||||
|
? NOT_OVERRIDDEN
|
||||||
|
: await assertNoActiveRestriction(
|
||||||
|
tx,
|
||||||
|
[before.apiaryId, data.apiaryId],
|
||||||
|
berlinToday(),
|
||||||
|
override,
|
||||||
|
);
|
||||||
const updated = await tx.colony.update({
|
const updated = await tx.colony.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { apiaryId: dto.apiaryId },
|
data: { apiaryId: data.apiaryId },
|
||||||
});
|
});
|
||||||
await tx.colonyApiaryHistory.create({
|
await tx.colonyApiaryHistory.create({
|
||||||
data: {
|
data: {
|
||||||
colonyId: id,
|
colonyId: id,
|
||||||
apiaryId: dto.apiaryId,
|
apiaryId: data.apiaryId,
|
||||||
movedByUserId: actingUserId,
|
movedByUserId: actingUserId,
|
||||||
notes: dto.notes,
|
notes: overrideNote(check, override.reason, data.notes),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (before.apiaryId !== updated.apiaryId) {
|
if (before.apiaryId !== updated.apiaryId) {
|
||||||
@@ -238,7 +367,7 @@ export class ColoniesService {
|
|||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated,
|
afterData: { ...updated, ...overrideTrace(check, override.reason) },
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -313,24 +442,54 @@ export class ColoniesService {
|
|||||||
dto: MergeColonyDto,
|
dto: MergeColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Colony> {
|
): Promise<Colony> {
|
||||||
|
const { restrictionOverrideReason, ...data } = dto;
|
||||||
const survivor = await this.findById(id);
|
const survivor = await this.findById(id);
|
||||||
this.assertActive(survivor);
|
this.assertActive(survivor);
|
||||||
if (dto.mergedColonyIds.includes(survivor.id)) {
|
if (data.mergedColonyIds.includes(survivor.id)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Ein Volk kann nicht mit sich selbst vereinigt werden',
|
'Ein Volk kann nicht mit sich selbst vereinigt werden',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) {
|
if (new Set(data.mergedColonyIds).size !== data.mergedColonyIds.length) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
|
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const override = await this.overrideOf(
|
||||||
|
restrictionOverrideReason,
|
||||||
|
actingUserId,
|
||||||
|
);
|
||||||
const today = berlinToday();
|
const today = berlinToday();
|
||||||
|
// The apiaries of the colonies as the unlocked read saw them: they are
|
||||||
|
// locked first, and compared with the locked rows afterwards.
|
||||||
|
const seenApiaries = new Map(
|
||||||
|
(
|
||||||
|
await this.prisma.colony.findMany({
|
||||||
|
where: { id: { in: data.mergedColonyIds } },
|
||||||
|
select: { id: true, apiaryId: true },
|
||||||
|
})
|
||||||
|
).map((colony) => [colony.id, colony.apiaryId]),
|
||||||
|
);
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowsForShare(tx, 'Apiary', [
|
||||||
|
...new Set([survivor.apiaryId, ...seenApiaries.values()]),
|
||||||
|
]);
|
||||||
// One statement in id order, the same order bulk lockRowsForShare
|
// One statement in id order, the same order bulk lockRowsForShare
|
||||||
// uses: no deadlock with a concurrent bulk write.
|
// uses: no deadlock with a concurrent bulk write.
|
||||||
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
|
await lockRowsForUpdate(tx, 'Colony', [
|
||||||
for (const mergedColonyId of dto.mergedColonyIds) {
|
survivor.id,
|
||||||
|
...data.mergedColonyIds,
|
||||||
|
]);
|
||||||
|
const lockedSurvivor = await tx.colony.findUnique({ where: { id } });
|
||||||
|
if (!lockedSurvivor) {
|
||||||
|
throw new NotFoundException('Colony not found');
|
||||||
|
}
|
||||||
|
this.assertActive(lockedSurvivor);
|
||||||
|
if (lockedSurvivor.apiaryId !== survivor.apiaryId) {
|
||||||
|
throw apiaryChangedMeanwhile();
|
||||||
|
}
|
||||||
|
const mergedRows: Colony[] = [];
|
||||||
|
for (const mergedColonyId of data.mergedColonyIds) {
|
||||||
const merged = await tx.colony.findUnique({
|
const merged = await tx.colony.findUnique({
|
||||||
where: { id: mergedColonyId },
|
where: { id: mergedColonyId },
|
||||||
});
|
});
|
||||||
@@ -340,6 +499,22 @@ export class ColoniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.assertActive(merged);
|
this.assertActive(merged);
|
||||||
|
if (seenApiaries.get(merged.id) !== merged.apiaryId) {
|
||||||
|
throw apiaryChangedMeanwhile();
|
||||||
|
}
|
||||||
|
mergedRows.push(merged);
|
||||||
|
}
|
||||||
|
// Uniting colonies of different apiaries brings bees from one stand to
|
||||||
|
// the other; within one apiary nothing leaves it.
|
||||||
|
const apiaryIds = new Set([
|
||||||
|
lockedSurvivor.apiaryId,
|
||||||
|
...mergedRows.map((merged) => merged.apiaryId),
|
||||||
|
]);
|
||||||
|
const check =
|
||||||
|
apiaryIds.size > 1
|
||||||
|
? await assertNoActiveRestriction(tx, [...apiaryIds], today, override)
|
||||||
|
: NOT_OVERRIDDEN;
|
||||||
|
for (const merged of mergedRows) {
|
||||||
// currentQueenId is unique across colonies - if it stayed set on
|
// currentQueenId is unique across colonies - if it stayed set on
|
||||||
// this now-dead colony, any later attempt to assign her queen to a
|
// this now-dead colony, any later attempt to assign her queen to a
|
||||||
// different colony would fail with a unique-constraint conflict.
|
// different colony would fail with a unique-constraint conflict.
|
||||||
@@ -348,11 +523,11 @@ export class ColoniesService {
|
|||||||
// merge is a beekeeper decision, not one to guess here).
|
// merge is a beekeeper decision, not one to guess here).
|
||||||
const releasedQueenId = await releaseQueenIn(
|
const releasedQueenId = await releaseQueenIn(
|
||||||
tx,
|
tx,
|
||||||
mergedColonyId,
|
merged.id,
|
||||||
dayToDate(today),
|
dayToDate(today),
|
||||||
);
|
);
|
||||||
const mergedAway = await tx.colony.update({
|
const mergedAway = await tx.colony.update({
|
||||||
where: { id: mergedColonyId },
|
where: { id: merged.id },
|
||||||
data: { status: 'MERGED', deactivatedAt: new Date() },
|
data: { status: 'MERGED', deactivatedAt: new Date() },
|
||||||
});
|
});
|
||||||
await this.events.recordAll(
|
await this.events.recordAll(
|
||||||
@@ -366,21 +541,25 @@ export class ColoniesService {
|
|||||||
await tx.colonyLineage.create({
|
await tx.colonyLineage.create({
|
||||||
data: {
|
data: {
|
||||||
type: 'MERGE',
|
type: 'MERGE',
|
||||||
fromColonyId: mergedColonyId,
|
fromColonyId: merged.id,
|
||||||
toColonyId: survivor.id,
|
toColonyId: lockedSurvivor.id,
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
notes: dto.notes,
|
notes: data.notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.auditLog.record(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
entityType: 'Colony',
|
entityType: 'Colony',
|
||||||
entityId: survivor.id,
|
entityId: lockedSurvivor.id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: survivor,
|
beforeData: lockedSurvivor,
|
||||||
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
|
afterData: {
|
||||||
|
...lockedSurvivor,
|
||||||
|
mergedColonyIds: data.mergedColonyIds,
|
||||||
|
...overrideTrace(check, override.reason),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
import { ColonyCondition, ColonyType } from '../../../generated/prisma';
|
import { ColonyCondition, ColonyType } from '../../../generated/prisma';
|
||||||
|
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||||
|
|
||||||
export class CreateColonyDto {
|
export class CreateColonyDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -67,4 +68,7 @@ export class CreateColonyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@RestrictionOverrideReason()
|
||||||
|
restrictionOverrideReason?: string | null;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator';
|
import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||||
|
|
||||||
export class MergeColonyDto {
|
export class MergeColonyDto {
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [String] })
|
||||||
@@ -12,4 +13,7 @@ export class MergeColonyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@RestrictionOverrideReason()
|
||||||
|
restrictionOverrideReason?: string | null;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||||
|
|
||||||
export class MoveColonyDto {
|
export class MoveColonyDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -10,4 +11,7 @@ export class MoveColonyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@RestrictionOverrideReason()
|
||||||
|
restrictionOverrideReason?: string | null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export const OVERRIDE_REASON_MIN = 10;
|
||||||
|
export const OVERRIDE_REASON_MAX = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `restrictionOverrideReason`: why a colony may enter or leave an apiary in a
|
||||||
|
* restricted zone. Only read by ColoniesService (needs compliance:manage) and
|
||||||
|
* written to audit and apiary history - never to the colony. Trimmed before
|
||||||
|
* the length check, so blanks do not count; null counts as omitted.
|
||||||
|
*/
|
||||||
|
export function RestrictionOverrideReason(): PropertyDecorator {
|
||||||
|
return applyDecorators(
|
||||||
|
ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
minLength: OVERRIDE_REASON_MIN,
|
||||||
|
maxLength: OVERRIDE_REASON_MAX,
|
||||||
|
description:
|
||||||
|
'Begründung für eine Ausnahme vom Sperrbezirk (nur mit compliance:manage)',
|
||||||
|
}),
|
||||||
|
IsOptional(),
|
||||||
|
Transform(({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value,
|
||||||
|
),
|
||||||
|
IsString(),
|
||||||
|
MinLength(OVERRIDE_REASON_MIN),
|
||||||
|
MaxLength(OVERRIDE_REASON_MAX),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { IsEnum } from 'class-validator';
|
|||||||
import { ColonyStatus } from '../../../generated/prisma';
|
import { ColonyStatus } from '../../../generated/prisma';
|
||||||
import { CreateColonyDto } from './create-colony.dto';
|
import { CreateColonyDto } from './create-colony.dto';
|
||||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||||
|
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
|
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
|
||||||
@@ -32,4 +33,7 @@ export class UpdateColonyDto extends UpdatableColonyFieldsDto {
|
|||||||
@IsOptionalNotNull()
|
@IsOptionalNotNull()
|
||||||
@IsEnum(ColonyStatus)
|
@IsEnum(ColonyStatus)
|
||||||
status?: ColonyStatus;
|
status?: ColonyStatus;
|
||||||
|
|
||||||
|
@RestrictionOverrideReason()
|
||||||
|
restrictionOverrideReason?: string | null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import type { RestrictionCheck } from '../compliance/restricted-zone';
|
||||||
|
|
||||||
|
/** 409 when the colony or an apiary changed between the unlocked read and the lock. */
|
||||||
|
export function apiaryChangedMeanwhile(): ConflictException {
|
||||||
|
return new ConflictException(
|
||||||
|
'Der Stand wurde gerade geändert. Bitte neu laden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOT_OVERRIDDEN: RestrictionCheck = {
|
||||||
|
overridden: false,
|
||||||
|
restrictionIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audit trace of an override: only the reason and the restriction ids,
|
||||||
|
* and only when an active restriction was really overridden. Spread into the
|
||||||
|
* audit entry's afterData - never into data written to the colony.
|
||||||
|
*/
|
||||||
|
export function overrideTrace(
|
||||||
|
check: RestrictionCheck,
|
||||||
|
reason: string | null | undefined,
|
||||||
|
): { restrictionOverride?: { reason: string; restrictionIds: string[] } } {
|
||||||
|
return check.overridden && reason
|
||||||
|
? { restrictionOverride: { reason, restrictionIds: check.restrictionIds } }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The apiary-history note: the override in front of an existing note. */
|
||||||
|
export function overrideNote(
|
||||||
|
check: RestrictionCheck,
|
||||||
|
reason: string | null | undefined,
|
||||||
|
note: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!check.overridden || !reason) {
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
const prefix = `Ausnahme Sperrbezirk: ${reason}`;
|
||||||
|
return note ? `${prefix}\n${note}` : prefix;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 { AuthorityNoticesService } from './authority-notices.service';
|
||||||
|
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
|
||||||
|
import { MarkSentDto } from './dto/mark-sent.dto';
|
||||||
|
import { NoticeListQuery } from './dto/notice-list-query.dto';
|
||||||
|
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
|
||||||
|
|
||||||
|
@ApiTags('authority-notices')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('authority-notices')
|
||||||
|
export class AuthorityNoticesController {
|
||||||
|
constructor(private readonly notices: AuthorityNoticesService) {}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:read')
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() query: NoticeListQuery) {
|
||||||
|
return this.notices.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:read')
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.notices.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Idempotent()
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateAuthorityNoticeDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.notices.create(dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateAuthorityNoticeDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.notices.update(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Post(':id/recount')
|
||||||
|
recount(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
|
return this.notices.recount(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Post(':id/mark-sent')
|
||||||
|
markSent(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: MarkSentDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.notices.markSent(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Post(':id/reopen')
|
||||||
|
reopen(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
|
return this.notices.reopen(id, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Post(':id/cancel')
|
||||||
|
cancel(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
|
return this.notices.cancel(id, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import { AuthorityNoticesService } from './authority-notices.service';
|
||||||
|
|
||||||
|
jest.mock('../prisma/row-lock', () => ({
|
||||||
|
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lockMock = lockRowForUpdate as jest.Mock;
|
||||||
|
|
||||||
|
const SETTINGS = {
|
||||||
|
id: 'singleton',
|
||||||
|
tskExcludedColonyTypes: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
function notice(patch: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
kind: 'HERD_CHANGE',
|
||||||
|
status: 'DRAFT',
|
||||||
|
year: null,
|
||||||
|
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
|
||||||
|
countedAt: new Date('2027-01-01T10:00:00.000Z'),
|
||||||
|
snapshot: { version: 1, apiaries: [], totalLiving: 2 },
|
||||||
|
totalColonies: 2,
|
||||||
|
sentOn: null,
|
||||||
|
sentVia: null,
|
||||||
|
reference: null,
|
||||||
|
notes: null,
|
||||||
|
openKey: 'HERD_CHANGE',
|
||||||
|
createdByUserId: 'u1',
|
||||||
|
createdAt: new Date(0),
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function p2002(): Prisma.PrismaClientKnownRequestError {
|
||||||
|
return new Prisma.PrismaClientKnownRequestError('unique', {
|
||||||
|
code: 'P2002',
|
||||||
|
clientVersion: 'test',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthorityNoticesService', () => {
|
||||||
|
let service: AuthorityNoticesService;
|
||||||
|
let tx: {
|
||||||
|
complianceSettings: { upsert: jest.Mock; findUniqueOrThrow: jest.Mock };
|
||||||
|
authorityNotice: {
|
||||||
|
findFirst: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
findMany: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
apiary: { findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
let prisma: {
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
authorityNotice: { findFirst: jest.Mock; findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
|
// The baseline lookup and the open-draft lookup both use findFirst: the
|
||||||
|
// helper answers by the where clause.
|
||||||
|
function stubLookups(opts: {
|
||||||
|
baseline?: unknown;
|
||||||
|
openDraft?: unknown;
|
||||||
|
}): void {
|
||||||
|
tx.authorityNotice.findFirst.mockImplementation(
|
||||||
|
({ where }: { where: { openKey?: string; status?: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
where.openKey !== undefined
|
||||||
|
? (opts.openDraft ?? null)
|
||||||
|
: (opts.baseline ?? null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
lockMock.mockReset().mockResolvedValue(true);
|
||||||
|
tx = {
|
||||||
|
complianceSettings: {
|
||||||
|
upsert: jest.fn().mockResolvedValue(SETTINGS),
|
||||||
|
findUniqueOrThrow: jest.fn().mockResolvedValue(SETTINGS),
|
||||||
|
},
|
||||||
|
authorityNotice: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(notice()),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
create: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(({ data }: { data: object }) =>
|
||||||
|
Promise.resolve(notice(data as Record<string, unknown>)),
|
||||||
|
),
|
||||||
|
update: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(({ data }: { data: object }) =>
|
||||||
|
Promise.resolve(notice(data as Record<string, unknown>)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
apiary: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
name: 'Stand 1',
|
||||||
|
address: null,
|
||||||
|
location: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
colonies: [
|
||||||
|
{ colonyType: 'PRODUCTION_COLONY' },
|
||||||
|
{ colonyType: 'NUCLEUS_COLONY' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
$transaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||||
|
authorityNotice: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
auditLog = { record: jest.fn() };
|
||||||
|
service = new AuthorityNoticesService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
auditLog as unknown as AuditLogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('one open draft per kind and year', async () => {
|
||||||
|
stubLookups({
|
||||||
|
baseline: notice({ status: 'SENT' }),
|
||||||
|
openDraft: { id: 'existing' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const attempt = service.create({ kind: 'HERD_CHANGE' }, 'u1');
|
||||||
|
|
||||||
|
await expect(attempt).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await expect(attempt).rejects.toMatchObject({
|
||||||
|
response: { code: 'DRAFT_EXISTS', existingId: 'existing' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers DRAFT_EXISTS when the unique key of the open draft is hit', async () => {
|
||||||
|
stubLookups({ baseline: notice({ status: 'SENT' }) });
|
||||||
|
tx.authorityNotice.create.mockRejectedValueOnce(p2002());
|
||||||
|
prisma.authorityNotice.findFirst.mockResolvedValue({ id: 'winner' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: { code: 'DRAFT_EXISTS', existingId: 'winner' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create with sentOn is SENT at once and has no openKey', async () => {
|
||||||
|
await service.create(
|
||||||
|
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
kind: 'REGISTRATION',
|
||||||
|
status: 'SENT',
|
||||||
|
sentOn: new Date('2026-05-01T00:00:00.000Z'),
|
||||||
|
openKey: null,
|
||||||
|
totalColonies: 2,
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a notice created as sent does not collide with an open draft', async () => {
|
||||||
|
stubLookups({ openDraft: { id: 'existing' } });
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows a unique violation that no open draft explains', async () => {
|
||||||
|
stubLookups({ baseline: notice({ status: 'SENT' }) });
|
||||||
|
const error = p2002();
|
||||||
|
tx.authorityNotice.create.mockRejectedValueOnce(error);
|
||||||
|
prisma.authorityNotice.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.create({ kind: 'HERD_CHANGE' }, 'u1')).rejects.toBe(
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a draft carries the open key and the counted snapshot', async () => {
|
||||||
|
await service.create({ kind: 'REGISTRATION' }, 'u1');
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'DRAFT',
|
||||||
|
openKey: 'REGISTRATION',
|
||||||
|
sentOn: null,
|
||||||
|
totalColonies: 2,
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('herd change needs a baseline and registration needs none', async () => {
|
||||||
|
stubLookups({});
|
||||||
|
await expect(
|
||||||
|
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: expect.objectContaining({
|
||||||
|
message: expect.stringContaining('Erstanzeige') as unknown,
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
|
||||||
|
stubLookups({ baseline: notice({ status: 'SENT' }) });
|
||||||
|
await expect(
|
||||||
|
service.create({ kind: 'REGISTRATION' }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TSK_REPORT needs a year and uses it in the open key', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({ kind: 'TSK_REPORT' }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
year: 2027,
|
||||||
|
openKey: 'TSK_REPORT:2027',
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a year is only valid for the TSK report', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({ kind: 'REGISTRATION', year: 2027 }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the TSK count leaves out the excluded colony types', async () => {
|
||||||
|
tx.complianceSettings.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...SETTINGS,
|
||||||
|
tskExcludedColonyTypes: ['NUCLEUS_COLONY'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ totalColonies: 1 }) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the settings row as mutex before reading and audits the create', async () => {
|
||||||
|
await service.create({ kind: 'REGISTRATION' }, 'u1');
|
||||||
|
|
||||||
|
expect(lockMock).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'ComplianceSettings',
|
||||||
|
'singleton',
|
||||||
|
);
|
||||||
|
expect(lockMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
tx.authorityNotice.findFirst.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'AuthorityNotice',
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: 'u1',
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDraftIn', () => {
|
||||||
|
it('is the shared core: no actor needed and the caller owns the transaction', async () => {
|
||||||
|
await service.createDraftIn(
|
||||||
|
tx as never,
|
||||||
|
{
|
||||||
|
kind: 'TSK_REPORT',
|
||||||
|
year: 2027,
|
||||||
|
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
createdByUserId: null,
|
||||||
|
openKey: 'TSK_REPORT:2027',
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transitions', () => {
|
||||||
|
it('recount only in DRAFT', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'SENT', openKey: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(notice());
|
||||||
|
await service.recount('n1', 'u1');
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1' },
|
||||||
|
data: expect.objectContaining({ totalColonies: 2 }) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mark-sent on CANCELLED and recount on SENT answer 409', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'CANCELLED', openKey: null }),
|
||||||
|
);
|
||||||
|
await expect(service.markSent('n1', {}, 'u1')).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'SENT', openKey: null }),
|
||||||
|
);
|
||||||
|
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
|
||||||
|
try {
|
||||||
|
await service.markSent('n1', {}, 'u1');
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
sentOn: new Date('2027-03-02T00:00:00.000Z'),
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
|
||||||
|
await service.markSent(
|
||||||
|
'n1',
|
||||||
|
{ sentOn: '2027-02-02', sentVia: 'E-Mail' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'SENT',
|
||||||
|
sentOn: new Date('2027-02-02T00:00:00.000Z'),
|
||||||
|
sentVia: 'E-Mail',
|
||||||
|
openKey: null,
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reopened registration can be marked sent again', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ kind: 'REGISTRATION', openKey: 'REGISTRATION' }),
|
||||||
|
);
|
||||||
|
stubLookups({ baseline: notice({ id: 'other', status: 'SENT' }) });
|
||||||
|
|
||||||
|
await service.markSent('n1', {}, 'u1');
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1' },
|
||||||
|
data: expect.objectContaining({ status: 'SENT' }) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopen answers 409 while another draft of the kind exists', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'SENT', openKey: null }),
|
||||||
|
);
|
||||||
|
stubLookups({ openDraft: { id: 'other' } });
|
||||||
|
|
||||||
|
await expect(service.reopen('n1', 'u1')).rejects.toMatchObject({
|
||||||
|
response: { code: 'DRAFT_EXISTS', existingId: 'other' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopen turns a SENT notice back into a draft with its open key', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.reopen('n1', 'u1');
|
||||||
|
|
||||||
|
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'DRAFT',
|
||||||
|
openKey: 'HERD_CHANGE',
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel only works on a draft and drops the open key', async () => {
|
||||||
|
await service.cancel('n1', 'u1');
|
||||||
|
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'CANCELLED',
|
||||||
|
openKey: null,
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'SENT', openKey: null }),
|
||||||
|
);
|
||||||
|
await expect(service.cancel('n1', 'u1')).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['recount', (s: AuthorityNoticesService) => s.recount('n1', 'u1')],
|
||||||
|
['markSent', (s: AuthorityNoticesService) => s.markSent('n1', {}, 'u1')],
|
||||||
|
['cancel', (s: AuthorityNoticesService) => s.cancel('n1', 'u1')],
|
||||||
|
[
|
||||||
|
'update',
|
||||||
|
(s: AuthorityNoticesService) => s.update('n1', { notes: 'x' }, 'u1'),
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'every transition locks the notice row and writes an audit entry (%s)',
|
||||||
|
async (_name, run) => {
|
||||||
|
await run(service);
|
||||||
|
|
||||||
|
expect(lockMock).toHaveBeenCalledWith(tx, 'AuthorityNotice', 'n1');
|
||||||
|
const lockOrder = lockMock.mock.invocationCallOrder.at(-1) ?? 0;
|
||||||
|
expect(lockOrder).toBeLessThan(
|
||||||
|
tx.authorityNotice.findUnique.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'AuthorityNotice',
|
||||||
|
entityId: 'n1',
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: 'u1',
|
||||||
|
beforeData: expect.anything() as unknown,
|
||||||
|
afterData: expect.anything() as unknown,
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('reopen locks the settings mutex before the notice row', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'SENT', openKey: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.reopen('n1', 'u1');
|
||||||
|
|
||||||
|
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||||
|
'ComplianceSettings',
|
||||||
|
'AuthorityNotice',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 for a missing notice', async () => {
|
||||||
|
lockMock.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.recount('missing', 'u1')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('changes the corrected fields but answers 409 on CANCELLED', async () => {
|
||||||
|
await service.update(
|
||||||
|
'n1',
|
||||||
|
{ totalColonies: 7, notes: 'korrigiert' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
totalColonies: 7,
|
||||||
|
notes: 'korrigiert',
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'CANCELLED', openKey: null }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.update('n1', { notes: 'x' }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a draft has no sent day', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('n1', { sentOn: '2027-01-01' }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing the sent day takes the settings mutex first', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.update('n1', { sentOn: '2027-01-02' }, 'u1');
|
||||||
|
|
||||||
|
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||||
|
'ComplianceSettings',
|
||||||
|
'AuthorityNotice',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('other edits do not take the settings mutex', async () => {
|
||||||
|
await service.update('n1', { notes: 'x' }, 'u1');
|
||||||
|
|
||||||
|
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||||
|
'AuthorityNotice',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a sent notice keeps its sent day', async () => {
|
||||||
|
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||||
|
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('n1', { sentOn: null }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll / findOne', () => {
|
||||||
|
it('filters by kind and year', async () => {
|
||||||
|
await service.findAll({ kind: 'TSK_REPORT', year: 2027 });
|
||||||
|
|
||||||
|
expect(prisma.authorityNotice.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { kind: 'TSK_REPORT', year: 2027 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 for an unknown id', async () => {
|
||||||
|
(
|
||||||
|
prisma.authorityNotice as unknown as { findUnique: jest.Mock }
|
||||||
|
).findUnique = jest.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findOne('x')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AuthorityNotice,
|
||||||
|
AuthorityNoticeKind,
|
||||||
|
Prisma,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
|
import { optionalDate } from '../common/patch-values';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
|
||||||
|
import { MarkSentDto } from './dto/mark-sent.dto';
|
||||||
|
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
|
||||||
|
import { buildHerdSnapshot, loadHerdApiaryRows } from './herd-snapshot';
|
||||||
|
import { COMPLIANCE_SETTINGS_ID, findBaselineIn } from './herd-status';
|
||||||
|
|
||||||
|
const AUDIT_ENTITY = 'AuthorityNotice';
|
||||||
|
const LIST_LIMIT = 200;
|
||||||
|
const UNIQUE_VIOLATION = 'P2002';
|
||||||
|
|
||||||
|
type NoticeUpdate = Prisma.AuthorityNoticeUncheckedUpdateInput;
|
||||||
|
|
||||||
|
interface DraftInput {
|
||||||
|
kind: AuthorityNoticeKind;
|
||||||
|
year?: number;
|
||||||
|
referenceDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransitionOptions {
|
||||||
|
/** Takes the ComplianceSettings mutex first (the open key is involved). */
|
||||||
|
mutex?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT:<year>": at most one draft each. */
|
||||||
|
function openKeyOf(kind: AuthorityNoticeKind, year: number | null): string {
|
||||||
|
return kind === 'TSK_REPORT' ? `TSK_REPORT:${year}` : kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmedOrNull(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | null | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notAllowed(message: string): ConflictException {
|
||||||
|
return new ConflictException({ statusCode: 409, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftExists(existingId: string): ConflictException {
|
||||||
|
return new ConflictException({
|
||||||
|
statusCode: 409,
|
||||||
|
code: 'DRAFT_EXISTS',
|
||||||
|
message: 'Es gibt schon einen offenen Entwurf dieser Anzeige.',
|
||||||
|
existingId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === UNIQUE_VIOLATION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertYearFits(kind: AuthorityNoticeKind, year?: number): void {
|
||||||
|
if (kind === 'TSK_REPORT' && year === undefined) {
|
||||||
|
throw new BadRequestException('Die TSK-Zählung braucht ein Jahr');
|
||||||
|
}
|
||||||
|
if (kind !== 'TSK_REPORT' && year !== undefined) {
|
||||||
|
throw new BadRequestException('Ein Jahr gibt es nur bei der TSK-Zählung');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthorityNoticesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findAll(q: {
|
||||||
|
kind?: AuthorityNoticeKind;
|
||||||
|
year?: number;
|
||||||
|
}): Promise<AuthorityNotice[]> {
|
||||||
|
return this.prisma.authorityNotice.findMany({
|
||||||
|
where: { kind: q.kind, year: q.year },
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: LIST_LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<AuthorityNotice> {
|
||||||
|
const notice = await this.prisma.authorityNotice.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!notice) {
|
||||||
|
throw new NotFoundException('Anzeige nicht gefunden');
|
||||||
|
}
|
||||||
|
return notice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a draft, or a SENT notice at once when `sentOn` is given (the
|
||||||
|
* way to record what was already reported). A second open draft of the
|
||||||
|
* same kind and year answers 409 DRAFT_EXISTS with the id of the first.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateAuthorityNoticeDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<AuthorityNotice> {
|
||||||
|
assertYearFits(dto.kind, dto.year);
|
||||||
|
const input = {
|
||||||
|
kind: dto.kind,
|
||||||
|
year: dto.year,
|
||||||
|
referenceDate: dayToDate(dto.referenceDate ?? berlinToday()),
|
||||||
|
sentOn: dto.sentOn ? dayToDate(dto.sentOn) : undefined,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction((tx) =>
|
||||||
|
this.insertIn(tx, input, actingUserId),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueViolation(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const winner = await this.prisma.authorityNotice.findFirst({
|
||||||
|
where: { openKey: openKeyOf(dto.kind, dto.year ?? null) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (winner) {
|
||||||
|
throw draftExists(winner.id);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared core of create: the daily TSK job calls it in its own transaction. */
|
||||||
|
createDraftIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: DraftInput,
|
||||||
|
userId: string | null,
|
||||||
|
): Promise<AuthorityNotice> {
|
||||||
|
assertYearFits(input.kind, input.year);
|
||||||
|
return this.insertIn(tx, input, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the herd picture and the total; only a draft can be recounted. */
|
||||||
|
recount(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||||
|
return this.transition(id, actingUserId, {}, async (tx, before) => {
|
||||||
|
if (before.status !== 'DRAFT') {
|
||||||
|
throw notAllowed('Nur ein Entwurf kann neu gezählt werden.');
|
||||||
|
}
|
||||||
|
const countedAt = new Date();
|
||||||
|
const snapshot = await this.countIn(tx, before.kind, countedAt);
|
||||||
|
return {
|
||||||
|
snapshot: snapshot as unknown as Prisma.InputJsonValue,
|
||||||
|
totalColonies: snapshot.totalLiving,
|
||||||
|
countedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markSent(
|
||||||
|
id: string,
|
||||||
|
dto: MarkSentDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<AuthorityNotice> {
|
||||||
|
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
|
||||||
|
if (before.status !== 'DRAFT') {
|
||||||
|
return Promise.reject(
|
||||||
|
notAllowed('Nur ein Entwurf kann als gesendet markiert werden.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
status: 'SENT',
|
||||||
|
sentOn: dayToDate(dto.sentOn ?? berlinToday()),
|
||||||
|
sentVia: trimmedOrNull(dto.sentVia),
|
||||||
|
reference: trimmedOrNull(dto.reference),
|
||||||
|
openKey: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reopen(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||||
|
return this.transition(
|
||||||
|
id,
|
||||||
|
actingUserId,
|
||||||
|
{ mutex: true },
|
||||||
|
async (tx, before) => {
|
||||||
|
if (before.status !== 'SENT') {
|
||||||
|
throw notAllowed('Nur eine gesendete Anzeige kann geöffnet werden.');
|
||||||
|
}
|
||||||
|
const openKey = openKeyOf(before.kind, before.year);
|
||||||
|
const other = await tx.authorityNotice.findFirst({
|
||||||
|
where: { openKey },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (other) {
|
||||||
|
throw draftExists(other.id);
|
||||||
|
}
|
||||||
|
return { status: 'DRAFT', openKey, sentOn: null };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||||
|
return this.transition(id, actingUserId, {}, (_tx, before) => {
|
||||||
|
if (before.status !== 'DRAFT') {
|
||||||
|
return Promise.reject(
|
||||||
|
notAllowed('Nur ein Entwurf kann verworfen werden.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ status: 'CANCELLED', openKey: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateAuthorityNoticeDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<AuthorityNotice> {
|
||||||
|
// A new sent day can change which notice is the baseline: the mutex
|
||||||
|
// comes first, in lock order, whenever the sent day is touched.
|
||||||
|
const mutex = dto.sentOn !== undefined;
|
||||||
|
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
|
||||||
|
if (before.status === 'CANCELLED') {
|
||||||
|
return Promise.reject(
|
||||||
|
notAllowed('Eine verworfene Anzeige kann nicht geändert werden.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (before.status === 'DRAFT' && dto.sentOn != null) {
|
||||||
|
return Promise.reject(
|
||||||
|
new BadRequestException('Ein Entwurf hat kein Sendedatum.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (before.status === 'SENT' && dto.sentOn === null) {
|
||||||
|
return Promise.reject(
|
||||||
|
new BadRequestException(
|
||||||
|
'Eine gesendete Anzeige braucht ihr Sendedatum',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
totalColonies: dto.totalColonies,
|
||||||
|
referenceDate: dto.referenceDate
|
||||||
|
? dayToDate(dto.referenceDate)
|
||||||
|
: undefined,
|
||||||
|
sentOn: optionalDate(dto.sentOn),
|
||||||
|
sentVia: trimmedOrNull(dto.sentVia),
|
||||||
|
reference: trimmedOrNull(dto.reference),
|
||||||
|
notes: trimmedOrNull(dto.notes),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
kind: AuthorityNoticeKind,
|
||||||
|
countedAt: Date,
|
||||||
|
) {
|
||||||
|
const settings = await tx.complianceSettings.findUniqueOrThrow({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
});
|
||||||
|
return buildHerdSnapshot(
|
||||||
|
await loadHerdApiaryRows(tx),
|
||||||
|
countedAt,
|
||||||
|
kind === 'TSK_REPORT' ? settings.tskExcludedColonyTypes : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: DraftInput & { sentOn?: Date },
|
||||||
|
userId: string | null,
|
||||||
|
): Promise<AuthorityNotice> {
|
||||||
|
await this.lockSettingsIn(tx);
|
||||||
|
const baseline = await findBaselineIn(tx);
|
||||||
|
if (input.kind === 'REGISTRATION' && baseline) {
|
||||||
|
throw notAllowed('Die Erstanzeige ist schon erfasst.');
|
||||||
|
}
|
||||||
|
if (input.kind === 'HERD_CHANGE' && !baseline) {
|
||||||
|
throw notAllowed('Erst die Erstanzeige erfassen.');
|
||||||
|
}
|
||||||
|
const openKey = openKeyOf(input.kind, input.year ?? null);
|
||||||
|
// A notice recorded as already sent has no open key and cannot collide.
|
||||||
|
const draft = input.sentOn
|
||||||
|
? null
|
||||||
|
: await tx.authorityNotice.findFirst({
|
||||||
|
where: { openKey },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (draft) {
|
||||||
|
throw draftExists(draft.id);
|
||||||
|
}
|
||||||
|
const countedAt = new Date();
|
||||||
|
const snapshot = await this.countIn(tx, input.kind, countedAt);
|
||||||
|
const created = await tx.authorityNotice.create({
|
||||||
|
data: {
|
||||||
|
kind: input.kind,
|
||||||
|
status: input.sentOn ? 'SENT' : 'DRAFT',
|
||||||
|
year: input.year ?? null,
|
||||||
|
referenceDate: input.referenceDate,
|
||||||
|
countedAt,
|
||||||
|
snapshot: snapshot as unknown as Prisma.InputJsonValue,
|
||||||
|
totalColonies: snapshot.totalLiving,
|
||||||
|
sentOn: input.sentOn ?? null,
|
||||||
|
openKey: input.sentOn ? null : openKey,
|
||||||
|
createdByUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: AUDIT_ENTITY,
|
||||||
|
entityId: created.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: userId ?? undefined,
|
||||||
|
afterData: created,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ComplianceSettings row is the mutex of everything around the open key. */
|
||||||
|
private async lockSettingsIn(tx: Prisma.TransactionClient): Promise<void> {
|
||||||
|
await tx.complianceSettings.upsert({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
update: {},
|
||||||
|
create: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
});
|
||||||
|
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One state change: lock (settings mutex first when asked, then the notice
|
||||||
|
* row), read "before" under the lock, check the transition, write, audit.
|
||||||
|
*/
|
||||||
|
private async transition(
|
||||||
|
id: string,
|
||||||
|
actingUserId: string,
|
||||||
|
options: TransitionOptions,
|
||||||
|
change: (
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
before: AuthorityNotice,
|
||||||
|
) => Promise<NoticeUpdate>,
|
||||||
|
): Promise<AuthorityNotice> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
if (options.mutex) {
|
||||||
|
await this.lockSettingsIn(tx);
|
||||||
|
}
|
||||||
|
if (!(await lockRowForUpdate(tx, 'AuthorityNotice', id))) {
|
||||||
|
throw new NotFoundException('Anzeige nicht gefunden');
|
||||||
|
}
|
||||||
|
const before = await tx.authorityNotice.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('Anzeige nicht gefunden');
|
||||||
|
}
|
||||||
|
const after = await tx.authorityNotice.update({
|
||||||
|
where: { id },
|
||||||
|
data: await change(tx, before),
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: AUDIT_ENTITY,
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: after,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return after;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
describeNullContracts,
|
||||||
|
NullContract,
|
||||||
|
} from '../common/validation/null-contract.spec-helper';
|
||||||
|
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
|
||||||
|
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
|
||||||
|
|
||||||
|
const contracts: Record<string, NullContract> = {
|
||||||
|
UpdateComplianceSettingsDto: {
|
||||||
|
dto: UpdateComplianceSettingsDto,
|
||||||
|
notNull: [
|
||||||
|
'noticeOnColonyCountChange',
|
||||||
|
'noticeOnApiaryChange',
|
||||||
|
'tskReferenceDay',
|
||||||
|
'tskExcludedColonyTypes',
|
||||||
|
'netQuantityDigitRules',
|
||||||
|
],
|
||||||
|
clearable: [
|
||||||
|
'veterinaryOfficeName',
|
||||||
|
'veterinaryOfficeAddress',
|
||||||
|
'registrationNumber',
|
||||||
|
'tskReportDueDay',
|
||||||
|
'tskMemberNumber',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
UpdateAuthorityNoticeDto: {
|
||||||
|
dto: UpdateAuthorityNoticeDto,
|
||||||
|
notNull: ['totalColonies', 'referenceDate'],
|
||||||
|
clearable: ['sentOn', 'sentVia', 'reference', 'notes'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describeNullContracts('Compliance DTO null contracts', contracts);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Body, Controller, Get, Patch } 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 { ComplianceSettingsService } from './compliance-settings.service';
|
||||||
|
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
|
||||||
|
|
||||||
|
@ApiTags('compliance')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('compliance/settings')
|
||||||
|
export class ComplianceSettingsController {
|
||||||
|
constructor(private readonly settings: ComplianceSettingsService) {}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:read')
|
||||||
|
@Get()
|
||||||
|
get() {
|
||||||
|
return this.settings.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('compliance:manage')
|
||||||
|
@Patch()
|
||||||
|
update(
|
||||||
|
@Body() dto: UpdateComplianceSettingsDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.settings.update(dto, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import { ComplianceSettingsService } from './compliance-settings.service';
|
||||||
|
|
||||||
|
jest.mock('../prisma/row-lock', () => ({
|
||||||
|
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const STORED = {
|
||||||
|
id: 'singleton',
|
||||||
|
veterinaryOfficeName: null,
|
||||||
|
veterinaryOfficeAddress: null,
|
||||||
|
registrationNumber: null,
|
||||||
|
noticeOnColonyCountChange: true,
|
||||||
|
noticeOnApiaryChange: true,
|
||||||
|
tskReferenceDay: '01-01',
|
||||||
|
tskReportDueDay: null,
|
||||||
|
tskMemberNumber: null,
|
||||||
|
tskExcludedColonyTypes: [],
|
||||||
|
netQuantityDigitRules: [
|
||||||
|
{ maxGrams: 50, minMm: 2 },
|
||||||
|
{ maxGrams: null, minMm: 6 },
|
||||||
|
],
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ComplianceSettingsService', () => {
|
||||||
|
let service: ComplianceSettingsService;
|
||||||
|
let tx: {
|
||||||
|
complianceSettings: {
|
||||||
|
upsert: jest.Mock;
|
||||||
|
findUniqueOrThrow: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let prisma: {
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
complianceSettings: { findUnique: jest.Mock; upsert: jest.Mock };
|
||||||
|
};
|
||||||
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
|
||||||
|
tx = {
|
||||||
|
complianceSettings: {
|
||||||
|
upsert: jest.fn().mockResolvedValue(STORED),
|
||||||
|
findUniqueOrThrow: jest.fn().mockResolvedValue(STORED),
|
||||||
|
update: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(({ data }: { data: object }) =>
|
||||||
|
Promise.resolve({ ...STORED, ...data }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
$transaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||||
|
complianceSettings: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
upsert: jest.fn().mockResolvedValue(STORED),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
auditLog = { record: jest.fn() };
|
||||||
|
service = new ComplianceSettingsService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
auditLog as unknown as AuditLogService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates defaults on first read', async () => {
|
||||||
|
const settings = await service.get();
|
||||||
|
|
||||||
|
expect(prisma.complianceSettings.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
update: {},
|
||||||
|
create: { id: 'singleton' },
|
||||||
|
});
|
||||||
|
expect(settings).toBe(STORED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the stored row without creating one', async () => {
|
||||||
|
prisma.complianceSettings.findUnique.mockResolvedValue(STORED);
|
||||||
|
|
||||||
|
await service.get();
|
||||||
|
|
||||||
|
expect(prisma.complianceSettings.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects 02-29 as reference day with 400', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update({ tskReferenceDay: '02-29' }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.update({ tskReportDueDay: '13-01' }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects digit rules that are not ascending or whose last rule has a limit', async () => {
|
||||||
|
const attempts = [
|
||||||
|
[
|
||||||
|
{ maxGrams: 200, minMm: 3 },
|
||||||
|
{ maxGrams: 50, minMm: 2 },
|
||||||
|
{ maxGrams: null, minMm: 6 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ maxGrams: 50, minMm: 2 },
|
||||||
|
{ maxGrams: 50, minMm: 3 },
|
||||||
|
{ maxGrams: null, minMm: 6 },
|
||||||
|
],
|
||||||
|
[{ maxGrams: 50, minMm: 2 }],
|
||||||
|
[
|
||||||
|
{ maxGrams: null, minMm: 2 },
|
||||||
|
{ maxGrams: null, minMm: 6 },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
for (const rules of attempts) {
|
||||||
|
await expect(
|
||||||
|
service.update({ netQuantityDigitRules: rules }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a digit height outside 0.5 to 20 mm and more than 10 rules', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update(
|
||||||
|
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 0.4 }] },
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.update(
|
||||||
|
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 20.5 }] },
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
const eleven = Array.from({ length: 10 }, (_, index) => ({
|
||||||
|
maxGrams: (index + 1) * 10,
|
||||||
|
minMm: 2,
|
||||||
|
})).concat([{ maxGrams: null as never, minMm: 2 }]);
|
||||||
|
await expect(
|
||||||
|
service.update({ netQuantityDigitRules: eleven }, 'u1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing maxGrams key like null', async () => {
|
||||||
|
await service.update(
|
||||||
|
{
|
||||||
|
netQuantityDigitRules: [
|
||||||
|
{ maxGrams: 50, minMm: 2 },
|
||||||
|
{ minMm: 6 } as never,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const call = (
|
||||||
|
tx.complianceSettings.update.mock.calls as unknown[][]
|
||||||
|
)[0][0] as {
|
||||||
|
data: { netQuantityDigitRules: unknown };
|
||||||
|
};
|
||||||
|
expect(call.data.netQuantityDigitRules).toEqual([
|
||||||
|
{ maxGrams: 50, minMm: 2 },
|
||||||
|
{ maxGrams: null, minMm: 6 },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
service.update(
|
||||||
|
{
|
||||||
|
netQuantityDigitRules: [{ minMm: 2 } as never, { minMm: 6 } as never],
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a colony type listed twice', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update(
|
||||||
|
{ tskExcludedColonyTypes: ['NUCLEUS_COLONY', 'NUCLEUS_COLONY'] },
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores valid values under the row lock with an audit entry', async () => {
|
||||||
|
await service.update(
|
||||||
|
{
|
||||||
|
veterinaryOfficeName: ' Veterinäramt ',
|
||||||
|
tskReferenceDay: '04-15',
|
||||||
|
tskReportDueDay: null,
|
||||||
|
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'ComplianceSettings',
|
||||||
|
'singleton',
|
||||||
|
);
|
||||||
|
expect(tx.complianceSettings.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
veterinaryOfficeName: 'Veterinäramt',
|
||||||
|
tskReferenceDay: '04-15',
|
||||||
|
tskReportDueDay: null,
|
||||||
|
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: 'ComplianceSettings',
|
||||||
|
entityId: 'singleton',
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: 'u1',
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an omitted field keeps the stored value', async () => {
|
||||||
|
await service.update({ noticeOnApiaryChange: false }, 'u1');
|
||||||
|
|
||||||
|
const call = (
|
||||||
|
tx.complianceSettings.update.mock.calls as unknown[][]
|
||||||
|
)[0][0] as {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(call.data.noticeOnApiaryChange).toBe(false);
|
||||||
|
expect(call.data.tskReferenceDay).toBeUndefined();
|
||||||
|
expect(call.data.veterinaryOfficeName).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { ComplianceSettings } from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
|
||||||
|
import { complianceSettingsIn, COMPLIANCE_SETTINGS_ID } from './herd-status';
|
||||||
|
import { isValidMonthDay } from './tsk-day';
|
||||||
|
|
||||||
|
const AUDIT_ENTITY = 'ComplianceSettings';
|
||||||
|
const MIN_DIGIT_RULES = 1;
|
||||||
|
const MAX_DIGIT_RULES = 10;
|
||||||
|
const MIN_DIGIT_MM = 0.5;
|
||||||
|
const MAX_DIGIT_MM = 20;
|
||||||
|
|
||||||
|
interface DigitRuleInput {
|
||||||
|
maxGrams: number | null;
|
||||||
|
minMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMonthDay(field: string, value: string | null | undefined): void {
|
||||||
|
if (value != null && !isValidMonthDay(value)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${field} muss ein gültiger Tag im Format MM-TT sein (ohne 29. Februar)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1 to 10 rules, the limits strictly ascending, only the last without a
|
||||||
|
* limit, every height between 0.5 and 20 mm. The values themselves are the
|
||||||
|
* owner's assumption - nothing here knows what the law asks for.
|
||||||
|
*/
|
||||||
|
function assertDigitRules(rules: readonly DigitRuleInput[]): void {
|
||||||
|
if (rules.length < MIN_DIGIT_RULES || rules.length > MAX_DIGIT_RULES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Es braucht ${MIN_DIGIT_RULES} bis ${MAX_DIGIT_RULES} Regeln für die Ziffernhöhe`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let previous = 0;
|
||||||
|
rules.forEach((rule, index) => {
|
||||||
|
const isLast = index === rules.length - 1;
|
||||||
|
if (rule.minMm < MIN_DIGIT_MM || rule.minMm > MAX_DIGIT_MM) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Die Ziffernhöhe muss zwischen ${MIN_DIGIT_MM} und ${MAX_DIGIT_MM} mm liegen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLast) {
|
||||||
|
if (rule.maxGrams !== null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Die letzte Regel gilt für alles darüber und hat keine Obergrenze',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rule.maxGrams === null || rule.maxGrams <= previous) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Die Obergrenzen der Regeln müssen aufsteigen, nur die letzte hat keine',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
previous = rule.maxGrams;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUnique(values: readonly string[]): void {
|
||||||
|
if (new Set(values).size !== values.length) {
|
||||||
|
throw new BadRequestException('Eine Völkerart steht doppelt in der Liste');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ComplianceSettingsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Find-or-create, like SiteSettings. */
|
||||||
|
get(): Promise<ComplianceSettings> {
|
||||||
|
return complianceSettingsIn(this.prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
dto: UpdateComplianceSettingsDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ComplianceSettings> {
|
||||||
|
assertMonthDay('Der Stichtag', dto.tskReferenceDay);
|
||||||
|
assertMonthDay('Die Meldefrist', dto.tskReportDueDay);
|
||||||
|
if (dto.tskExcludedColonyTypes) {
|
||||||
|
assertUnique(dto.tskExcludedColonyTypes);
|
||||||
|
}
|
||||||
|
const digitRules = dto.netQuantityDigitRules?.map((rule) => ({
|
||||||
|
maxGrams: rule.maxGrams ?? null,
|
||||||
|
minMm: rule.minMm,
|
||||||
|
}));
|
||||||
|
if (digitRules) {
|
||||||
|
assertDigitRules(digitRules);
|
||||||
|
}
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// The row must exist to be locked; "before" is read after the lock.
|
||||||
|
await tx.complianceSettings.upsert({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
update: {},
|
||||||
|
create: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
});
|
||||||
|
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
|
||||||
|
const before = await tx.complianceSettings.findUniqueOrThrow({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
});
|
||||||
|
const settings = await tx.complianceSettings.update({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
data: {
|
||||||
|
veterinaryOfficeName: trimmedOrNull(dto.veterinaryOfficeName),
|
||||||
|
veterinaryOfficeAddress: trimmedOrNull(dto.veterinaryOfficeAddress),
|
||||||
|
registrationNumber: trimmedOrNull(dto.registrationNumber),
|
||||||
|
noticeOnColonyCountChange: dto.noticeOnColonyCountChange,
|
||||||
|
noticeOnApiaryChange: dto.noticeOnApiaryChange,
|
||||||
|
tskReferenceDay: dto.tskReferenceDay,
|
||||||
|
tskReportDueDay: dto.tskReportDueDay,
|
||||||
|
tskMemberNumber: trimmedOrNull(dto.tskMemberNumber),
|
||||||
|
tskExcludedColonyTypes: dto.tskExcludedColonyTypes,
|
||||||
|
netQuantityDigitRules: digitRules,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: AUDIT_ENTITY,
|
||||||
|
entityId: COMPLIANCE_SETTINGS_ID,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: settings,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return settings;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthorityNoticesController } from './authority-notices.controller';
|
||||||
|
import { AuthorityNoticesService } from './authority-notices.service';
|
||||||
|
import { ComplianceSettingsController } from './compliance-settings.controller';
|
||||||
|
import { ComplianceSettingsService } from './compliance-settings.service';
|
||||||
|
|
||||||
|
// No provider here may inject PermissionResolverService: the worker imports
|
||||||
|
// this module too (handlers arrive with the notification task).
|
||||||
|
@Module({
|
||||||
|
controllers: [ComplianceSettingsController, AuthorityNoticesController],
|
||||||
|
providers: [ComplianceSettingsService, AuthorityNoticesService],
|
||||||
|
exports: [ComplianceSettingsService, AuthorityNoticesService],
|
||||||
|
})
|
||||||
|
export class ComplianceModule {}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AuthorityNoticeKind } from '../../../generated/prisma';
|
||||||
|
|
||||||
|
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
export const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||||
|
export const MIN_NOTICE_YEAR = 2000;
|
||||||
|
export const MAX_NOTICE_YEAR = 2100;
|
||||||
|
|
||||||
|
export class CreateAuthorityNoticeDto {
|
||||||
|
@ApiProperty({ enum: AuthorityNoticeKind })
|
||||||
|
@IsEnum(AuthorityNoticeKind)
|
||||||
|
kind!: AuthorityNoticeKind;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Nur für die TSK-Zählung (dort Pflicht)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(MIN_NOTICE_YEAR)
|
||||||
|
@Max(MAX_NOTICE_YEAR)
|
||||||
|
year?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: '2027-01-01',
|
||||||
|
description: 'Vorgabe: heute (Berlin)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
referenceDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: '2026-05-01',
|
||||||
|
description:
|
||||||
|
'Gesetzt: die Anzeige entsteht gleich als gesendet (Altbestand)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
sentOn?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
|
||||||
|
import { MAX_NOTICE_TEXT_LENGTH } from './update-authority-notice.dto';
|
||||||
|
|
||||||
|
export class MarkSentDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: '2027-01-15',
|
||||||
|
description: 'Vorgabe: heute (Berlin)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
sentOn?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'z. B. E-Mail, Post' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||||
|
sentVia?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Aktenzeichen' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
import { AuthorityNoticeKind } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
MAX_NOTICE_YEAR,
|
||||||
|
MIN_NOTICE_YEAR,
|
||||||
|
} from './create-authority-notice.dto';
|
||||||
|
|
||||||
|
export class NoticeListQuery {
|
||||||
|
@ApiPropertyOptional({ enum: AuthorityNoticeKind })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AuthorityNoticeKind)
|
||||||
|
kind?: AuthorityNoticeKind;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(MIN_NOTICE_YEAR)
|
||||||
|
@Max(MAX_NOTICE_YEAR)
|
||||||
|
year?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||||
|
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
|
||||||
|
|
||||||
|
export const MAX_NOTICE_TEXT_LENGTH = 300;
|
||||||
|
export const MAX_NOTICE_NOTES_LENGTH = 2000;
|
||||||
|
|
||||||
|
export class UpdateAuthorityNoticeDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Die angezeigte Zahl; bleibt korrigierbar',
|
||||||
|
})
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
totalColonies?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2027-01-01' })
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
referenceDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, example: '2027-01-15' })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
sentOn?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, description: 'z. B. E-Mail, Post' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||||
|
sentVia?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||||
|
reference?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NOTICE_NOTES_LENGTH)
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ColonyType } from '../../../generated/prisma';
|
||||||
|
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||||
|
|
||||||
|
const MONTH_DAY_PATTERN = /^\d{2}-\d{2}$/;
|
||||||
|
const MONTH_DAY_MESSAGE = 'muss ein Tag im Format MM-TT sein';
|
||||||
|
const MAX_NAME_LENGTH = 200;
|
||||||
|
const MAX_ADDRESS_LENGTH = 300;
|
||||||
|
|
||||||
|
/** One row of the label digit height table. Annahme - vom Inhaber zu prüfen. */
|
||||||
|
export class NetQuantityDigitRuleDto {
|
||||||
|
@ApiProperty({
|
||||||
|
nullable: true,
|
||||||
|
description: 'Obergrenze in g, null = alles darüber',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||||
|
maxGrams!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Mindesthöhe der Ziffern in mm' })
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||||
|
minMm!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal values (deadlines, digit heights) are settings, not code. Every value
|
||||||
|
* here is an assumption the owner has to check.
|
||||||
|
*/
|
||||||
|
export class UpdateComplianceSettingsDto {
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NAME_LENGTH)
|
||||||
|
veterinaryOfficeName?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||||
|
veterinaryOfficeAddress?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NAME_LENGTH)
|
||||||
|
registrationNumber?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsBoolean()
|
||||||
|
noticeOnColonyCountChange?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsBoolean()
|
||||||
|
noticeOnApiaryChange?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '01-01', description: 'MM-TT, ohne 02-29' })
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsString()
|
||||||
|
@Matches(MONTH_DAY_PATTERN, {
|
||||||
|
message: `tskReferenceDay ${MONTH_DAY_MESSAGE}`,
|
||||||
|
})
|
||||||
|
tskReferenceDay?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true, example: '03-31' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(MONTH_DAY_PATTERN, {
|
||||||
|
message: `tskReportDueDay ${MONTH_DAY_MESSAGE}`,
|
||||||
|
})
|
||||||
|
tskReportDueDay?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_NAME_LENGTH)
|
||||||
|
tskMemberNumber?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ColonyType, isArray: true })
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsArray()
|
||||||
|
@IsEnum(ColonyType, { each: true })
|
||||||
|
tskExcludedColonyTypes?: ColonyType[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [NetQuantityDigitRuleDto] })
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => NetQuantityDigitRuleDto)
|
||||||
|
netQuantityDigitRules?: NetQuantityDigitRuleDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { ColonyType } from '../../generated/prisma';
|
||||||
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
|
import {
|
||||||
|
buildHerdSnapshot,
|
||||||
|
herdDeviation,
|
||||||
|
HerdApiaryRow,
|
||||||
|
HerdSnapshot,
|
||||||
|
loadHerdApiaryRows,
|
||||||
|
} from './herd-snapshot';
|
||||||
|
|
||||||
|
const TAKEN_AT = new Date('2027-01-01T10:00:00.000Z');
|
||||||
|
const ENABLED = { noticeOnColonyCountChange: true, noticeOnApiaryChange: true };
|
||||||
|
const P = ColonyType.PRODUCTION_COLONY;
|
||||||
|
const N = ColonyType.NUCLEUS_COLONY;
|
||||||
|
|
||||||
|
function row(
|
||||||
|
apiaryId: string,
|
||||||
|
colonyTypes: ColonyType[],
|
||||||
|
patch: Partial<HerdApiaryRow> = {},
|
||||||
|
): HerdApiaryRow {
|
||||||
|
return {
|
||||||
|
apiaryId,
|
||||||
|
name: `Stand ${apiaryId}`,
|
||||||
|
address: `Weg ${apiaryId}`,
|
||||||
|
location: null,
|
||||||
|
latitude: 48.1,
|
||||||
|
longitude: 11.5,
|
||||||
|
colonyTypes,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildHerdSnapshot', () => {
|
||||||
|
it('counts per apiary and per type', () => {
|
||||||
|
const snapshot = buildHerdSnapshot(
|
||||||
|
[row('a', [P, P, N]), row('b', [P])],
|
||||||
|
TAKEN_AT,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.version).toBe(1);
|
||||||
|
expect(snapshot.takenAt).toBe(TAKEN_AT.toISOString());
|
||||||
|
expect(snapshot.totalLiving).toBe(4);
|
||||||
|
expect(snapshot.apiaries[0]).toMatchObject({
|
||||||
|
apiaryId: 'a',
|
||||||
|
livingColonies: 3,
|
||||||
|
byType: { PRODUCTION_COLONY: 2, NUCLEUS_COLONY: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excluded colony types do not count', () => {
|
||||||
|
const snapshot = buildHerdSnapshot([row('a', [P, N, N])], TAKEN_AT, [N]);
|
||||||
|
|
||||||
|
expect(snapshot.totalLiving).toBe(1);
|
||||||
|
expect(snapshot.apiaries[0].byType).toEqual({ PRODUCTION_COLONY: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an apiary without colonies', () => {
|
||||||
|
const snapshot = buildHerdSnapshot([row('a', [])], TAKEN_AT);
|
||||||
|
|
||||||
|
expect(snapshot.apiaries).toHaveLength(1);
|
||||||
|
expect(snapshot.totalLiving).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadHerdApiaryRows', () => {
|
||||||
|
it('counts only living colonies at active apiaries', async () => {
|
||||||
|
const findMany = jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
name: 'A',
|
||||||
|
address: null,
|
||||||
|
location: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
colonies: [{ colonyType: P }, { colonyType: N }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const db = { apiary: { findMany } };
|
||||||
|
|
||||||
|
const rows = await loadHerdApiaryRows(db as never);
|
||||||
|
|
||||||
|
const args = (findMany.mock.calls as unknown[][])[0][0] as {
|
||||||
|
where: unknown;
|
||||||
|
select: { colonies: { where: unknown } };
|
||||||
|
};
|
||||||
|
expect(args.where).toEqual({ status: 'ACTIVE' });
|
||||||
|
expect(args.select.colonies.where).toEqual(LIVING_COLONY_WHERE);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
expect.objectContaining({ apiaryId: 'a', colonyTypes: [P, N] }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('herdDeviation', () => {
|
||||||
|
const base = [row('a', [P, P]), row('b', [P])];
|
||||||
|
|
||||||
|
function baselineOf(rows: HerdApiaryRow[], totalColonies?: number) {
|
||||||
|
const snapshot = buildHerdSnapshot(rows, TAKEN_AT);
|
||||||
|
return { snapshot, totalColonies: totalColonies ?? snapshot.totalLiving };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('no deviation without changes', () => {
|
||||||
|
const deviation = herdDeviation(
|
||||||
|
baselineOf(base),
|
||||||
|
buildHerdSnapshot(base, TAKEN_AT),
|
||||||
|
ENABLED,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deviation).toEqual({
|
||||||
|
triggered: false,
|
||||||
|
colonyCountChanged: false,
|
||||||
|
apiariesAdded: [],
|
||||||
|
apiariesRemoved: [],
|
||||||
|
apiariesRelocated: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('count change triggers only when enabled', () => {
|
||||||
|
const current = buildHerdSnapshot([row('a', [P, P, P]), base[1]], TAKEN_AT);
|
||||||
|
|
||||||
|
expect(herdDeviation(baselineOf(base), current, ENABLED).triggered).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const off = herdDeviation(baselineOf(base), current, {
|
||||||
|
...ENABLED,
|
||||||
|
noticeOnColonyCountChange: false,
|
||||||
|
});
|
||||||
|
expect(off.triggered).toBe(false);
|
||||||
|
expect(off.colonyCountChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moving between apiaries keeps the total and triggers nothing', () => {
|
||||||
|
const current = buildHerdSnapshot(
|
||||||
|
[row('a', [P]), row('b', [P, P])],
|
||||||
|
TAKEN_AT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deviation = herdDeviation(baselineOf(base), current, ENABLED);
|
||||||
|
|
||||||
|
expect(deviation.colonyCountChanged).toBe(false);
|
||||||
|
expect(deviation.triggered).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new, removed or relocated apiary triggers when enabled', () => {
|
||||||
|
const added = buildHerdSnapshot([...base, row('c', [])], TAKEN_AT);
|
||||||
|
const removed = buildHerdSnapshot([base[0]], TAKEN_AT);
|
||||||
|
const relocated = buildHerdSnapshot(
|
||||||
|
[base[0], row('b', [P], { address: 'Neuer Weg 1' })],
|
||||||
|
TAKEN_AT,
|
||||||
|
);
|
||||||
|
const sameCount = (current: HerdSnapshot) =>
|
||||||
|
herdDeviation(baselineOf(base, current.totalLiving), current, ENABLED);
|
||||||
|
|
||||||
|
expect(sameCount(added)).toMatchObject({
|
||||||
|
triggered: true,
|
||||||
|
apiariesAdded: ['c'],
|
||||||
|
});
|
||||||
|
expect(sameCount(removed)).toMatchObject({
|
||||||
|
triggered: true,
|
||||||
|
apiariesRemoved: ['b'],
|
||||||
|
});
|
||||||
|
expect(sameCount(relocated)).toMatchObject({
|
||||||
|
triggered: true,
|
||||||
|
apiariesRelocated: ['b'],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
herdDeviation(baselineOf(base, 3), relocated, {
|
||||||
|
...ENABLED,
|
||||||
|
noticeOnApiaryChange: false,
|
||||||
|
}).triggered,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares the count with the corrected totalColonies of the baseline', () => {
|
||||||
|
const current = buildHerdSnapshot(base, TAKEN_AT);
|
||||||
|
|
||||||
|
const deviation = herdDeviation(baselineOf(base, 5), current, ENABLED);
|
||||||
|
|
||||||
|
expect(deviation.colonyCountChanged).toBe(true);
|
||||||
|
expect(deviation.triggered).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { ColonyType, ComplianceSettings } from '../../generated/prisma';
|
||||||
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
|
|
||||||
|
export const HERD_SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
|
export interface HerdApiarySnapshot {
|
||||||
|
apiaryId: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
location: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
livingColonies: number;
|
||||||
|
byType: Partial<Record<ColonyType, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HerdSnapshot {
|
||||||
|
version: 1;
|
||||||
|
takenAt: string;
|
||||||
|
apiaries: HerdApiarySnapshot[];
|
||||||
|
totalLiving: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An active apiary with the colony type of each living colony standing there. */
|
||||||
|
export interface HerdApiaryRow {
|
||||||
|
apiaryId: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
location: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
colonyTypes: ColonyType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HerdDeviation {
|
||||||
|
triggered: boolean;
|
||||||
|
colonyCountChanged: boolean;
|
||||||
|
apiariesAdded: string[];
|
||||||
|
apiariesRemoved: string[];
|
||||||
|
apiariesRelocated: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviationSettings = Pick<
|
||||||
|
ComplianceSettings,
|
||||||
|
'noticeOnColonyCountChange' | 'noticeOnApiaryChange'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Pure: counts the living colonies per apiary and per type. */
|
||||||
|
export function buildHerdSnapshot(
|
||||||
|
rows: readonly HerdApiaryRow[],
|
||||||
|
takenAt: Date,
|
||||||
|
excludedTypes: readonly ColonyType[] = [],
|
||||||
|
): HerdSnapshot {
|
||||||
|
const apiaries = rows.map((row): HerdApiarySnapshot => {
|
||||||
|
const byType: Partial<Record<ColonyType, number>> = {};
|
||||||
|
let livingColonies = 0;
|
||||||
|
for (const type of row.colonyTypes) {
|
||||||
|
if (excludedTypes.includes(type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byType[type] = (byType[type] ?? 0) + 1;
|
||||||
|
livingColonies += 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
apiaryId: row.apiaryId,
|
||||||
|
name: row.name,
|
||||||
|
address: row.address,
|
||||||
|
location: row.location,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
livingColonies,
|
||||||
|
byType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
version: HERD_SNAPSHOT_VERSION,
|
||||||
|
takenAt: takenAt.toISOString(),
|
||||||
|
apiaries,
|
||||||
|
totalLiving: apiaries.reduce(
|
||||||
|
(sum, apiary) => sum + apiary.livingColonies,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active apiaries with their living colonies (LIVING_COLONY_WHERE). */
|
||||||
|
export async function loadHerdApiaryRows(
|
||||||
|
db: DbClient,
|
||||||
|
): Promise<HerdApiaryRow[]> {
|
||||||
|
const apiaries = await db.apiary.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
address: true,
|
||||||
|
location: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
colonies: { where: LIVING_COLONY_WHERE, select: { colonyType: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return apiaries.map((apiary) => ({
|
||||||
|
apiaryId: apiary.id,
|
||||||
|
name: apiary.name,
|
||||||
|
address: apiary.address,
|
||||||
|
location: apiary.location,
|
||||||
|
latitude: apiary.latitude,
|
||||||
|
longitude: apiary.longitude,
|
||||||
|
colonyTypes: apiary.colonies.map((colony) => colony.colonyType),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSite(a: HerdApiarySnapshot, b: HerdApiarySnapshot): boolean {
|
||||||
|
return (
|
||||||
|
a.address === b.address &&
|
||||||
|
a.location === b.location &&
|
||||||
|
a.latitude === b.latitude &&
|
||||||
|
a.longitude === b.longitude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the current herd differs from the baseline. The count is compared with
|
||||||
|
* the corrected `totalColonies` of the notice, not with its snapshot. A move
|
||||||
|
* between apiaries keeps the total and triggers nothing.
|
||||||
|
*/
|
||||||
|
export function herdDeviation(
|
||||||
|
baseline: { snapshot: HerdSnapshot; totalColonies: number },
|
||||||
|
current: HerdSnapshot,
|
||||||
|
settings: DeviationSettings,
|
||||||
|
): HerdDeviation {
|
||||||
|
const before = new Map(
|
||||||
|
baseline.snapshot.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
|
||||||
|
);
|
||||||
|
const now = new Map(
|
||||||
|
current.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
|
||||||
|
);
|
||||||
|
const apiariesAdded = [...now.keys()].filter((id) => !before.has(id));
|
||||||
|
const apiariesRemoved = [...before.keys()].filter((id) => !now.has(id));
|
||||||
|
const apiariesRelocated = [...now.entries()]
|
||||||
|
.filter(([id, apiary]) => {
|
||||||
|
const old = before.get(id);
|
||||||
|
return old !== undefined && !sameSite(old, apiary);
|
||||||
|
})
|
||||||
|
.map(([id]) => id);
|
||||||
|
const colonyCountChanged = baseline.totalColonies !== current.totalLiving;
|
||||||
|
const apiaryChanged =
|
||||||
|
apiariesAdded.length + apiariesRemoved.length + apiariesRelocated.length >
|
||||||
|
0;
|
||||||
|
return {
|
||||||
|
triggered:
|
||||||
|
(settings.noticeOnColonyCountChange && colonyCountChanged) ||
|
||||||
|
(settings.noticeOnApiaryChange && apiaryChanged),
|
||||||
|
colonyCountChanged,
|
||||||
|
apiariesAdded,
|
||||||
|
apiariesRemoved,
|
||||||
|
apiariesRelocated,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { herdStatusIn } from './herd-status';
|
||||||
|
|
||||||
|
const NOW = new Date('2027-03-01T10:00:00.000Z');
|
||||||
|
|
||||||
|
function settings() {
|
||||||
|
return {
|
||||||
|
id: 'singleton',
|
||||||
|
noticeOnColonyCountChange: true,
|
||||||
|
noticeOnApiaryChange: true,
|
||||||
|
tskExcludedColonyTypes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbWith(baseline: unknown) {
|
||||||
|
return {
|
||||||
|
complianceSettings: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(settings()),
|
||||||
|
upsert: jest.fn().mockResolvedValue(settings()),
|
||||||
|
},
|
||||||
|
authorityNotice: { findFirst: jest.fn().mockResolvedValue(baseline) },
|
||||||
|
apiary: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
name: 'A',
|
||||||
|
address: null,
|
||||||
|
location: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
colonies: [{ colonyType: 'PRODUCTION_COLONY' }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('herdStatusIn', () => {
|
||||||
|
it('no baseline means deviation null, not a herd change', async () => {
|
||||||
|
const status = await herdStatusIn(dbWith(null) as never, NOW);
|
||||||
|
|
||||||
|
expect(status.baseline).toBeNull();
|
||||||
|
expect(status.deviation).toBeNull();
|
||||||
|
expect(status.current.totalLiving).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the baseline is the newest SENT registration or herd change by sentOn and ignores TSK reports', async () => {
|
||||||
|
const db = dbWith(null);
|
||||||
|
|
||||||
|
await herdStatusIn(db as never, NOW);
|
||||||
|
|
||||||
|
expect(db.authorityNotice.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
status: 'SENT',
|
||||||
|
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
|
||||||
|
},
|
||||||
|
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the deviation against the baseline', async () => {
|
||||||
|
const baseline = {
|
||||||
|
id: 'n1',
|
||||||
|
totalColonies: 3,
|
||||||
|
snapshot: {
|
||||||
|
version: 1,
|
||||||
|
takenAt: NOW.toISOString(),
|
||||||
|
apiaries: [],
|
||||||
|
totalLiving: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = await herdStatusIn(dbWith(baseline) as never, NOW);
|
||||||
|
|
||||||
|
expect(status.deviation).toMatchObject({
|
||||||
|
triggered: true,
|
||||||
|
colonyCountChanged: true,
|
||||||
|
apiariesAdded: ['a'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { AuthorityNotice, ComplianceSettings } from '../../generated/prisma';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
|
import {
|
||||||
|
buildHerdSnapshot,
|
||||||
|
herdDeviation,
|
||||||
|
HerdDeviation,
|
||||||
|
HerdSnapshot,
|
||||||
|
loadHerdApiaryRows,
|
||||||
|
} from './herd-snapshot';
|
||||||
|
|
||||||
|
export const COMPLIANCE_SETTINGS_ID = 'singleton';
|
||||||
|
|
||||||
|
export interface HerdStatus {
|
||||||
|
settings: ComplianceSettings;
|
||||||
|
baseline: AuthorityNotice | null;
|
||||||
|
current: HerdSnapshot;
|
||||||
|
/** null without a baseline: there is nothing to deviate from. */
|
||||||
|
deviation: HerdDeviation | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The settings row, created with its defaults when it does not exist yet. */
|
||||||
|
export async function complianceSettingsIn(
|
||||||
|
db: DbClient,
|
||||||
|
): Promise<ComplianceSettings> {
|
||||||
|
const existing = await db.complianceSettings.findUnique({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return db.complianceSettings.upsert({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
update: {},
|
||||||
|
create: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline is the newest SENT registration or herd change (by sentOn);
|
||||||
|
* TSK reports go to another recipient and do not count.
|
||||||
|
*/
|
||||||
|
export function findBaselineIn(db: DbClient): Promise<AuthorityNotice | null> {
|
||||||
|
return db.authorityNotice.findFirst({
|
||||||
|
where: { status: 'SENT', kind: { in: ['REGISTRATION', 'HERD_CHANGE'] } },
|
||||||
|
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function herdStatusIn(
|
||||||
|
db: DbClient,
|
||||||
|
now: Date,
|
||||||
|
): Promise<HerdStatus> {
|
||||||
|
const settings = await complianceSettingsIn(db);
|
||||||
|
const baseline = await findBaselineIn(db);
|
||||||
|
const current = buildHerdSnapshot(await loadHerdApiaryRows(db), now);
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
baseline,
|
||||||
|
current,
|
||||||
|
deviation: baseline
|
||||||
|
? herdDeviation(
|
||||||
|
{
|
||||||
|
snapshot: baseline.snapshot as unknown as HerdSnapshot,
|
||||||
|
totalColonies: baseline.totalColonies,
|
||||||
|
},
|
||||||
|
current,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
assertNoActiveRestriction,
|
||||||
|
isRestrictionActive,
|
||||||
|
} from './restricted-zone';
|
||||||
|
|
||||||
|
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
function row(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
apiary: { name: 'Stand Nord' },
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
endsOn: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTx(rows: unknown[]) {
|
||||||
|
return { apiaryRestriction: { findMany: jest.fn().mockResolvedValue(rows) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpBody {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
restrictionIds: string[];
|
||||||
|
restrictions: Record<string, string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refusal(promise: Promise<unknown>): Promise<HttpBody> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
return (error as { getResponse: () => HttpBody }).getResponse();
|
||||||
|
}
|
||||||
|
throw new Error('expected a refusal');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isRestrictionActive', () => {
|
||||||
|
it('active on the start and end day, inactive the day after', () => {
|
||||||
|
const restriction = {
|
||||||
|
startsOn: day('2026-10-01'),
|
||||||
|
endsOn: day('2026-10-10'),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isRestrictionActive(restriction, '2026-09-30')).toBe(false);
|
||||||
|
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
|
||||||
|
expect(isRestrictionActive(restriction, '2026-10-10')).toBe(true);
|
||||||
|
expect(isRestrictionActive(restriction, '2026-10-11')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an open end date means until further notice', () => {
|
||||||
|
const restriction = { startsOn: day('2026-10-01'), endsOn: null };
|
||||||
|
|
||||||
|
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
|
||||||
|
expect(isRestrictionActive(restriction, '2040-01-01')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertNoActiveRestriction', () => {
|
||||||
|
it('passes without a query when no apiary is named', async () => {
|
||||||
|
const tx = fakeTx([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(tx as never, [], '2026-10-15'),
|
||||||
|
).resolves.toEqual({ overridden: false, restrictionIds: [] });
|
||||||
|
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the only restriction ended yesterday', async () => {
|
||||||
|
const tx = fakeTx([row({ endsOn: day('2026-10-14') })]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
|
||||||
|
).resolves.toEqual({ overridden: false, restrictionIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for each apiary once, with the unique ids', async () => {
|
||||||
|
const tx = fakeTx([]);
|
||||||
|
|
||||||
|
await assertNoActiveRestriction(
|
||||||
|
tx as never,
|
||||||
|
['a2', 'a1', 'a2'],
|
||||||
|
'2026-10-15',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[args]] = tx.apiaryRestriction.findMany.mock.calls as [
|
||||||
|
[{ where: { apiaryId: { in: string[] } } }],
|
||||||
|
];
|
||||||
|
expect([...args.where.apiaryId.in].sort()).toEqual(['a1', 'a2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses moving into and out of a restricted apiary', async () => {
|
||||||
|
const tx = fakeTx([row()]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(tx as never, ['a0', 'a1'], '2026-10-15'),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the 409 body names apiary, disease and start', async () => {
|
||||||
|
const tx = fakeTx([row({ id: 'r9' })]);
|
||||||
|
|
||||||
|
const body = await refusal(
|
||||||
|
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body.code).toBe('RESTRICTED_ZONE');
|
||||||
|
expect(body.restrictionIds).toEqual(['r9']);
|
||||||
|
expect(body.restrictions).toEqual([
|
||||||
|
{
|
||||||
|
id: 'r9',
|
||||||
|
apiaryId: 'a1',
|
||||||
|
apiaryName: 'Stand Nord',
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(body.message).toContain('Stand Nord');
|
||||||
|
expect(body.message).toContain('Faulbrut');
|
||||||
|
expect(body.message).toContain('01.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('override needs compliance:manage and a reason', async () => {
|
||||||
|
const tx = fakeTx([row()]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||||
|
reason: 'Amtstierarzt hat es genehmigt',
|
||||||
|
canOverride: false,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||||
|
reason: 'Amtstierarzt hat es genehmigt',
|
||||||
|
canOverride: true,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ overridden: true, restrictionIds: ['r1'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, undefined])(
|
||||||
|
'a missing reason (%s) is no override even with the right',
|
||||||
|
async (reason) => {
|
||||||
|
const tx = fakeTx([row()]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||||
|
reason,
|
||||||
|
canOverride: true,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('a reason without an active restriction is ignored, even without the right', async () => {
|
||||||
|
const tx = fakeTx([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||||
|
reason: 'Amtstierarzt hat es genehmigt',
|
||||||
|
canOverride: false,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ overridden: false, restrictionIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the root client', async () => {
|
||||||
|
const root = {
|
||||||
|
$connect: jest.fn(),
|
||||||
|
apiaryRestriction: { findMany: jest.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNoActiveRestriction(root as never, ['a1'], '2026-10-15'),
|
||||||
|
).rejects.toThrow('needs the transaction client');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { ConflictException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { dayToDate } from '../common/dates';
|
||||||
|
import { formatDayDe } from '../common/format-day';
|
||||||
|
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a caller may do about an active restriction: `reason` is the typed
|
||||||
|
* justification (null and undefined count as omitted), `canOverride` says
|
||||||
|
* whether the user holds compliance:manage.
|
||||||
|
*/
|
||||||
|
export interface RestrictionOverride {
|
||||||
|
reason: string | null | undefined;
|
||||||
|
canOverride: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveRestrictionInfo {
|
||||||
|
id: string;
|
||||||
|
apiaryId: string;
|
||||||
|
apiaryName: string;
|
||||||
|
disease: string;
|
||||||
|
startsOn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestrictionCheck {
|
||||||
|
overridden: boolean;
|
||||||
|
restrictionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESTRICTED_ZONE_CODE = 'RESTRICTED_ZONE';
|
||||||
|
|
||||||
|
const NO_RESTRICTION: RestrictionCheck = {
|
||||||
|
overridden: false,
|
||||||
|
restrictionIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function dayOf(value: Date): string {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active from the start day to the end day, both included; an open end date
|
||||||
|
* means until further notice. `day` is a Berlin calendar day (YYYY-MM-DD),
|
||||||
|
* the dates are stored at midnight UTC of their day.
|
||||||
|
*/
|
||||||
|
export function isRestrictionActive(
|
||||||
|
restriction: { startsOn: Date; endsOn: Date | null },
|
||||||
|
day: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
dayOf(restriction.startsOn) <= day &&
|
||||||
|
(restriction.endsOn === null || day <= dayOf(restriction.endsOn))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RestrictionRow = Prisma.ApiaryRestrictionGetPayload<{
|
||||||
|
include: { apiary: { select: { name: true } } };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function toActiveRestrictionInfo(
|
||||||
|
row: RestrictionRow,
|
||||||
|
): ActiveRestrictionInfo {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
apiaryId: row.apiaryId,
|
||||||
|
apiaryName: row.apiary.name,
|
||||||
|
disease: row.disease,
|
||||||
|
startsOn: dayOf(row.startsOn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The restrictions that started on or before `day` (the caller filters the end). */
|
||||||
|
export function startedRestrictions(
|
||||||
|
db: DbClient,
|
||||||
|
apiaryIds: readonly string[] | undefined,
|
||||||
|
day: string,
|
||||||
|
): Promise<RestrictionRow[]> {
|
||||||
|
return db.apiaryRestriction.findMany({
|
||||||
|
where: {
|
||||||
|
apiaryId: apiaryIds ? { in: [...apiaryIds] } : undefined,
|
||||||
|
startsOn: { lte: dayToDate(day) },
|
||||||
|
},
|
||||||
|
include: { apiary: { select: { name: true } } },
|
||||||
|
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refusalMessage(infos: readonly ActiveRestrictionInfo[]): string {
|
||||||
|
const places = infos.map(
|
||||||
|
(info) =>
|
||||||
|
`„${info.apiaryName}“ (${info.disease}, seit ${formatDayDe(info.startsOn, true)})`,
|
||||||
|
);
|
||||||
|
return infos.length === 1
|
||||||
|
? `Der Bienenstand ${places[0]} liegt im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`
|
||||||
|
: `Die Bienenstände ${places.join(', ')} liegen im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a colony being brought into, out of or between apiaries while one of
|
||||||
|
* them lies in an active restricted zone (409 RESTRICTED_ZONE). Runs inside the
|
||||||
|
* caller's transaction AFTER the apiary rows are locked (FOR SHARE), so a
|
||||||
|
* restriction cannot start between the check and the write.
|
||||||
|
*
|
||||||
|
* A reason with the right to use it (`canOverride`) lets the action through;
|
||||||
|
* a reason without the right is a 403. A reason without any active
|
||||||
|
* restriction is ignored. The caller writes the reason into audit and
|
||||||
|
* history, never onto the colony.
|
||||||
|
*/
|
||||||
|
export async function assertNoActiveRestriction(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
apiaryIds: readonly string[],
|
||||||
|
day: string,
|
||||||
|
override?: RestrictionOverride,
|
||||||
|
): Promise<RestrictionCheck> {
|
||||||
|
assertTransactionClient(tx, 'assertNoActiveRestriction');
|
||||||
|
const ids = [...new Set(apiaryIds)];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NO_RESTRICTION;
|
||||||
|
}
|
||||||
|
const active = (await startedRestrictions(tx, ids, day)).filter((row) =>
|
||||||
|
isRestrictionActive(row, day),
|
||||||
|
);
|
||||||
|
if (active.length === 0) {
|
||||||
|
return NO_RESTRICTION;
|
||||||
|
}
|
||||||
|
const restrictionIds = active.map((row) => row.id);
|
||||||
|
if (override?.reason) {
|
||||||
|
if (!override.canOverride) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Für eine Ausnahme vom Sperrbezirk fehlt das Recht Prüfbereitschaft verwalten (compliance:manage).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { overridden: true, restrictionIds };
|
||||||
|
}
|
||||||
|
const restrictions = active.map(toActiveRestrictionInfo);
|
||||||
|
throw new ConflictException({
|
||||||
|
statusCode: 409,
|
||||||
|
code: RESTRICTED_ZONE_CODE,
|
||||||
|
message: refusalMessage(restrictions),
|
||||||
|
restrictionIds,
|
||||||
|
restrictions,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { isValidMonthDay, tskReferenceDayOf } from './tsk-day';
|
||||||
|
|
||||||
|
describe('isValidMonthDay', () => {
|
||||||
|
it.each(['02-30', '02-29', '1-1', '13-01', '00-10', 'ab-cd', '01-32', ''])(
|
||||||
|
'rejects %s',
|
||||||
|
(value) => {
|
||||||
|
expect(isValidMonthDay(value)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['01-01', '12-31', '02-28', '04-30'])('accepts %s', (value) => {
|
||||||
|
expect(isValidMonthDay(value)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tskReferenceDayOf', () => {
|
||||||
|
it('joins year and month-day into a day', () => {
|
||||||
|
expect(tskReferenceDayOf(2027, '01-01')).toBe('2027-01-01');
|
||||||
|
expect(tskReferenceDayOf(2028, '12-31')).toBe('2028-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid month-day', () => {
|
||||||
|
expect(() => tskReferenceDayOf(2027, '02-29')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const MONTH_DAY_PATTERN = /^(\d{2})-(\d{2})$/;
|
||||||
|
// Days per month of a non-leap year: 02-29 is not a valid reference day, so
|
||||||
|
// the reference day exists in every year.
|
||||||
|
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
|
||||||
|
/** MM-TT with a real month and a day that exists in a non-leap year. */
|
||||||
|
export function isValidMonthDay(value: string): boolean {
|
||||||
|
const match = MONTH_DAY_PATTERN.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const month = Number(match[1]);
|
||||||
|
const day = Number(match[2]);
|
||||||
|
return (
|
||||||
|
month >= 1 && month <= 12 && day >= 1 && day <= DAYS_IN_MONTH[month - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reference day (YYYY-MM-DD) of a year for a stored MM-TT. */
|
||||||
|
export function tskReferenceDayOf(year: number, monthDay: string): string {
|
||||||
|
if (!isValidMonthDay(monthDay)) {
|
||||||
|
throw new Error(`Invalid month-day: ${monthDay}`);
|
||||||
|
}
|
||||||
|
return `${String(year).padStart(4, '0')}-${monthDay}`;
|
||||||
|
}
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
@ApiTags('export')
|
@ApiTags('export')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('export')
|
@Controller('export')
|
||||||
export class ExportController {
|
export class ExportController {
|
||||||
constructor(private readonly exportService: ExportService) {}
|
constructor(
|
||||||
|
private readonly exportService: ExportService,
|
||||||
|
private readonly permissions: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@RequirePermission('export:read')
|
@RequirePermission('export:read')
|
||||||
@Get('colonies.csv')
|
@Get('colonies.csv')
|
||||||
@@ -54,8 +67,17 @@ export class ExportController {
|
|||||||
@Query('colonyId') colonyId: string | undefined,
|
@Query('colonyId') colonyId: string | undefined,
|
||||||
@Query('apiaryId') apiaryId: string | undefined,
|
@Query('apiaryId') apiaryId: string | undefined,
|
||||||
@Query('year') year: string | undefined,
|
@Query('year') year: string | undefined,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// The Bestandsbuch prints supplier, purchase proofs and the vet: those
|
||||||
|
// are visible with veterinary-medicines:read only.
|
||||||
|
if (report === 'treatment-log') {
|
||||||
|
const held = await this.permissions.getPermissions(actor.id);
|
||||||
|
if (!held.has('veterinary-medicines:read')) {
|
||||||
|
throw new ForbiddenException('Insufficient permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
const pdf = await this.exportService.exportReportPdf(report, {
|
const pdf = await this.exportService.exportReportPdf(report, {
|
||||||
honeyBatchId,
|
honeyBatchId,
|
||||||
colonyId,
|
colonyId,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
apiaryOnDate,
|
apiaryOnDate,
|
||||||
groupTreatmentLog,
|
groupTreatmentLog,
|
||||||
|
LOT_FOOTNOTE,
|
||||||
|
MISSING,
|
||||||
|
resolveSourceValues,
|
||||||
|
treatmentLogCells,
|
||||||
|
treatmentLogGaps,
|
||||||
TreatmentLogSource,
|
TreatmentLogSource,
|
||||||
} from './treatment-log';
|
} from './treatment-log';
|
||||||
|
|
||||||
@@ -24,10 +29,51 @@ function source(overrides: Partial<TreatmentLogSource>): TreatmentLogSource {
|
|||||||
prescribingVet: null,
|
prescribingVet: null,
|
||||||
withdrawalPeriodDays: 0,
|
withdrawalPeriodDays: 0,
|
||||||
administeredBy: 'Imker',
|
administeredBy: 'Imker',
|
||||||
|
medicineId: null,
|
||||||
|
prescriptionOnly: false,
|
||||||
|
doseQuantity: null,
|
||||||
|
medicineUnit: null,
|
||||||
|
lot: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Lot = NonNullable<TreatmentLogSource['lot']>;
|
||||||
|
|
||||||
|
const lotOf = (overrides: Partial<Lot> = {}): Lot => ({
|
||||||
|
id: 'lot-1',
|
||||||
|
batchNumber: 'LOT-B',
|
||||||
|
supplier: 'Apotheke Y, Hauptstr. 1',
|
||||||
|
purchaseProof: 'Rechnung 77',
|
||||||
|
prescribingVet: 'Dr. Vet',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A treatment from a pack: supplier, proof, batch and vet only on the lot.
|
||||||
|
const fromLot = (
|
||||||
|
overrides: Partial<TreatmentLogSource> = {},
|
||||||
|
): TreatmentLogSource =>
|
||||||
|
source({
|
||||||
|
medicineId: 'med-1',
|
||||||
|
batchNumber: null,
|
||||||
|
supplier: null,
|
||||||
|
purchaseProof: null,
|
||||||
|
prescribingVet: null,
|
||||||
|
lot: lotOf(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const withDose = (
|
||||||
|
dose: number,
|
||||||
|
unit: 'ML' | 'G' | 'PIECE',
|
||||||
|
): TreatmentLogSource =>
|
||||||
|
source({
|
||||||
|
amount: null,
|
||||||
|
medicineId: 'med-1',
|
||||||
|
doseQuantity: dose,
|
||||||
|
medicineUnit: unit,
|
||||||
|
});
|
||||||
|
|
||||||
describe('apiaryOnDate', () => {
|
describe('apiaryOnDate', () => {
|
||||||
it('uses the apiary stored with the treatment', () => {
|
it('uses the apiary stored with the treatment', () => {
|
||||||
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
|
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
|
||||||
@@ -124,3 +170,178 @@ describe('groupTreatmentLog', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveSourceValues', () => {
|
||||||
|
it('takes supplier, proof, batch and vet from the lot', () => {
|
||||||
|
expect(
|
||||||
|
resolveSourceValues(fromLot({ batchNumber: 'ignored-by-design' })),
|
||||||
|
).toMatchObject({
|
||||||
|
batchNumber: 'LOT-B',
|
||||||
|
supplier: 'Apotheke Y, Hauptstr. 1',
|
||||||
|
purchaseProof: 'Rechnung 77',
|
||||||
|
prescribingVet: 'Dr. Vet',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never mixes treatment text into a row with a lot', () => {
|
||||||
|
const values = resolveSourceValues(
|
||||||
|
fromLot({
|
||||||
|
supplier: 'alt',
|
||||||
|
lot: lotOf({ supplier: null, purchaseProof: null }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(values.supplier).toBeNull();
|
||||||
|
expect(values.purchaseProof).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the treatment text when there is no lot', () => {
|
||||||
|
expect(resolveSourceValues(source({}))).toMatchObject({
|
||||||
|
batchNumber: 'B1',
|
||||||
|
supplier: 'Imkereibedarf X',
|
||||||
|
purchaseProof: 'Rechnung 1',
|
||||||
|
prescribingVet: null,
|
||||||
|
amount: '30 ml',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints the dose with unit when the amount text is empty', () => {
|
||||||
|
expect(resolveSourceValues(withDose(30, 'ML')).amount).toBe('30 ml');
|
||||||
|
expect(resolveSourceValues(withDose(2.5, 'G')).amount).toBe('2,5 g');
|
||||||
|
expect(resolveSourceValues(withDose(1, 'PIECE')).amount).toBe('1 Stück');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the amount text over the dose', () => {
|
||||||
|
expect(
|
||||||
|
resolveSourceValues({ ...withDose(30, 'ML'), amount: '5 ml je Volk' })
|
||||||
|
.amount,
|
||||||
|
).toBe('5 ml je Volk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the amount empty without text and without dose', () => {
|
||||||
|
expect(resolveSourceValues(source({ amount: null })).amount).toBeNull();
|
||||||
|
expect(resolveSourceValues(source({ amount: ' ' })).amount).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('treatmentLogGaps', () => {
|
||||||
|
const complete = {
|
||||||
|
supplier: 'S',
|
||||||
|
purchaseProof: 'P',
|
||||||
|
amount: '1 ml',
|
||||||
|
withdrawalPeriodDays: 0,
|
||||||
|
prescribingVet: null,
|
||||||
|
prescriptionOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reports nothing for a complete row; a withdrawal of 0 days is complete', () => {
|
||||||
|
expect(treatmentLogGaps(complete)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports each missing mandatory field', () => {
|
||||||
|
expect(
|
||||||
|
treatmentLogGaps({
|
||||||
|
...complete,
|
||||||
|
supplier: null,
|
||||||
|
purchaseProof: '',
|
||||||
|
amount: null,
|
||||||
|
withdrawalPeriodDays: null,
|
||||||
|
}),
|
||||||
|
).toEqual(['SUPPLIER', 'PROOF', 'AMOUNT', 'WITHDRAWAL']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the vet only for prescription-only medicines', () => {
|
||||||
|
expect(treatmentLogGaps({ ...complete, prescriptionOnly: true })).toEqual([
|
||||||
|
'VET',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
treatmentLogGaps({
|
||||||
|
...complete,
|
||||||
|
prescriptionOnly: true,
|
||||||
|
prescribingVet: 'Dr. Vet',
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(treatmentLogGaps(complete)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bestandsbuch from the lot', () => {
|
||||||
|
it('groups two colonies of the same lot and day into one row', () => {
|
||||||
|
const [sheet] = groupTreatmentLog(
|
||||||
|
[
|
||||||
|
fromLot({ id: '1', colonyCode: 'V-02', apiaryId: 'a' }),
|
||||||
|
fromLot({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sheet.rows).toHaveLength(1);
|
||||||
|
expect(sheet.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
|
||||||
|
expect(sheet.rows[0]).toMatchObject({
|
||||||
|
lotId: 'lot-1',
|
||||||
|
supplier: 'Apotheke Y, Hauptstr. 1',
|
||||||
|
batchNumber: 'LOT-B',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps colonies of two different lots in two rows', () => {
|
||||||
|
const [sheet] = groupTreatmentLog(
|
||||||
|
[
|
||||||
|
fromLot({ id: '1', apiaryId: 'a' }),
|
||||||
|
fromLot({
|
||||||
|
id: '2',
|
||||||
|
colonyId: 'c2',
|
||||||
|
apiaryId: 'a',
|
||||||
|
lot: lotOf({ id: 'lot-2' }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sheet.rows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints FEHLT exactly where treatmentLogGaps reports a gap', () => {
|
||||||
|
const variants: Partial<TreatmentLogSource>[] = [
|
||||||
|
{},
|
||||||
|
{ lot: lotOf({ supplier: null }) },
|
||||||
|
{ lot: lotOf({ purchaseProof: '' }) },
|
||||||
|
{ amount: null },
|
||||||
|
{ withdrawalPeriodDays: null },
|
||||||
|
{ prescriptionOnly: true, lot: lotOf({ prescribingVet: null }) },
|
||||||
|
{ prescriptionOnly: true },
|
||||||
|
{
|
||||||
|
lot: lotOf({ supplier: null, purchaseProof: null }),
|
||||||
|
amount: null,
|
||||||
|
withdrawalPeriodDays: null,
|
||||||
|
prescriptionOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const overrides of variants) {
|
||||||
|
const [sheet] = groupTreatmentLog(
|
||||||
|
[fromLot({ apiaryId: 'a', ...overrides })],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const row = sheet.rows[0];
|
||||||
|
const gaps = treatmentLogGaps(row);
|
||||||
|
const cells = treatmentLogCells(row);
|
||||||
|
const [, , supplierCell, proofCell, , amountCell, withdrawalCell] = cells;
|
||||||
|
|
||||||
|
expect(supplierCell.startsWith(MISSING)).toBe(gaps.includes('SUPPLIER'));
|
||||||
|
expect(supplierCell.includes(`Tierarzt: ${MISSING}`)).toBe(
|
||||||
|
gaps.includes('VET'),
|
||||||
|
);
|
||||||
|
expect(proofCell === MISSING).toBe(gaps.includes('PROOF'));
|
||||||
|
expect(amountCell === MISSING).toBe(gaps.includes('AMOUNT'));
|
||||||
|
expect(withdrawalCell === MISSING).toBe(gaps.includes('WITHDRAWAL'));
|
||||||
|
// No FEHLT anywhere else.
|
||||||
|
expect(cells.join('|').split(MISSING).length - 1).toBe(gaps.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('footnote says supplier, proof and batch come from the recorded lot', () => {
|
||||||
|
expect(LOT_FOOTNOTE).toBe(
|
||||||
|
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
|
import type { MedicineUnit } from '../../generated/prisma';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
PDF_MARGIN,
|
PDF_MARGIN,
|
||||||
@@ -24,6 +25,15 @@ import {
|
|||||||
|
|
||||||
export const MISSING = 'FEHLT';
|
export const MISSING = 'FEHLT';
|
||||||
|
|
||||||
|
export const LOT_FOOTNOTE =
|
||||||
|
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.';
|
||||||
|
|
||||||
|
const UNIT_LABELS: Record<MedicineUnit, string> = {
|
||||||
|
ML: 'ml',
|
||||||
|
G: 'g',
|
||||||
|
PIECE: 'Stück',
|
||||||
|
};
|
||||||
|
|
||||||
export interface TreatmentLogSource {
|
export interface TreatmentLogSource {
|
||||||
id: string;
|
id: string;
|
||||||
colonyId: string;
|
colonyId: string;
|
||||||
@@ -42,6 +52,19 @@ export interface TreatmentLogSource {
|
|||||||
prescribingVet: string | null;
|
prescribingVet: string | null;
|
||||||
withdrawalPeriodDays: number | null;
|
withdrawalPeriodDays: number | null;
|
||||||
administeredBy: string;
|
administeredBy: string;
|
||||||
|
medicineId: string | null;
|
||||||
|
/** Of the catalogue entry; the vet is only mandatory for these. */
|
||||||
|
prescriptionOnly: boolean;
|
||||||
|
doseQuantity: number | null;
|
||||||
|
medicineUnit: MedicineUnit | null;
|
||||||
|
/** The pack the dose was taken from: its values replace the treatment's. */
|
||||||
|
lot: {
|
||||||
|
id: string;
|
||||||
|
batchNumber: string | null;
|
||||||
|
supplier: string | null;
|
||||||
|
purchaseProof: string | null;
|
||||||
|
prescribingVet: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColonyMove {
|
export interface ColonyMove {
|
||||||
@@ -65,6 +88,76 @@ export interface TreatmentLogRow {
|
|||||||
administeredBy: string;
|
administeredBy: string;
|
||||||
/** Apiary derived from today's assignment, not known for that date. */
|
/** Apiary derived from today's assignment, not known for that date. */
|
||||||
locationUncertain: boolean;
|
locationUncertain: boolean;
|
||||||
|
lotId: string | null;
|
||||||
|
prescriptionOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreatmentLogGap =
|
||||||
|
'SUPPLIER' | 'PROOF' | 'AMOUNT' | 'WITHDRAWAL' | 'VET';
|
||||||
|
|
||||||
|
function hasText(value: string | null | undefined): value is string {
|
||||||
|
return value !== null && value !== undefined && value.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function orNull(value: string | null | undefined): string | null {
|
||||||
|
return hasText(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doseText(dose: number, unit: MedicineUnit): string {
|
||||||
|
return `${String(dose).replace('.', ',')} ${UNIT_LABELS[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns of one treatment, each from exactly one source: the pack when
|
||||||
|
* the dose was taken from one (supplier, proof, batch number and vet are never
|
||||||
|
* mixed with text on the treatment), else the treatment itself. The amount is
|
||||||
|
* the treatment's own text, else its dose with the unit of the medicine.
|
||||||
|
*/
|
||||||
|
export function resolveSourceValues(
|
||||||
|
source: TreatmentLogSource,
|
||||||
|
): Pick<
|
||||||
|
TreatmentLogSource,
|
||||||
|
'batchNumber' | 'supplier' | 'purchaseProof' | 'prescribingVet'
|
||||||
|
> & { amount: string | null } {
|
||||||
|
const origin = source.lot ?? source;
|
||||||
|
const amount = hasText(source.amount)
|
||||||
|
? source.amount
|
||||||
|
: source.doseQuantity !== null && source.medicineUnit !== null
|
||||||
|
? doseText(source.doseQuantity, source.medicineUnit)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
batchNumber: orNull(origin.batchNumber),
|
||||||
|
supplier: orNull(origin.supplier),
|
||||||
|
purchaseProof: orNull(origin.purchaseProof),
|
||||||
|
prescribingVet: orNull(origin.prescribingVet),
|
||||||
|
amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mandatory fields a row lacks: supplier, proof, amount and withdrawal
|
||||||
|
* period (0 days counts as given), and the vet only for prescription-only
|
||||||
|
* medicines. The PDF prints FEHLT exactly where this reports a gap, and the
|
||||||
|
* readiness check reads the same list.
|
||||||
|
*/
|
||||||
|
export function treatmentLogGaps(
|
||||||
|
row: Pick<
|
||||||
|
TreatmentLogRow,
|
||||||
|
| 'supplier'
|
||||||
|
| 'purchaseProof'
|
||||||
|
| 'amount'
|
||||||
|
| 'withdrawalPeriodDays'
|
||||||
|
| 'prescribingVet'
|
||||||
|
| 'prescriptionOnly'
|
||||||
|
>,
|
||||||
|
): TreatmentLogGap[] {
|
||||||
|
const gaps: TreatmentLogGap[] = [];
|
||||||
|
if (!hasText(row.supplier)) gaps.push('SUPPLIER');
|
||||||
|
if (!hasText(row.purchaseProof)) gaps.push('PROOF');
|
||||||
|
if (!hasText(row.amount)) gaps.push('AMOUNT');
|
||||||
|
if (row.withdrawalPeriodDays === null) gaps.push('WITHDRAWAL');
|
||||||
|
if (row.prescriptionOnly && !hasText(row.prescribingVet)) gaps.push('VET');
|
||||||
|
return gaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreatmentLogSheet {
|
export interface TreatmentLogSheet {
|
||||||
@@ -120,17 +213,20 @@ export function groupTreatmentLog(
|
|||||||
for (const source of sorted) {
|
for (const source of sorted) {
|
||||||
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
|
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
|
||||||
const start = source.startDate ?? source.date;
|
const start = source.startDate ?? source.date;
|
||||||
|
const values = resolveSourceValues(source);
|
||||||
const key = [
|
const key = [
|
||||||
source.date.toISOString(),
|
source.date.toISOString(),
|
||||||
source.endDate?.toISOString() ?? '',
|
source.endDate?.toISOString() ?? '',
|
||||||
source.product,
|
source.product,
|
||||||
source.batchNumber ?? '',
|
values.batchNumber ?? '',
|
||||||
source.amount ?? '',
|
values.amount ?? '',
|
||||||
source.supplier ?? '',
|
values.supplier ?? '',
|
||||||
source.purchaseProof ?? '',
|
values.purchaseProof ?? '',
|
||||||
source.prescribingVet ?? '',
|
values.prescribingVet ?? '',
|
||||||
source.withdrawalPeriodDays ?? '',
|
source.withdrawalPeriodDays ?? '',
|
||||||
source.administeredBy,
|
source.administeredBy,
|
||||||
|
source.lot?.id ?? '',
|
||||||
|
source.prescriptionOnly,
|
||||||
uncertain,
|
uncertain,
|
||||||
].join('|');
|
].join('|');
|
||||||
|
|
||||||
@@ -142,17 +238,19 @@ export function groupTreatmentLog(
|
|||||||
rows.set(key, {
|
rows.set(key, {
|
||||||
colonyCodes: [source.colonyCode],
|
colonyCodes: [source.colonyCode],
|
||||||
product: source.product,
|
product: source.product,
|
||||||
batchNumber: source.batchNumber,
|
batchNumber: values.batchNumber,
|
||||||
supplier: source.supplier,
|
supplier: values.supplier,
|
||||||
prescribingVet: source.prescribingVet,
|
prescribingVet: values.prescribingVet,
|
||||||
purchaseProof: source.purchaseProof,
|
purchaseProof: values.purchaseProof,
|
||||||
date: source.date,
|
date: source.date,
|
||||||
endDate: source.endDate,
|
endDate: source.endDate,
|
||||||
durationDays: durationDays(start, source.endDate),
|
durationDays: durationDays(start, source.endDate),
|
||||||
amount: source.amount,
|
amount: values.amount,
|
||||||
withdrawalPeriodDays: source.withdrawalPeriodDays,
|
withdrawalPeriodDays: source.withdrawalPeriodDays,
|
||||||
administeredBy: source.administeredBy,
|
administeredBy: source.administeredBy,
|
||||||
locationUncertain: uncertain,
|
locationUncertain: uncertain,
|
||||||
|
lotId: source.lot?.id ?? null,
|
||||||
|
prescriptionOnly: source.prescriptionOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sheets.set(apiaryId, rows);
|
sheets.set(apiaryId, rows);
|
||||||
@@ -194,6 +292,16 @@ export async function buildTreatmentLog(
|
|||||||
include: {
|
include: {
|
||||||
colony: { select: { code: true, apiaryId: true } },
|
colony: { select: { code: true, apiaryId: true } },
|
||||||
user: { select: { name: true } },
|
user: { select: { name: true } },
|
||||||
|
medicine: { select: { prescriptionOnly: true, unit: true } },
|
||||||
|
medicineLot: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
batchNumber: true,
|
||||||
|
supplier: true,
|
||||||
|
purchaseProof: true,
|
||||||
|
prescribingVet: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
|
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
|
||||||
@@ -226,6 +334,11 @@ export async function buildTreatmentLog(
|
|||||||
prescribingVet: t.prescribingVet,
|
prescribingVet: t.prescribingVet,
|
||||||
withdrawalPeriodDays: t.withdrawalPeriodDays,
|
withdrawalPeriodDays: t.withdrawalPeriodDays,
|
||||||
administeredBy: t.user.name,
|
administeredBy: t.user.name,
|
||||||
|
medicineId: t.medicineId,
|
||||||
|
prescriptionOnly: t.medicine?.prescriptionOnly ?? false,
|
||||||
|
doseQuantity: t.doseQuantity,
|
||||||
|
medicineUnit: t.medicine?.unit ?? null,
|
||||||
|
lot: t.medicineLot,
|
||||||
})),
|
})),
|
||||||
moves,
|
moves,
|
||||||
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
|
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
|
||||||
@@ -255,6 +368,30 @@ function orMissing(value: string | number | null | undefined): string {
|
|||||||
: String(value);
|
: String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The printed cells of one row. FEHLT stands exactly where treatmentLogGaps
|
||||||
|
* reports a gap - the supplier column also carries the vet, with FEHLT after
|
||||||
|
* "Tierarzt:" when one is required and missing.
|
||||||
|
*/
|
||||||
|
export function treatmentLogCells(row: TreatmentLogRow): string[] {
|
||||||
|
const gaps = treatmentLogGaps(row);
|
||||||
|
const vetPart = row.prescribingVet
|
||||||
|
? `; Tierarzt: ${row.prescribingVet}`
|
||||||
|
: gaps.includes('VET')
|
||||||
|
? `; Tierarzt: ${MISSING}`
|
||||||
|
: '';
|
||||||
|
return [
|
||||||
|
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
|
||||||
|
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
|
||||||
|
`${gaps.includes('SUPPLIER') ? MISSING : row.supplier}${vetPart}`,
|
||||||
|
gaps.includes('PROOF') ? MISSING : (row.purchaseProof ?? MISSING),
|
||||||
|
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
|
||||||
|
gaps.includes('AMOUNT') ? MISSING : (row.amount ?? MISSING),
|
||||||
|
gaps.includes('WITHDRAWAL') ? MISSING : orMissing(row.withdrawalPeriodDays),
|
||||||
|
orDash(row.administeredBy),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function renderSheet(
|
function renderSheet(
|
||||||
doc: PDFKit.PDFDocument,
|
doc: PDFKit.PDFDocument,
|
||||||
log: TreatmentLog,
|
log: TreatmentLog,
|
||||||
@@ -305,16 +442,7 @@ function renderSheet(
|
|||||||
{ header: 'Wartezeit (Tage)', width: 50 },
|
{ header: 'Wartezeit (Tage)', width: 50 },
|
||||||
{ header: 'Anwendende Person', width: 0 },
|
{ header: 'Anwendende Person', width: 0 },
|
||||||
]),
|
]),
|
||||||
sheet.rows.map((row) => [
|
sheet.rows.map(treatmentLogCells),
|
||||||
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
|
|
||||||
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
|
|
||||||
`${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
|
|
||||||
orMissing(row.purchaseProof),
|
|
||||||
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
|
|
||||||
orMissing(row.amount),
|
|
||||||
orMissing(row.withdrawalPeriodDays),
|
|
||||||
orDash(row.administeredBy),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
doc.font('Helvetica').fontSize(7);
|
doc.font('Helvetica').fontSize(7);
|
||||||
if (uncertain) {
|
if (uncertain) {
|
||||||
@@ -324,7 +452,7 @@ function renderSheet(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
doc.text(
|
doc.text(
|
||||||
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
|
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung oder in der Packung nachtragen. ${LOT_FOOTNOTE} Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
|
||||||
PDF_MARGIN,
|
PDF_MARGIN,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ describe('ImportService', () => {
|
|||||||
queen: { create: jest.Mock; findUnique: jest.Mock };
|
queen: { create: jest.Mock; findUnique: jest.Mock };
|
||||||
$transaction: jest.Mock;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let tx: { colony: { create: jest.Mock } };
|
let tx: {
|
||||||
|
colony: { create: jest.Mock };
|
||||||
|
$queryRaw: jest.Mock;
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
apiaryRestriction: { findMany: jest.Mock };
|
||||||
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let events: { record: jest.Mock };
|
let events: { record: jest.Mock };
|
||||||
|
|
||||||
@@ -25,7 +30,16 @@ describe('ImportService', () => {
|
|||||||
};
|
};
|
||||||
// The colony row is written through the transaction client; it shares the
|
// The colony row is written through the transaction client; it shares the
|
||||||
// mock with prisma.colony so the row tests read the same calls.
|
// mock with prisma.colony so the row tests read the same calls.
|
||||||
tx = { colony: { create: prisma.colony.create } };
|
tx = {
|
||||||
|
colony: { create: prisma.colony.create },
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
apiary: {
|
||||||
|
findUnique: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'apiary-1', status: 'ACTIVE' }),
|
||||||
|
},
|
||||||
|
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
prisma.$transaction.mockImplementation(
|
prisma.$transaction.mockImplementation(
|
||||||
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
||||||
);
|
);
|
||||||
@@ -243,6 +257,84 @@ describe('ImportService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('locks the apiary (share) and checks activity and the restricted zone before it writes', async () => {
|
||||||
|
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.colony.create.mockResolvedValue({
|
||||||
|
id: 'c1',
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.importCsv(
|
||||||
|
'colonies',
|
||||||
|
Buffer.from(
|
||||||
|
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
|
||||||
|
),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
|
||||||
|
expect(query.sql).toContain('"Apiary"');
|
||||||
|
expect(query.sql).toContain('FOR SHARE');
|
||||||
|
expect(tx.apiaryRestriction.findMany).toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||||
|
).toBeLessThan(prisma.colony.create.mock.invocationCallOrder[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a row in a restricted apiary as an error and writes nothing for it', async () => {
|
||||||
|
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
|
tx.apiaryRestriction.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
apiary: { name: 'Teststand' },
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: new Date('2000-01-01T00:00:00.000Z'),
|
||||||
|
endsOn: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const report = await service.importCsv(
|
||||||
|
'colonies',
|
||||||
|
Buffer.from(
|
||||||
|
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
|
||||||
|
),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.errors).toBe(1);
|
||||||
|
expect(report.rows[0].status).toBe('error');
|
||||||
|
expect(report.rows[0].message).toContain('Sperrbezirk');
|
||||||
|
expect(prisma.colony.create).not.toHaveBeenCalled();
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ entityType: 'Colony' }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a row in an apiary deactivated meanwhile as an error', async () => {
|
||||||
|
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
|
tx.apiary.findUnique.mockResolvedValue({
|
||||||
|
id: 'apiary-1',
|
||||||
|
status: 'INACTIVE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await service.importCsv(
|
||||||
|
'colonies',
|
||||||
|
Buffer.from(
|
||||||
|
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
|
||||||
|
),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.rows[0].status).toBe('error');
|
||||||
|
expect(prisma.colony.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('records no event for a skipped or invalid row', async () => {
|
it('records no event for a skipped or invalid row', async () => {
|
||||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||||
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
|
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate, ValidationError } from 'class-validator';
|
import { validate, ValidationError } from 'class-validator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -12,6 +16,9 @@ import {
|
|||||||
appendLegacyFeedNote,
|
appendLegacyFeedNote,
|
||||||
feedConditionFromInput,
|
feedConditionFromInput,
|
||||||
} from '../common/feed-vocabulary';
|
} from '../common/feed-vocabulary';
|
||||||
|
import { berlinToday } from '../common/dates';
|
||||||
|
import { assertNoActiveRestriction } from '../compliance/restricted-zone';
|
||||||
|
import { lockRowsForShare } from '../prisma/row-lock';
|
||||||
import { parseCsv } from './csv';
|
import { parseCsv } from './csv';
|
||||||
|
|
||||||
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
|
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
|
||||||
@@ -216,8 +223,26 @@ export class ImportService {
|
|||||||
id: existing.id,
|
id: existing.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Like ColoniesService.create: record, audit and event in one transaction.
|
// Like ColoniesService.create: apiary rows locked (share), activity and
|
||||||
const colony = await this.prisma.$transaction(async (tx) => {
|
// restricted zone checked under the lock - an import has no override -,
|
||||||
|
// then record, audit and event in one transaction.
|
||||||
|
const outcome = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockRowsForShare(tx, 'Apiary', [apiary.id]);
|
||||||
|
const locked = await tx.apiary.findUnique({ where: { id: apiary.id } });
|
||||||
|
if (locked?.status !== 'ACTIVE') {
|
||||||
|
return {
|
||||||
|
error: `Bienenstand "${apiaryName}" ist nicht aktiv und kann keine Völker aufnehmen`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await assertNoActiveRestriction(tx, [apiary.id], berlinToday());
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConflictException) {
|
||||||
|
const body = error.getResponse() as { message?: string };
|
||||||
|
return { error: body.message ?? error.message };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const created = await tx.colony.create({ data: { ...dto } });
|
const created = await tx.colony.create({ data: { ...dto } });
|
||||||
await this.auditLog.record(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
@@ -230,8 +255,12 @@ export class ImportService {
|
|||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||||
return created;
|
return { colony: created };
|
||||||
});
|
});
|
||||||
|
if ('error' in outcome) {
|
||||||
|
return { row: rowNumber, status: 'error', message: outcome.error };
|
||||||
|
}
|
||||||
|
const { colony } = outcome;
|
||||||
return {
|
return {
|
||||||
row: rowNumber,
|
row: rowNumber,
|
||||||
status: 'created',
|
status: 'created',
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
Equals,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { PLACEHOLDER_KEYS } from '../label-element.types';
|
||||||
|
|
||||||
|
const FONT_FAMILIES = ['sans', 'serif', 'mono'] as const;
|
||||||
|
const TEXT_ALIGNS = ['left', 'center', 'right'] as const;
|
||||||
|
const MAX_ELEMENTS = 100;
|
||||||
|
const MAX_FONT_SIZE_PT = 500;
|
||||||
|
// A label is a few centimetres; the bounds only keep absurd numbers out.
|
||||||
|
const MAX_COORDINATE_MM = 1000;
|
||||||
|
const MIN_BOX_MM = 0.1;
|
||||||
|
const MAX_SHEET_MM = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text element of a label draft. The check only looks at text, so every
|
||||||
|
* other element type is refused here instead of being ignored: a draft that
|
||||||
|
* sends an image is a client bug worth a 400.
|
||||||
|
*/
|
||||||
|
export class LabelTextElementDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['text'] })
|
||||||
|
@Equals('text')
|
||||||
|
type!: 'text';
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-MAX_COORDINATE_MM)
|
||||||
|
@Max(MAX_COORDINATE_MM)
|
||||||
|
x!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-MAX_COORDINATE_MM)
|
||||||
|
@Max(MAX_COORDINATE_MM)
|
||||||
|
y!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(MIN_BOX_MM)
|
||||||
|
@Max(MAX_SHEET_MM)
|
||||||
|
width!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(MIN_BOX_MM)
|
||||||
|
@Max(MAX_SHEET_MM)
|
||||||
|
height!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Literaler Text' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
text?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: PLACEHOLDER_KEYS })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(PLACEHOLDER_KEYS)
|
||||||
|
placeholderKey?: (typeof PLACEHOLDER_KEYS)[number] | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Schriftgröße in pt' })
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
@Max(MAX_FONT_SIZE_PT)
|
||||||
|
fontSize!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: FONT_FAMILIES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(FONT_FAMILIES)
|
||||||
|
fontFamily?: (typeof FONT_FAMILIES)[number];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
bold?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
italic?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: TEXT_ALIGNS })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TEXT_ALIGNS)
|
||||||
|
align?: (typeof TEXT_ALIGNS)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /label-templates/check: a draft that is not saved yet. */
|
||||||
|
export class CheckLabelTemplateDto {
|
||||||
|
@ApiProperty({ type: [LabelTextElementDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(MAX_ELEMENTS)
|
||||||
|
@ArrayUnique((element: LabelTextElementDto) => element.id)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => LabelTextElementDto)
|
||||||
|
elements!: LabelTextElementDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Wird geprüft, fließt aber nicht in die Prüfung ein.',
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
@Min(10)
|
||||||
|
@Max(MAX_SHEET_MM)
|
||||||
|
widthMm!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(10)
|
||||||
|
@Max(MAX_SHEET_MM)
|
||||||
|
heightMm!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Kleinste x-Höhe (mm) des Entwurfs, 0,5 bis 5.',
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.5)
|
||||||
|
@Max(5)
|
||||||
|
minXHeightMm!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Abfüllung, gegen die geprüft wird. Leer = Beispielwerte.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/)
|
||||||
|
bottlingBatchId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { ConflictException, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
expectedParticulars,
|
||||||
|
loadDigitRules,
|
||||||
|
parseDigitRules,
|
||||||
|
} from './label-check.loader';
|
||||||
|
|
||||||
|
describe('expectedParticulars', () => {
|
||||||
|
it('maps a bottling batch to the expected particulars (jar grams, best-before day, lot, origin, master data)', () => {
|
||||||
|
const expected = expectedParticulars(
|
||||||
|
{
|
||||||
|
jarSize: 500,
|
||||||
|
lotNumber: '2026-0042',
|
||||||
|
// Midnight UTC: stays on its own calendar day.
|
||||||
|
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originCountry: 'Deutschland',
|
||||||
|
foodBusinessName: 'Imkerei Muster',
|
||||||
|
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(expected).toEqual({
|
||||||
|
jarSizeGrams: 500,
|
||||||
|
bestBefore: '2028-06-01',
|
||||||
|
lotNumber: '2026-0042',
|
||||||
|
originCountry: 'Deutschland',
|
||||||
|
foodBusinessName: 'Imkerei Muster',
|
||||||
|
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the best-before day from labelDayOf, an instant maps to its Berlin day', () => {
|
||||||
|
const expected = expectedParticulars(
|
||||||
|
{
|
||||||
|
jarSize: 250,
|
||||||
|
lotNumber: 'L1',
|
||||||
|
bestBeforeDate: new Date('2028-06-01T22:30:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originCountry: 'Deutschland',
|
||||||
|
foodBusinessName: null,
|
||||||
|
foodBusinessAddress: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(expected.bestBefore).toBe('2028-06-02');
|
||||||
|
expect(expected.foodBusinessName).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseDigitRules', () => {
|
||||||
|
const open = { maxGrams: null, minMm: 5 };
|
||||||
|
|
||||||
|
it('reads the stored rules', () => {
|
||||||
|
const rules = [{ maxGrams: 100, minMm: 2 }, open];
|
||||||
|
expect(parseDigitRules(rules)).toEqual(rules);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['null', null],
|
||||||
|
['undefined', undefined],
|
||||||
|
['a string', 'x'],
|
||||||
|
['an object', {}],
|
||||||
|
['an empty array', []],
|
||||||
|
['a non-numeric limit', [{ maxGrams: 'a', minMm: 2 }, open]],
|
||||||
|
['an empty rule', [{}]],
|
||||||
|
['minMm zero', [{ maxGrams: 100, minMm: 0 }, open]],
|
||||||
|
['minMm negative', [{ maxGrams: 100, minMm: -1 }, open]],
|
||||||
|
['minMm not finite', [{ maxGrams: 100, minMm: Infinity }, open]],
|
||||||
|
['maxGrams not finite', [{ maxGrams: Infinity, minMm: 2 }, open]],
|
||||||
|
['no rule without a limit', [{ maxGrams: 100, minMm: 2 }]],
|
||||||
|
['two rules without a limit', [open, { maxGrams: null, minMm: 6 }]],
|
||||||
|
])('rejects %s', (_name, value) => {
|
||||||
|
expect(parseDigitRules(value)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadDigitRules', () => {
|
||||||
|
let errorLog: jest.SpyInstance;
|
||||||
|
const dbWith = (row: unknown) => ({
|
||||||
|
complianceSettings: { findUnique: jest.fn().mockResolvedValue(row) },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
errorLog = jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
async function refusal(row: unknown): Promise<ConflictException> {
|
||||||
|
try {
|
||||||
|
await loadDigitRules(dbWith(row) as never);
|
||||||
|
} catch (error) {
|
||||||
|
return error as ConflictException;
|
||||||
|
}
|
||||||
|
throw new Error('expected a refusal');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fails closed with 409 when the settings row is missing', async () => {
|
||||||
|
const error = await refusal(null);
|
||||||
|
expect(error).toBeInstanceOf(ConflictException);
|
||||||
|
expect(error.getResponse()).toEqual({
|
||||||
|
code: 'COMPLIANCE_SETTINGS_INVALID',
|
||||||
|
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
|
||||||
|
});
|
||||||
|
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed with 409 when the stored rules are invalid', async () => {
|
||||||
|
const error = await refusal({ netQuantityDigitRules: [{ minMm: 0 }] });
|
||||||
|
expect(error.getStatus()).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the rules of the settings row', async () => {
|
||||||
|
const rules = [{ maxGrams: null, minMm: 7 }];
|
||||||
|
const db = dbWith({ netQuantityDigitRules: rules });
|
||||||
|
await expect(loadDigitRules(db as never)).resolves.toEqual(rules);
|
||||||
|
expect(db.complianceSettings.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
select: { netQuantityDigitRules: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ConflictException, Logger } from '@nestjs/common';
|
||||||
|
import { loggableError } from '../common/loggable-error';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
|
import { LabelCheckInput, NetQuantityDigitRule } from './label-particulars';
|
||||||
|
import { labelDayOf } from './label-text';
|
||||||
|
|
||||||
|
const COMPLIANCE_SETTINGS_ID = 'singleton';
|
||||||
|
export const COMPLIANCE_SETTINGS_INVALID = 'COMPLIANCE_SETTINGS_INVALID';
|
||||||
|
const logger = new Logger('LabelCheckLoader');
|
||||||
|
|
||||||
|
export interface BottlingForCheck {
|
||||||
|
jarSize: number;
|
||||||
|
lotNumber: string;
|
||||||
|
bestBeforeDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterDataForCheck {
|
||||||
|
originCountry: string;
|
||||||
|
foodBusinessName: string | null;
|
||||||
|
foodBusinessAddress: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the label must show for this bottling. The best-before day comes from
|
||||||
|
* `labelDayOf`, the function the printed text uses too, so check and print
|
||||||
|
* cannot disagree about the day.
|
||||||
|
*/
|
||||||
|
export function expectedParticulars(
|
||||||
|
bottling: BottlingForCheck,
|
||||||
|
settings: MasterDataForCheck,
|
||||||
|
): LabelCheckInput['expected'] {
|
||||||
|
return {
|
||||||
|
jarSizeGrams: bottling.jarSize,
|
||||||
|
bestBefore: labelDayOf(bottling.bestBeforeDate),
|
||||||
|
lotNumber: bottling.lotNumber,
|
||||||
|
originCountry: settings.originCountry,
|
||||||
|
foodBusinessName: settings.foodBusinessName,
|
||||||
|
foodBusinessAddress: settings.foodBusinessAddress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDigitRule(value: unknown): value is NetQuantityDigitRule {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { maxGrams, minMm } = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
(maxGrams === null ||
|
||||||
|
(typeof maxGrams === 'number' && Number.isFinite(maxGrams))) &&
|
||||||
|
typeof minMm === 'number' &&
|
||||||
|
Number.isFinite(minMm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored JSON as rules, or null when it cannot be trusted: a rule needs
|
||||||
|
* a finite positive height, the list must not be empty, and exactly one rule
|
||||||
|
* (the catch-all for any size) has no upper limit.
|
||||||
|
*/
|
||||||
|
export function parseDigitRules(value: unknown): NetQuantityDigitRule[] | null {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length === 0 ||
|
||||||
|
!value.every(isDigitRule)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rules = value.map(({ maxGrams, minMm }) => ({ maxGrams, minMm }));
|
||||||
|
const open = rules.filter((rule) => rule.maxGrams === null).length;
|
||||||
|
return open === 1 && rules.every((rule) => rule.minMm > 0) ? rules : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The digit rules of the compliance settings. No legal value lives in the
|
||||||
|
* code: a missing row or unusable rules refuse the check (409) instead of
|
||||||
|
* checking against a guess.
|
||||||
|
*/
|
||||||
|
export async function loadDigitRules(
|
||||||
|
db: DbClient,
|
||||||
|
): Promise<NetQuantityDigitRule[]> {
|
||||||
|
const row = await db.complianceSettings.findUnique({
|
||||||
|
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||||
|
select: { netQuantityDigitRules: true },
|
||||||
|
});
|
||||||
|
const rules = parseDigitRules(row?.netQuantityDigitRules);
|
||||||
|
if (!rules) {
|
||||||
|
const { message } = loggableError(
|
||||||
|
new Error(
|
||||||
|
row
|
||||||
|
? 'Invalid netQuantityDigitRules'
|
||||||
|
: 'ComplianceSettings row missing',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
logger.error(message);
|
||||||
|
throw new ConflictException({
|
||||||
|
code: COMPLIANCE_SETTINGS_INVALID,
|
||||||
|
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ const FORBIDDEN = new RegExp(
|
|||||||
'toLocaleTimeString\\(',
|
'toLocaleTimeString\\(',
|
||||||
'toLocaleDateString\\((?![^)]*timeZone)',
|
'toLocaleDateString\\((?![^)]*timeZone)',
|
||||||
'toLocaleString\\((?![^)]*timeZone)',
|
'toLocaleString\\((?![^)]*timeZone)',
|
||||||
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
'Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
||||||
// new Date(y, m, d, ...): several arguments build a local-time date
|
// new Date(y, m, d, ...): several arguments build a local-time date
|
||||||
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
|
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
|
||||||
].join('|'),
|
].join('|'),
|
||||||
@@ -86,6 +86,7 @@ describe('local-time guard', () => {
|
|||||||
'date.toDateString()',
|
'date.toDateString()',
|
||||||
"date.toLocaleDateString('de-DE')",
|
"date.toLocaleDateString('de-DE')",
|
||||||
"new Intl.DateTimeFormat('de-DE')",
|
"new Intl.DateTimeFormat('de-DE')",
|
||||||
|
"Intl.DateTimeFormat('de-DE')",
|
||||||
'new Date(2027, 2, 1)',
|
'new Date(2027, 2, 1)',
|
||||||
])('flags %s', (sample) => {
|
])('flags %s', (sample) => {
|
||||||
expect(sample).toMatch(FORBIDDEN);
|
expect(sample).toMatch(FORBIDDEN);
|
||||||
|
|||||||
@@ -735,23 +735,57 @@ describe('checkLabel: round 3', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('measures the distance against the whole group, not the last element', () => {
|
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
|
// A is a tall box, B a short one below its top, C lies 24 mm below B but
|
||||||
// empty box alone would be 24 mm from the town, the street box is not
|
// inside A's box: only the running extent of the group joins C
|
||||||
const elements = [
|
const elements = [
|
||||||
...base(),
|
...base(),
|
||||||
textElement('street', null, 62, {
|
textElement('a', null, 62, { text: 'Bühlweg', height: 40 }),
|
||||||
text: 'Bühlweg 10',
|
textElement('b', null, 64, { x: 30, text: '10', height: 2 }),
|
||||||
height: 40,
|
textElement('c', null, 90, { text: '86450 Altenmünster', height: 5 }),
|
||||||
}),
|
|
||||||
textElement('empty', null, 64, { text: '', height: 2 }),
|
|
||||||
textElement('town', null, 90, {
|
|
||||||
text: '86450 Altenmünster',
|
|
||||||
height: 5,
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('an empty box does not bridge distances', () => {
|
||||||
|
const elements = [
|
||||||
|
...base(),
|
||||||
|
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
|
||||||
|
textElement('empty', null, 66, { text: '', height: 40 }),
|
||||||
|
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
|
||||||
|
];
|
||||||
|
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||||
|
'FOOD_BUSINESS',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tall text box bridges rows by its box height (documented)', () => {
|
||||||
|
const street = (height: number) => [
|
||||||
|
...base(),
|
||||||
|
textElement('street', null, 62, { text: 'Bühlweg 10', height }),
|
||||||
|
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
|
||||||
|
];
|
||||||
|
expect(checkLabel(input({ elements: street(40) })).gaps).toEqual([]);
|
||||||
|
expect(codes(checkLabel(input({ elements: street(5) })).gaps)).toEqual([
|
||||||
|
'FOOD_BUSINESS',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names only clipped parts that belong to the match', () => {
|
||||||
|
const measurer: TextMeasurer = {
|
||||||
|
heightOfStringPt: (_text, element) =>
|
||||||
|
element.id === 'town' || element.id === 'lot-ref' ? 999 : 0,
|
||||||
|
};
|
||||||
|
const elements = [
|
||||||
|
...base(),
|
||||||
|
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
|
||||||
|
textElement('town', null, 68, { text: '86450 Altenmünster', height: 5 }),
|
||||||
|
textElement('lot-ref', null, 90, { text: 'Lot 10', height: 5 }),
|
||||||
|
];
|
||||||
|
const result = checkLabel(input({ elements, measurer }));
|
||||||
|
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||||
|
expect(result.gaps[0].elementIds).toEqual(['town']);
|
||||||
|
});
|
||||||
|
|
||||||
describe('number and unit in two elements', () => {
|
describe('number and unit in two elements', () => {
|
||||||
function split(numberSize: number, unitSize: number): LabelTextElement[] {
|
function split(numberSize: number, unitSize: number): LabelTextElement[] {
|
||||||
return [
|
return [
|
||||||
@@ -792,6 +826,38 @@ describe('checkLabel: round 3', () => {
|
|||||||
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
|
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('finds a big number with a small, bottom-aligned unit', () => {
|
||||||
|
const elements = [
|
||||||
|
...without('net'),
|
||||||
|
textElement('num', null, 14, {
|
||||||
|
x: 2,
|
||||||
|
width: 20,
|
||||||
|
height: 10,
|
||||||
|
fontSize: 20,
|
||||||
|
text: '500',
|
||||||
|
}),
|
||||||
|
textElement('unit', null, 20, {
|
||||||
|
x: 24,
|
||||||
|
width: 10,
|
||||||
|
height: 4,
|
||||||
|
fontSize: 10,
|
||||||
|
text: 'g',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pair a far left number with a far right unit', () => {
|
||||||
|
const elements = [
|
||||||
|
...without('net'),
|
||||||
|
textElement('num', null, 14, { x: 2, width: 20, text: '500' }),
|
||||||
|
textElement('unit', null, 14, { x: 70, width: 10, text: 'g' }),
|
||||||
|
];
|
||||||
|
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||||
|
'NET_QUANTITY',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not join across rows or onto a bigger number', () => {
|
it('does not join across rows or onto a bigger number', () => {
|
||||||
const otherRow = [
|
const otherRow = [
|
||||||
...without('net'),
|
...without('net'),
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ function distanceTo(span: Span, candidate: Candidate): number {
|
|||||||
function groupByDistance(list: Candidate[]): Candidate[][] {
|
function groupByDistance(list: Candidate[]): Candidate[][] {
|
||||||
const groups: Candidate[][] = [];
|
const groups: Candidate[][] = [];
|
||||||
let span: Span = { top: 0, bottom: 0 };
|
let span: Span = { top: 0, bottom: 0 };
|
||||||
for (const candidate of list) {
|
// An empty box (a placeholder without a value) must not bridge distances.
|
||||||
|
for (const candidate of list.filter((entry) => entry.normalized !== '')) {
|
||||||
const group = groups[groups.length - 1];
|
const group = groups[groups.length - 1];
|
||||||
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
|
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
|
||||||
group.push(candidate);
|
group.push(candidate);
|
||||||
@@ -295,9 +296,20 @@ function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
|
|||||||
const pairs: Candidate[][] = [];
|
const pairs: Candidate[][] = [];
|
||||||
views.visible.forEach((first, index) => {
|
views.visible.forEach((first, index) => {
|
||||||
const second = views.visible[index + 1];
|
const second = views.visible[index + 1];
|
||||||
|
// Side by side: the boxes overlap vertically (a big number with a small,
|
||||||
|
// bottom-aligned unit), the unit lies to the right and not far away.
|
||||||
if (
|
if (
|
||||||
!second ||
|
!second ||
|
||||||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
|
distanceTo(
|
||||||
|
{
|
||||||
|
top: first.element.y,
|
||||||
|
bottom: first.element.y + first.element.height,
|
||||||
|
},
|
||||||
|
second,
|
||||||
|
) > 0 ||
|
||||||
|
second.element.x < first.element.x ||
|
||||||
|
second.element.x - (first.element.x + first.element.width) >
|
||||||
|
MAX_JOIN_GAP_MM
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -528,6 +540,7 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
|||||||
const candidates = candidatesOf(input, measurer);
|
const candidates = candidatesOf(input, measurer);
|
||||||
const visible = candidates.filter((candidate) => !candidate.clipped);
|
const visible = candidates.filter((candidate) => !candidate.clipped);
|
||||||
const views = textViews(visible);
|
const views = textViews(visible);
|
||||||
|
const allViews = textViews(candidates);
|
||||||
const combined = views.full;
|
const combined = views.full;
|
||||||
|
|
||||||
const gaps: LabelFinding[] = [];
|
const gaps: LabelFinding[] = [];
|
||||||
@@ -558,13 +571,20 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
|||||||
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
|
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
|
||||||
];
|
];
|
||||||
holders.forEach((candidate) => carriers.add(candidate));
|
holders.forEach((candidate) => carriers.add(candidate));
|
||||||
|
// Clipped parts of a split phrase: those that would be part of the match
|
||||||
|
// had they been visible.
|
||||||
|
const clippedSplit = new Set(
|
||||||
|
splitPhrases.flatMap((phrase) =>
|
||||||
|
groupsHolding(allViews, phrase)
|
||||||
|
.flat()
|
||||||
|
.filter((candidate) => candidate.clipped),
|
||||||
|
),
|
||||||
|
);
|
||||||
const clippedHolders = candidates.filter(
|
const clippedHolders = candidates.filter(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
candidate.clipped &&
|
candidate.clipped &&
|
||||||
(particular.isCarriedBy(candidate.normalized) ||
|
(particular.isCarriedBy(candidate.normalized) ||
|
||||||
splitPhrases.some((phrase) =>
|
clippedSplit.has(candidate)),
|
||||||
sharesWord(candidate.normalized, phrase),
|
|
||||||
)),
|
|
||||||
);
|
);
|
||||||
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
|
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
|
||||||
if (!isOnLabel) {
|
if (!isOnLabel) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { drawProofWatermark } from './label-proof-watermark';
|
||||||
|
|
||||||
|
type Call = [name: string, ...args: unknown[]];
|
||||||
|
|
||||||
|
/** A PDFKit stand-in that records the drawing calls in order. */
|
||||||
|
function fakeDoc() {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const doc: Record<string, unknown> = {};
|
||||||
|
const record =
|
||||||
|
(name: string) =>
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
calls.push([name, ...args]);
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
|
for (const name of [
|
||||||
|
'save',
|
||||||
|
'restore',
|
||||||
|
'rotate',
|
||||||
|
'fillColor',
|
||||||
|
'fillOpacity',
|
||||||
|
'font',
|
||||||
|
'fontSize',
|
||||||
|
'text',
|
||||||
|
]) {
|
||||||
|
doc[name] = record(name);
|
||||||
|
}
|
||||||
|
doc.widthOfString = () => 100;
|
||||||
|
return { doc: doc as unknown as PDFKit.PDFDocument, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const callsOf = (calls: Call[], name: string) =>
|
||||||
|
calls.filter(([callName]) => callName === name);
|
||||||
|
|
||||||
|
describe('drawProofWatermark', () => {
|
||||||
|
it('draws the text MUSTER – nicht verkaufsfähig rotated, grey, half opaque, once per label', () => {
|
||||||
|
const { doc, calls } = fakeDoc();
|
||||||
|
|
||||||
|
drawProofWatermark(doc, 10, 20, 255, 99);
|
||||||
|
|
||||||
|
const texts = callsOf(calls, 'text');
|
||||||
|
expect(texts).toHaveLength(1);
|
||||||
|
expect(texts[0][1]).toBe('MUSTER – nicht verkaufsfähig');
|
||||||
|
expect(callsOf(calls, 'fillOpacity')).toEqual([['fillOpacity', 0.5]]);
|
||||||
|
const [, color] = callsOf(calls, 'fillColor')[0];
|
||||||
|
expect(color).toBe('#808080');
|
||||||
|
const [, angle] = callsOf(calls, 'rotate')[0];
|
||||||
|
expect(angle).toBeLessThan(0);
|
||||||
|
expect(angle).toBeGreaterThan(-90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves and restores the graphics state around the watermark', () => {
|
||||||
|
const { doc, calls } = fakeDoc();
|
||||||
|
|
||||||
|
drawProofWatermark(doc, 0, 0, 255, 99);
|
||||||
|
|
||||||
|
expect(calls[0][0]).toBe('save');
|
||||||
|
expect(calls[calls.length - 1][0]).toBe('restore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the text on the label: centred on the label box', () => {
|
||||||
|
const { doc, calls } = fakeDoc();
|
||||||
|
|
||||||
|
drawProofWatermark(doc, 10, 20, 200, 100);
|
||||||
|
|
||||||
|
const [, , options] = callsOf(calls, 'rotate')[0] as [
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
{ origin: [number, number] },
|
||||||
|
];
|
||||||
|
expect(options.origin).toEqual([110, 70]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export const PROOF_WATERMARK_TEXT = 'MUSTER – nicht verkaufsfähig';
|
||||||
|
|
||||||
|
const WATERMARK_COLOR = '#808080';
|
||||||
|
const WATERMARK_OPACITY = 0.5;
|
||||||
|
const WATERMARK_FONT = 'Helvetica-Bold';
|
||||||
|
// The text runs along the label's diagonal; this share of it is covered.
|
||||||
|
const DIAGONAL_COVERAGE = 0.8;
|
||||||
|
// Never taller than this share of the label's height.
|
||||||
|
const MAX_HEIGHT_SHARE = 0.5;
|
||||||
|
const MEASURE_SIZE_PT = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the proof stamp "MUSTER – nicht verkaufsfähig" over one label: grey,
|
||||||
|
* half opaque, rotated along the label's diagonal and centred. Called once
|
||||||
|
* per label, after its content. The graphics state is restored, so nothing
|
||||||
|
* leaks into the next label.
|
||||||
|
*/
|
||||||
|
export function drawProofWatermark(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
originX: number,
|
||||||
|
originY: number,
|
||||||
|
widthPt: number,
|
||||||
|
heightPt: number,
|
||||||
|
): void {
|
||||||
|
const centerX = originX + widthPt / 2;
|
||||||
|
const centerY = originY + heightPt / 2;
|
||||||
|
const angleDeg = (Math.atan2(heightPt, widthPt) * 180) / Math.PI;
|
||||||
|
|
||||||
|
doc.save();
|
||||||
|
doc.font(WATERMARK_FONT).fontSize(MEASURE_SIZE_PT);
|
||||||
|
const widthAtMeasureSize = doc.widthOfString(PROOF_WATERMARK_TEXT);
|
||||||
|
const targetWidth = Math.hypot(widthPt, heightPt) * DIAGONAL_COVERAGE;
|
||||||
|
const fontSize = Math.min(
|
||||||
|
(MEASURE_SIZE_PT * targetWidth) / widthAtMeasureSize,
|
||||||
|
heightPt * MAX_HEIGHT_SHARE,
|
||||||
|
);
|
||||||
|
const textWidth = (widthAtMeasureSize * fontSize) / MEASURE_SIZE_PT;
|
||||||
|
|
||||||
|
doc.fillColor(WATERMARK_COLOR).fillOpacity(WATERMARK_OPACITY);
|
||||||
|
doc.rotate(-angleDeg, { origin: [centerX, centerY] });
|
||||||
|
doc.fontSize(fontSize);
|
||||||
|
doc.text(
|
||||||
|
PROOF_WATERMARK_TEXT,
|
||||||
|
centerX - textWidth / 2,
|
||||||
|
centerY - fontSize / 2,
|
||||||
|
{ lineBreak: false },
|
||||||
|
);
|
||||||
|
doc.restore();
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
|
ParseBoolPipe,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -12,18 +16,34 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||||
|
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||||
import { LabelTemplatesService } from './label-templates.service';
|
import { LabelTemplatesService } from './label-templates.service';
|
||||||
|
|
||||||
|
// Far above any sheet grid; the service clamps to the real slot count.
|
||||||
|
const MAX_START_POSITION = 1000;
|
||||||
|
|
||||||
@ApiTags('label-templates')
|
@ApiTags('label-templates')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('label-templates')
|
@Controller('label-templates')
|
||||||
export class LabelTemplatesController {
|
export class LabelTemplatesController {
|
||||||
constructor(private readonly labelTemplatesService: LabelTemplatesService) {}
|
constructor(
|
||||||
|
private readonly labelTemplatesService: LabelTemplatesService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// A check or print reads the bottling (lot, dates, jar size), so it needs
|
||||||
|
// the right to see bottlings as well as the label permission.
|
||||||
|
private async assertCanReadBottling(userId: string): Promise<void> {
|
||||||
|
if (!(await this.permissionResolver.hasAny(userId, ['bottling:read']))) {
|
||||||
|
throw new ForbiddenException('Missing permission: bottling:read');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission('label-templates:read')
|
@RequirePermission('label-templates:read')
|
||||||
@Get()
|
@Get()
|
||||||
@@ -31,6 +51,30 @@ export class LabelTemplatesController {
|
|||||||
return this.labelTemplatesService.findAll();
|
return this.labelTemplatesService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:read')
|
||||||
|
@Post('check')
|
||||||
|
@HttpCode(200)
|
||||||
|
async checkDraft(
|
||||||
|
@Body() dto: CheckLabelTemplateDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
if (dto.bottlingBatchId) {
|
||||||
|
await this.assertCanReadBottling(actor.id);
|
||||||
|
}
|
||||||
|
return this.labelTemplatesService.checkDraft(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('label-templates:read')
|
||||||
|
@Get(':id/check/:bottlingBatchId')
|
||||||
|
async checkForBottling(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('bottlingBatchId') bottlingBatchId: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
await this.assertCanReadBottling(actor.id);
|
||||||
|
return this.labelTemplatesService.checkForBottling(id, bottlingBatchId);
|
||||||
|
}
|
||||||
|
|
||||||
@RequirePermission('label-templates:read')
|
@RequirePermission('label-templates:read')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
@@ -69,12 +113,25 @@ export class LabelTemplatesController {
|
|||||||
@Param('bottlingBatchId') bottlingBatchId: string,
|
@Param('bottlingBatchId') bottlingBatchId: string,
|
||||||
@Query('startPosition', new ParseIntPipe({ optional: true }))
|
@Query('startPosition', new ParseIntPipe({ optional: true }))
|
||||||
startPosition: number | undefined,
|
startPosition: number | undefined,
|
||||||
|
@Query('proof', new ParseBoolPipe({ optional: true }))
|
||||||
|
proof: boolean | undefined,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
startPosition !== undefined &&
|
||||||
|
(startPosition < 1 || startPosition > MAX_START_POSITION)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`startPosition must be between 1 and ${MAX_START_POSITION}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertCanReadBottling(actor.id);
|
||||||
const pdf = await this.labelTemplatesService.renderLabelsPdf(
|
const pdf = await this.labelTemplatesService.renderLabelsPdf(
|
||||||
id,
|
id,
|
||||||
bottlingBatchId,
|
bottlingBatchId,
|
||||||
startPosition,
|
{ startPosition, proof },
|
||||||
|
actor.id,
|
||||||
);
|
);
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
LabelTemplatesService,
|
LabelTemplatesService,
|
||||||
buildPlaceholderValues,
|
buildPlaceholderValues,
|
||||||
@@ -7,12 +7,16 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
import { LabelElement } from './label-element.types';
|
import { LabelElement, LabelTextElement } from './label-element.types';
|
||||||
|
import * as watermark from './label-proof-watermark';
|
||||||
|
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||||
|
|
||||||
jest.mock('../prisma/row-lock', () => ({
|
jest.mock('../prisma/row-lock', () => ({
|
||||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const PROOF = { proof: true };
|
||||||
|
|
||||||
const SETTINGS = {
|
const SETTINGS = {
|
||||||
originCountry: 'Deutschland',
|
originCountry: 'Deutschland',
|
||||||
foodBusinessName: 'Imkerei Muster' as string | null,
|
foodBusinessName: 'Imkerei Muster' as string | null,
|
||||||
@@ -38,6 +42,7 @@ describe('LabelTemplatesService', () => {
|
|||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
bottlingBatch: { findUnique: jest.Mock };
|
bottlingBatch: { findUnique: jest.Mock };
|
||||||
|
complianceSettings: { findUnique: jest.Mock };
|
||||||
$transaction: jest.Mock;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
@@ -90,6 +95,7 @@ describe('LabelTemplatesService', () => {
|
|||||||
gapYMm: 2,
|
gapYMm: 2,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
active: true,
|
active: true,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -117,6 +123,14 @@ describe('LabelTemplatesService', () => {
|
|||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
bottlingBatch: { findUnique: jest.fn() },
|
bottlingBatch: { findUnique: jest.fn() },
|
||||||
|
complianceSettings: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
netQuantityDigitRules: [
|
||||||
|
{ maxGrams: 1000, minMm: 4 },
|
||||||
|
{ maxGrams: null, minMm: 6 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||||
callback(tx),
|
callback(tx),
|
||||||
),
|
),
|
||||||
@@ -248,7 +262,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(pdf).toBeInstanceOf(Buffer);
|
expect(pdf).toBeInstanceOf(Buffer);
|
||||||
expect(pdf.length).toBeGreaterThan(0);
|
expect(pdf.length).toBeGreaterThan(0);
|
||||||
@@ -261,7 +280,7 @@ describe('LabelTemplatesService', () => {
|
|||||||
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.renderLabelsPdf('template-1', 'missing'),
|
service.renderLabelsPdf('template-1', 'missing', PROOF, 'user-1'),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,7 +307,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
// A PDF with an embedded raster image contains an XObject /Image entry.
|
// A PDF with an embedded raster image contains an XObject /Image entry.
|
||||||
@@ -323,7 +347,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
// pdfkit always lists /ImageB /ImageC /ImageI in a page's ProcSet
|
// pdfkit always lists /ImageB /ImageC /ImageI in a page's ProcSet
|
||||||
// regardless of whether anything is actually embedded, so a plain
|
// regardless of whether anything is actually embedded, so a plain
|
||||||
@@ -358,7 +387,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
expect(pdf.toString('latin1')).not.toMatch(/\/Subtype\s*\/Image\b/);
|
expect(pdf.toString('latin1')).not.toMatch(/\/Subtype\s*\/Image\b/);
|
||||||
@@ -386,7 +420,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(countPages(pdf)).toBe(3);
|
expect(countPages(pdf)).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -411,7 +450,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
// Clamped to the real max of 2 columns x 1 row = 2 per page, so 3
|
// Clamped to the real max of 2 columns x 1 row = 2 per page, so 3
|
||||||
// copies still need 2 pages - same as the explicit columns:2 test.
|
// copies still need 2 pages - same as the explicit columns:2 test.
|
||||||
@@ -434,7 +478,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
// Only fits on a single page if the rotated 15-per-page layout was
|
// Only fits on a single page if the rotated 15-per-page layout was
|
||||||
// actually used - upright would need a second page for the 15th copy.
|
// actually used - upright would need a second page for the 15th copy.
|
||||||
@@ -458,7 +507,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
PROOF,
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
// An explicit grid describes a real pre-printed sheet - it must be
|
// An explicit grid describes a real pre-printed sheet - it must be
|
||||||
// honoured as-is (14 per page), needing 2 pages for 15 copies, even
|
// honoured as-is (14 per page), needing 2 pages for 15 copies, even
|
||||||
@@ -483,7 +537,12 @@ describe('LabelTemplatesService', () => {
|
|||||||
product: { name: 'Glas 500g' },
|
product: { name: 'Glas 500g' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1', 2);
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
{ startPosition: 2, proof: true },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
// perPage=2, skip=1: label 1 fills the remaining slot on page 1,
|
// perPage=2, skip=1: label 1 fills the remaining slot on page 1,
|
||||||
// labels 2-3 fill both slots of page 2.
|
// labels 2-3 fill both slots of page 2.
|
||||||
@@ -491,6 +550,296 @@ describe('LabelTemplatesService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('print guard, proof and audit', () => {
|
||||||
|
function textElement(
|
||||||
|
id: string,
|
||||||
|
y: number,
|
||||||
|
placeholderKey: LabelTextElement['placeholderKey'],
|
||||||
|
fontSize = 9,
|
||||||
|
): LabelTextElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'text',
|
||||||
|
x: 2,
|
||||||
|
y,
|
||||||
|
width: 80,
|
||||||
|
height: 8,
|
||||||
|
text: '',
|
||||||
|
placeholderKey,
|
||||||
|
fontSize,
|
||||||
|
fontFamily: 'sans',
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
color: '#000000',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every mandatory particular, type big enough: no gap and no warning.
|
||||||
|
const completeElements: LabelTextElement[] = [
|
||||||
|
textElement('legal', 1, 'legalName'),
|
||||||
|
textElement('net', 10, 'jarSize', 16),
|
||||||
|
textElement('mhd', 20, 'bestBeforeLine'),
|
||||||
|
textElement('lot', 29, 'lotLine'),
|
||||||
|
textElement('origin', 38, 'originCountry'),
|
||||||
|
textElement('firm', 47, 'foodBusinessName'),
|
||||||
|
textElement('address', 56, 'foodBusinessAddress'),
|
||||||
|
];
|
||||||
|
const completeTemplate = {
|
||||||
|
...template,
|
||||||
|
id: 'template-1',
|
||||||
|
heightMm: 70,
|
||||||
|
elements: completeElements,
|
||||||
|
};
|
||||||
|
const bottling = {
|
||||||
|
id: 'batch-1',
|
||||||
|
jarSize: 500,
|
||||||
|
jarCount: 3,
|
||||||
|
lotNumber: 'L-001',
|
||||||
|
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', legalName: 'Blütenhonig' },
|
||||||
|
};
|
||||||
|
const NO_FIRM = { ...SETTINGS, foodBusinessName: null };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue(completeTemplate);
|
||||||
|
prisma.bottlingBatch.findUnique.mockResolvedValue(bottling);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
throw new Error('expected a rejection');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('prints a complete label and audits it', async () => {
|
||||||
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
{},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
|
expect(auditLog.record).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('print refuses with 409 and the gap list', async () => {
|
||||||
|
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||||
|
|
||||||
|
const error = (await rejectionOf(
|
||||||
|
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||||
|
)) as ConflictException;
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ConflictException);
|
||||||
|
expect(error.getStatus()).toBe(409);
|
||||||
|
const body = error.getResponse() as {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
gaps: { code: string }[];
|
||||||
|
};
|
||||||
|
expect(body.code).toBe('LABEL_PARTICULARS_MISSING');
|
||||||
|
expect(body.message).toEqual(expect.any(String));
|
||||||
|
expect(body.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proof print renders despite a gap, adds the watermark and writes a PRINT audit entry', async () => {
|
||||||
|
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||||
|
const mark = jest.spyOn(watermark, 'drawProofWatermark');
|
||||||
|
|
||||||
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
{ proof: true },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
|
// One watermark per label, one label per jar.
|
||||||
|
expect(mark).toHaveBeenCalledTimes(bottling.jarCount);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'PRINT', userId: 'user-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a real print draws no watermark', async () => {
|
||||||
|
const mark = jest.spyOn(watermark, 'drawProofWatermark');
|
||||||
|
|
||||||
|
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
|
||||||
|
|
||||||
|
expect(mark).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the audit entry carries templateId, copies, proof, gapCodes and warningCodes', async () => {
|
||||||
|
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||||
|
|
||||||
|
await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
{ proof: true },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith({
|
||||||
|
entityType: 'BottlingBatch',
|
||||||
|
entityId: 'batch-1',
|
||||||
|
action: 'PRINT',
|
||||||
|
userId: 'user-1',
|
||||||
|
afterData: {
|
||||||
|
templateId: 'template-1',
|
||||||
|
copies: 3,
|
||||||
|
proof: true,
|
||||||
|
gapCodes: ['FOOD_BUSINESS'],
|
||||||
|
warningCodes: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a print with warnings only is rendered and audited with the warning codes', async () => {
|
||||||
|
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||||
|
...bottling,
|
||||||
|
lotNumber: 'AB-001',
|
||||||
|
});
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||||
|
...completeTemplate,
|
||||||
|
elements: completeElements.map((element) =>
|
||||||
|
element.id === 'lot'
|
||||||
|
? { ...element, placeholderKey: 'lotNumber' }
|
||||||
|
: element,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await service.renderLabelsPdf(
|
||||||
|
'template-1',
|
||||||
|
'batch-1',
|
||||||
|
{},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
afterData: expect.objectContaining({
|
||||||
|
proof: false,
|
||||||
|
gapCodes: [],
|
||||||
|
warningCodes: ['LOT_PREFIX'],
|
||||||
|
}) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the audit is written before the PDF is returned and a failing audit fails the print', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
auditLog.record.mockImplementation(() => {
|
||||||
|
order.push('audit');
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
|
||||||
|
order.push('returned');
|
||||||
|
expect(order).toEqual(['audit', 'returned']);
|
||||||
|
|
||||||
|
auditLog.record.mockRejectedValue(new Error('audit down'));
|
||||||
|
await expect(
|
||||||
|
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||||
|
).rejects.toThrow('audit down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkDraft without a bottling uses the sample values and sets sample true', async () => {
|
||||||
|
const dto = {
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
elements: [textElement('legal', 1, 'legalName')],
|
||||||
|
} as unknown as CheckLabelTemplateDto;
|
||||||
|
|
||||||
|
const result = await service.checkDraft(dto);
|
||||||
|
|
||||||
|
expect(result.sample).toBe(true);
|
||||||
|
expect(prisma.bottlingBatch.findUnique).not.toHaveBeenCalled();
|
||||||
|
const codes = result.gaps.map((gap) => gap.code);
|
||||||
|
// The sample's legal name fills the placeholder; the rest is missing.
|
||||||
|
expect(codes).not.toContain('LEGAL_NAME');
|
||||||
|
expect(codes).toContain('NET_QUANTITY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkDraft with a bottling uses that bottling and sample false', async () => {
|
||||||
|
const dto = {
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
bottlingBatchId: 'batch-1',
|
||||||
|
elements: completeElements,
|
||||||
|
} as unknown as CheckLabelTemplateDto;
|
||||||
|
|
||||||
|
const result = await service.checkDraft(dto);
|
||||||
|
|
||||||
|
expect(result.sample).toBe(false);
|
||||||
|
expect(result.printable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkDraft answers 404 for an unknown bottling', async () => {
|
||||||
|
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
||||||
|
const dto = {
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
bottlingBatchId: 'missing',
|
||||||
|
elements: [],
|
||||||
|
} as unknown as CheckLabelTemplateDto;
|
||||||
|
|
||||||
|
await expect(service.checkDraft(dto)).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkForBottling uses the placeholder values of that bottling and the master data', async () => {
|
||||||
|
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||||
|
prisma.complianceSettings.findUnique.mockResolvedValue({
|
||||||
|
netQuantityDigitRules: [{ maxGrams: null, minMm: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.checkForBottling('template-1', 'batch-1');
|
||||||
|
|
||||||
|
expect(result.sample).toBe(false);
|
||||||
|
expect(result.printable).toBe(false);
|
||||||
|
expect(result.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
|
||||||
|
expect(result.requirements.netQuantityMinCapHeightMm).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the print and the check when the compliance settings are missing', async () => {
|
||||||
|
prisma.complianceSettings.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.checkForBottling('template-1', 'batch-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkForBottling ignores non-text elements', async () => {
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||||
|
...completeTemplate,
|
||||||
|
elements: [
|
||||||
|
...completeElements,
|
||||||
|
{ id: 'qr', type: 'qrCode', x: 0, y: 0, size: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.checkForBottling('template-1', 'batch-1');
|
||||||
|
|
||||||
|
expect(result.printable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildPlaceholderValues', () => {
|
describe('buildPlaceholderValues', () => {
|
||||||
const bottlingBatch = {
|
const bottlingBatch = {
|
||||||
jarSize: 500,
|
jarSize: 500,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -10,16 +14,49 @@ import { LabelTemplate } from '../../generated/prisma';
|
|||||||
import { computeCropMarks } from './crop-marks';
|
import { computeCropMarks } from './crop-marks';
|
||||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||||
import { berlinDayOf } from '../common/dates';
|
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||||
import { formatDayDe } from '../common/format-day';
|
import { formatDayDe } from '../common/format-day';
|
||||||
import { MM_TO_PT, pdfFontName } from './font-metrics';
|
import {
|
||||||
import { lotStartsWithL } from './label-text';
|
MM_TO_PT,
|
||||||
import { LabelElement, PlaceholderKey } from './label-element.types';
|
pdfFontName,
|
||||||
|
pdfTextMeasurer,
|
||||||
|
TextMeasurer,
|
||||||
|
} from './font-metrics';
|
||||||
|
import { labelDayOf, lotStartsWithL } from './label-text';
|
||||||
|
import {
|
||||||
|
LabelElement,
|
||||||
|
LabelTextElement,
|
||||||
|
PlaceholderKey,
|
||||||
|
} from './label-element.types';
|
||||||
|
import { expectedParticulars, loadDigitRules } from './label-check.loader';
|
||||||
|
import {
|
||||||
|
checkLabel,
|
||||||
|
LabelCheckResult,
|
||||||
|
NetQuantityDigitRule,
|
||||||
|
SAMPLE_BOTTLING,
|
||||||
|
} from './label-particulars';
|
||||||
|
import { drawProofWatermark } from './label-proof-watermark';
|
||||||
|
|
||||||
const A4_WIDTH_MM = 210;
|
const A4_WIDTH_MM = 210;
|
||||||
const A4_HEIGHT_MM = 297;
|
const A4_HEIGHT_MM = 297;
|
||||||
const QR_SOURCE_SIZE_PX = 400;
|
const QR_SOURCE_SIZE_PX = 400;
|
||||||
|
|
||||||
|
export interface PrintOptions {
|
||||||
|
/** First free slot on the first sheet (1 = top left). */
|
||||||
|
startPosition?: number;
|
||||||
|
/** A sample print: allowed with gaps, stamped "MUSTER". */
|
||||||
|
proof?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaceholderBottling = Parameters<typeof buildPlaceholderValues>[0];
|
||||||
|
|
||||||
|
interface CheckContext {
|
||||||
|
settings: Parameters<typeof buildPlaceholderValues>[1];
|
||||||
|
digitRules: NetQuantityDigitRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_PARTICULARS_MISSING = 'LABEL_PARTICULARS_MISSING';
|
||||||
|
|
||||||
interface SheetLayout {
|
interface SheetLayout {
|
||||||
widthMm: number;
|
widthMm: number;
|
||||||
heightMm: number;
|
heightMm: number;
|
||||||
@@ -36,15 +73,7 @@ interface SheetLayout {
|
|||||||
// not to show if the blade lands a fraction beside it.
|
// not to show if the blade lands a fraction beside it.
|
||||||
const CROP_MARK_LINE_WIDTH_PT = 0.3;
|
const CROP_MARK_LINE_WIDTH_PT = 0.3;
|
||||||
|
|
||||||
/**
|
export { labelDayOf };
|
||||||
* 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 {
|
function formatDate(date: Date): string {
|
||||||
return formatDayDe(labelDayOf(date), true);
|
return formatDayDe(labelDayOf(date), true);
|
||||||
@@ -140,6 +169,33 @@ export function buildPlaceholderValues(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textElementsOf(template: LabelTemplate): LabelTextElement[] {
|
||||||
|
return elementsOf(template).filter(
|
||||||
|
(element): element is LabelTextElement => element.type === 'text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textElementFromDraft(
|
||||||
|
element: CheckLabelTemplateDto['elements'][number],
|
||||||
|
): LabelTextElement {
|
||||||
|
return {
|
||||||
|
id: element.id,
|
||||||
|
type: 'text',
|
||||||
|
x: element.x,
|
||||||
|
y: element.y,
|
||||||
|
width: element.width,
|
||||||
|
height: element.height,
|
||||||
|
text: element.text ?? '',
|
||||||
|
placeholderKey: element.placeholderKey ?? null,
|
||||||
|
fontSize: element.fontSize,
|
||||||
|
fontFamily: element.fontFamily ?? 'sans',
|
||||||
|
bold: element.bold ?? false,
|
||||||
|
italic: element.italic ?? false,
|
||||||
|
color: element.color ?? '#000000',
|
||||||
|
align: element.align ?? 'left',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LabelTemplatesService {
|
export class LabelTemplatesService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -244,24 +300,40 @@ export class LabelTemplatesService {
|
|||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the labels of one bottling. A mandatory particular that is missing
|
||||||
|
* blocks the print (409) unless it is a proof print. The audit entry is
|
||||||
|
* written before the PDF is handed out: no unaudited print.
|
||||||
|
*/
|
||||||
async renderLabelsPdf(
|
async renderLabelsPdf(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
bottlingBatchId: string,
|
bottlingBatchId: string,
|
||||||
startPosition = 1,
|
options: PrintOptions,
|
||||||
|
actingUserId: string,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
|
const proof = options.proof === true;
|
||||||
const template = await this.findById(templateId);
|
const template = await this.findById(templateId);
|
||||||
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
const bottlingBatch = await this.findBottling(bottlingBatchId);
|
||||||
where: { id: bottlingBatchId },
|
const context = await this.loadCheckContext();
|
||||||
include: { honeyBatch: true, product: true },
|
|
||||||
});
|
const result = this.check(
|
||||||
if (!bottlingBatch) {
|
context,
|
||||||
throw new NotFoundException('Bottling batch not found');
|
textElementsOf(template),
|
||||||
|
template.minXHeightMm,
|
||||||
|
bottlingBatch,
|
||||||
|
false,
|
||||||
|
pdfTextMeasurer(),
|
||||||
|
);
|
||||||
|
if (result.gaps.length > 0 && !proof) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: LABEL_PARTICULARS_MISSING,
|
||||||
|
message:
|
||||||
|
'Pflichtangaben fehlen auf dem Etikett. Ein Probedruck ist möglich.',
|
||||||
|
gaps: result.gaps,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = buildPlaceholderValues(
|
const values = buildPlaceholderValues(bottlingBatch, context.settings);
|
||||||
bottlingBatch,
|
|
||||||
await this.siteSettings.get(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const elements = elementsOf(template).map((element) =>
|
const elements = elementsOf(template).map((element) =>
|
||||||
element.type === 'text' && element.placeholderKey
|
element.type === 'text' && element.placeholderKey
|
||||||
@@ -283,7 +355,7 @@ export class LabelTemplatesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.renderPdf(
|
const pdf = await this.renderPdf(
|
||||||
elements,
|
elements,
|
||||||
{
|
{
|
||||||
widthMm: template.widthMm,
|
widthMm: template.widthMm,
|
||||||
@@ -298,8 +370,94 @@ export class LabelTemplatesService {
|
|||||||
},
|
},
|
||||||
bottlingBatch.jarCount,
|
bottlingBatch.jarCount,
|
||||||
qrCodeBuffer,
|
qrCodeBuffer,
|
||||||
startPosition,
|
options.startPosition ?? 1,
|
||||||
|
proof,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'BottlingBatch',
|
||||||
|
entityId: bottlingBatchId,
|
||||||
|
action: 'PRINT',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: {
|
||||||
|
templateId,
|
||||||
|
copies: bottlingBatch.jarCount,
|
||||||
|
proof,
|
||||||
|
gapCodes: result.gaps.map((gap) => gap.code),
|
||||||
|
warningCodes: result.warnings.map((warning) => warning.code),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks an unsaved draft; without a bottling the sample values fill it. */
|
||||||
|
async checkDraft(dto: CheckLabelTemplateDto): Promise<LabelCheckResult> {
|
||||||
|
const bottling = dto.bottlingBatchId
|
||||||
|
? await this.findBottling(dto.bottlingBatchId)
|
||||||
|
: SAMPLE_BOTTLING;
|
||||||
|
return this.check(
|
||||||
|
await this.loadCheckContext(),
|
||||||
|
dto.elements.map(textElementFromDraft),
|
||||||
|
dto.minXHeightMm,
|
||||||
|
bottling,
|
||||||
|
!dto.bottlingBatchId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same check the print runs, for one saved template and bottling. */
|
||||||
|
async checkForBottling(
|
||||||
|
templateId: string,
|
||||||
|
bottlingBatchId: string,
|
||||||
|
): Promise<LabelCheckResult> {
|
||||||
|
const template = await this.findById(templateId);
|
||||||
|
const bottling = await this.findBottling(bottlingBatchId);
|
||||||
|
return this.check(
|
||||||
|
await this.loadCheckContext(),
|
||||||
|
textElementsOf(template),
|
||||||
|
template.minXHeightMm,
|
||||||
|
bottling,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findBottling(id: string) {
|
||||||
|
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { honeyBatch: true, product: true },
|
||||||
|
});
|
||||||
|
if (!bottlingBatch) {
|
||||||
|
throw new NotFoundException('Bottling batch not found');
|
||||||
|
}
|
||||||
|
return bottlingBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCheckContext(): Promise<CheckContext> {
|
||||||
|
const [settings, digitRules] = await Promise.all([
|
||||||
|
this.siteSettings.get(),
|
||||||
|
loadDigitRules(this.prisma),
|
||||||
|
]);
|
||||||
|
return { settings, digitRules };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one place that builds a label check: the print and both check
|
||||||
|
// endpoints go through it.
|
||||||
|
private check(
|
||||||
|
context: CheckContext,
|
||||||
|
elements: LabelTextElement[],
|
||||||
|
minXHeightMm: number,
|
||||||
|
bottling: PlaceholderBottling,
|
||||||
|
sample: boolean,
|
||||||
|
measurer?: TextMeasurer,
|
||||||
|
): LabelCheckResult {
|
||||||
|
return checkLabel({
|
||||||
|
elements,
|
||||||
|
minXHeightMm,
|
||||||
|
digitRules: context.digitRules,
|
||||||
|
values: buildPlaceholderValues(bottling, context.settings),
|
||||||
|
expected: expectedParticulars(bottling, context.settings),
|
||||||
|
sample,
|
||||||
|
measurer,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderPdf(
|
private renderPdf(
|
||||||
@@ -308,6 +466,7 @@ export class LabelTemplatesService {
|
|||||||
copies: number,
|
copies: number,
|
||||||
qrCodeBuffer: Buffer | null,
|
qrCodeBuffer: Buffer | null,
|
||||||
startPosition: number,
|
startPosition: number,
|
||||||
|
proof: boolean,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||||
@@ -436,10 +595,18 @@ export class LabelTemplatesService {
|
|||||||
centerX - w / 2,
|
centerX - w / 2,
|
||||||
centerY - h / 2,
|
centerY - h / 2,
|
||||||
qrCodeBuffer,
|
qrCodeBuffer,
|
||||||
|
proof ? { widthPt: w, heightPt: h } : null,
|
||||||
);
|
);
|
||||||
doc.restore();
|
doc.restore();
|
||||||
} else {
|
} else {
|
||||||
this.drawLabel(doc, elements, originX, originY, qrCodeBuffer);
|
this.drawLabel(
|
||||||
|
doc,
|
||||||
|
elements,
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
qrCodeBuffer,
|
||||||
|
proof ? { widthPt: slotW, heightPt: slotH } : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,10 +620,20 @@ export class LabelTemplatesService {
|
|||||||
originX: number,
|
originX: number,
|
||||||
originY: number,
|
originY: number,
|
||||||
qrCodeBuffer: Buffer | null,
|
qrCodeBuffer: Buffer | null,
|
||||||
|
proofStamp: { widthPt: number; heightPt: number } | null,
|
||||||
): void {
|
): void {
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
this.drawElement(doc, element, originX, originY, qrCodeBuffer);
|
this.drawElement(doc, element, originX, originY, qrCodeBuffer);
|
||||||
}
|
}
|
||||||
|
if (proofStamp) {
|
||||||
|
drawProofWatermark(
|
||||||
|
doc,
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
proofStamp.widthPt,
|
||||||
|
proofStamp.heightPt,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawElement(
|
private drawElement(
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
|
import { berlinDayOf } from '../common/dates';
|
||||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text as the label check compares it: NFKC, lower case, every character that
|
* 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
|
* is not a letter or digit becomes a space, runs of spaces collapse. So
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import {
|
import {
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||||
|
const MAX_ID_LENGTH = 64;
|
||||||
|
|
||||||
export class CreateTreatmentDto {
|
export class CreateTreatmentDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -27,9 +34,45 @@ export class CreateTreatmentDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Handelsname. Fehlt er, aber ein Mittel oder eine Packung ist gewählt, wird es der Name aus dem Katalog.',
|
||||||
|
})
|
||||||
|
@ValidateIf((o: CreateTreatmentDto) => !o.medicineId && !o.medicineLotId)
|
||||||
@IsString()
|
@IsString()
|
||||||
product!: string;
|
@MaxLength(200)
|
||||||
|
product?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: 'Katalogeintrag (Tierarzneimittel)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(ID_PATTERN)
|
||||||
|
@MaxLength(MAX_ID_LENGTH)
|
||||||
|
medicineId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Packung, aus der die Dosis gebucht wird. Lieferant, Beleg, Ch.-B. und Tierarzt kommen dann aus der Packung.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(ID_PATTERN)
|
||||||
|
@MaxLength(MAX_ID_LENGTH)
|
||||||
|
medicineLotId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Dosis je Volk in der Einheit des Mittels (> 0). Mit Packung wird sie gebucht, ohne Packung nur gespeichert.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||||
|
@IsPositive()
|
||||||
|
doseQuantity?: number | null;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||||
|
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||||
import { CreateTreatmentDto } from './create-treatment.dto';
|
import { CreateTreatmentDto } from './create-treatment.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every field stays correctable, `colonyId` included: a record filed against
|
* Every field stays correctable, `colonyId` included: a record filed against
|
||||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||||
* repair the colony's history without deleting the record.
|
* repair the colony's history without deleting the record. `apiaryId` corrects
|
||||||
|
* the apiary kept for the Bestandsbuch (where the colony stood on the day);
|
||||||
|
* `medicineId`, `medicineLotId` and `doseQuantity` can be cleared with null.
|
||||||
*/
|
*/
|
||||||
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto, {
|
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto, {
|
||||||
skipNullProperties: false,
|
skipNullProperties: false,
|
||||||
}) {}
|
}) {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Stand am Behandlungstag (Bestandsbuch) korrigieren',
|
||||||
|
})
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/)
|
||||||
|
@MaxLength(64)
|
||||||
|
apiaryId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma, Treatment } from '../../generated/prisma';
|
||||||
|
import { roundToThousandths } from '../common/quantity';
|
||||||
|
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
|
import {
|
||||||
|
BookedPerLot,
|
||||||
|
treatmentBookingDelta,
|
||||||
|
} from '../veterinary-medicines/medicine-ledger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a treatment takes from the medicine catalogue and the pack book
|
||||||
|
* (Spec 5.2). Both helpers run inside the caller's transaction, for create,
|
||||||
|
* createBulk and update alike, so the three paths cannot drift apart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The smallest dose on the thousandths grid; below it nothing could be booked. */
|
||||||
|
const MIN_DOSE = 0.001;
|
||||||
|
|
||||||
|
export const LOT_SOURCE_MESSAGE = 'Diese Angabe kommt aus der Packung';
|
||||||
|
|
||||||
|
export interface MedicineInput {
|
||||||
|
/** The trade name as entered; undefined when the form sent none. */
|
||||||
|
product?: string | null;
|
||||||
|
/** Update only: the stored name, kept when no medicine is chosen. */
|
||||||
|
currentProduct?: string;
|
||||||
|
/** undefined: not given; null: explicitly empty. */
|
||||||
|
medicineId?: string | null;
|
||||||
|
medicineLotId?: string | null;
|
||||||
|
doseQuantity?: number | null;
|
||||||
|
supplier?: string | null;
|
||||||
|
purchaseProof?: string | null;
|
||||||
|
batchNumber?: string | null;
|
||||||
|
prescribingVet?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMedicine {
|
||||||
|
medicineId: string | null;
|
||||||
|
medicineLotId: string | null;
|
||||||
|
/** The catalogue name with a medicine, else the entered trade name. */
|
||||||
|
product: string;
|
||||||
|
/** On the thousandths grid, or null. */
|
||||||
|
doseQuantity: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasText(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && value.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameName(a: string, b: string): boolean {
|
||||||
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedDose(dose: number | null | undefined): number | null {
|
||||||
|
if (dose === null || dose === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rounded = roundToThousandths(dose);
|
||||||
|
if (!(rounded >= MIN_DOSE)) {
|
||||||
|
throw new BadRequestException('Die Dosis ist zu klein');
|
||||||
|
}
|
||||||
|
return rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoLotTextWithLot(input: MedicineInput): void {
|
||||||
|
const given = [
|
||||||
|
input.supplier,
|
||||||
|
input.purchaseProof,
|
||||||
|
input.batchNumber,
|
||||||
|
input.prescribingVet,
|
||||||
|
].some(hasText);
|
||||||
|
if (given) {
|
||||||
|
throw new BadRequestException(LOT_SOURCE_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the (merged) state of a treatment against catalogue and pack and
|
||||||
|
* returns what to store. Never fills activeIngredient, withdrawal period or
|
||||||
|
* method from the catalogue - only the trade name follows it.
|
||||||
|
*
|
||||||
|
* `previousMedicineId` (update): the medicine the treatment already had; an
|
||||||
|
* inactive entry only blocks a medicine that is new to the treatment.
|
||||||
|
*/
|
||||||
|
export async function resolveMedicineIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: MedicineInput,
|
||||||
|
mode: 'create' | 'update',
|
||||||
|
previousMedicineId: string | null = null,
|
||||||
|
): Promise<ResolvedMedicine> {
|
||||||
|
const doseQuantity = checkedDose(input.doseQuantity);
|
||||||
|
const lotId = input.medicineLotId ?? null;
|
||||||
|
if (lotId !== null && input.medicineId === null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Bei gewählter Packung muss das Mittel gesetzt bleiben',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lotId !== null) {
|
||||||
|
assertNoLotTextWithLot(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lot =
|
||||||
|
lotId === null
|
||||||
|
? null
|
||||||
|
: await tx.veterinaryMedicineLot.findUnique({
|
||||||
|
where: { id: lotId },
|
||||||
|
select: { id: true, medicineId: true },
|
||||||
|
});
|
||||||
|
if (lotId !== null && !lot) {
|
||||||
|
throw new NotFoundException('Packung nicht gefunden');
|
||||||
|
}
|
||||||
|
if (lot && input.medicineId && input.medicineId !== lot.medicineId) {
|
||||||
|
throw new BadRequestException('Die Packung gehört zu einem anderen Mittel');
|
||||||
|
}
|
||||||
|
|
||||||
|
const medicineId = lot?.medicineId ?? input.medicineId ?? null;
|
||||||
|
const entered = hasText(input.product) ? input.product!.trim() : undefined;
|
||||||
|
if (medicineId === null) {
|
||||||
|
const product = entered ?? input.currentProduct;
|
||||||
|
if (product === undefined) {
|
||||||
|
throw new BadRequestException('Bitte Mittel oder Handelsname angeben');
|
||||||
|
}
|
||||||
|
return { medicineId: null, medicineLotId: null, product, doseQuantity };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared: the entry cannot be deactivated before this transaction ends.
|
||||||
|
await lockRowsForShare(tx, 'VeterinaryMedicine', [medicineId]);
|
||||||
|
const medicine = await tx.veterinaryMedicine.findUnique({
|
||||||
|
where: { id: medicineId },
|
||||||
|
select: { id: true, name: true, active: true },
|
||||||
|
});
|
||||||
|
if (!medicine) {
|
||||||
|
throw new NotFoundException('Mittel nicht gefunden');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!medicine.active &&
|
||||||
|
(mode === 'create' || medicineId !== previousMedicineId)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Das Mittel ist deaktiviert.');
|
||||||
|
}
|
||||||
|
if (entered !== undefined && !sameName(entered, medicine.name)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Mittel und Katalogeintrag passen nicht zusammen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
medicineId,
|
||||||
|
medicineLotId: lotId,
|
||||||
|
product: medicine.name,
|
||||||
|
doseQuantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sums the TREATMENT movements one treatment has so far, per lot. */
|
||||||
|
export async function bookingsOf(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
treatmentId: string,
|
||||||
|
): Promise<BookedPerLot[]> {
|
||||||
|
const sums = await tx.veterinaryMedicineMovement.groupBy({
|
||||||
|
by: ['lotId'],
|
||||||
|
where: { treatmentId, type: 'TREATMENT' },
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
return sums.map((sum) => ({
|
||||||
|
lotId: sum.lotId,
|
||||||
|
quantity: sum._sum.quantity ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes the movements of the treatment add up to its state: -dose on its lot,
|
||||||
|
* nothing on any other. Existing movements are never touched, a changed lot or
|
||||||
|
* dose is a counter-booking plus a new booking (treatmentBookingDelta). The
|
||||||
|
* lots are locked in ascending id order, after the treatment row, so two
|
||||||
|
* edits cannot wait for each other and an inventory count (which reads the
|
||||||
|
* rest under the same lot lock) sees either all of this booking or none.
|
||||||
|
*
|
||||||
|
* `booked`: the movements so far; omitted, they are read (a new treatment has
|
||||||
|
* none and passes []).
|
||||||
|
*/
|
||||||
|
export async function bookTreatmentIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
treatment: Treatment,
|
||||||
|
userId: string,
|
||||||
|
booked?: readonly BookedPerLot[],
|
||||||
|
): Promise<void> {
|
||||||
|
const current = booked ?? (await bookingsOf(tx, treatment.id));
|
||||||
|
const { medicineLotId, doseQuantity } = treatment;
|
||||||
|
const desired =
|
||||||
|
typeof medicineLotId === 'string' && typeof doseQuantity === 'number'
|
||||||
|
? { lotId: medicineLotId, dose: doseQuantity }
|
||||||
|
: null;
|
||||||
|
const deltas = treatmentBookingDelta(current, desired);
|
||||||
|
if (deltas.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lotIds = deltas.map((delta) => delta.lotId);
|
||||||
|
await lockRowsForUpdate(tx, 'VeterinaryMedicineLot', lotIds);
|
||||||
|
if (desired) {
|
||||||
|
// The lot was read before it was locked: it may have changed medicine.
|
||||||
|
const lots = await tx.veterinaryMedicineLot.findMany({
|
||||||
|
where: { id: { in: lotIds } },
|
||||||
|
select: { id: true, medicineId: true },
|
||||||
|
});
|
||||||
|
const wanted = lots.find((lot) => lot.id === desired.lotId);
|
||||||
|
if (!wanted || wanted.medicineId !== treatment.medicineId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Die Packung wurde gerade geändert. Bitte neu laden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const delta of [...deltas].sort((a, b) =>
|
||||||
|
a.lotId < b.lotId ? -1 : a.lotId > b.lotId ? 1 : 0,
|
||||||
|
)) {
|
||||||
|
await tx.veterinaryMedicineMovement.create({
|
||||||
|
data: {
|
||||||
|
type: 'TREATMENT',
|
||||||
|
quantity: delta.quantity,
|
||||||
|
treatmentId: treatment.id,
|
||||||
|
lotId: delta.lotId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
describeNullContracts,
|
||||||
|
NullContract,
|
||||||
|
} from '../common/validation/null-contract.spec-helper';
|
||||||
|
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||||
|
|
||||||
|
const contracts: Record<string, NullContract> = {
|
||||||
|
UpdateTreatmentDto: {
|
||||||
|
dto: UpdateTreatmentDto,
|
||||||
|
notNull: ['colonyId', 'date', 'product', 'apiaryId'],
|
||||||
|
clearable: [
|
||||||
|
'startDate',
|
||||||
|
'endDate',
|
||||||
|
'activeIngredient',
|
||||||
|
'amount',
|
||||||
|
'method',
|
||||||
|
'reason',
|
||||||
|
'batchNumber',
|
||||||
|
'supplier',
|
||||||
|
'purchaseProof',
|
||||||
|
'prescribingVet',
|
||||||
|
'notes',
|
||||||
|
'withdrawalPeriodDays',
|
||||||
|
'medicineId',
|
||||||
|
'medicineLotId',
|
||||||
|
'doseQuantity',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describeNullContracts('Treatment update DTO', contracts);
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { TreatmentsService } from './treatments.service';
|
import { TreatmentsService } from './treatments.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
@@ -26,6 +30,10 @@ describe('TreatmentsService', () => {
|
|||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
colony: { findUnique: jest.Mock; findMany: jest.Mock };
|
colony: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||||
|
apiary: { findUnique: jest.Mock };
|
||||||
|
veterinaryMedicine: { findUnique: jest.Mock };
|
||||||
|
veterinaryMedicineLot: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||||
|
veterinaryMedicineMovement: { create: jest.Mock; groupBy: jest.Mock };
|
||||||
$queryRaw: jest.Mock;
|
$queryRaw: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
@@ -34,6 +42,7 @@ describe('TreatmentsService', () => {
|
|||||||
const treatment = {
|
const treatment = {
|
||||||
id: 'treatment-1',
|
id: 'treatment-1',
|
||||||
colonyId: 'colony-1',
|
colonyId: 'colony-1',
|
||||||
|
product: 'Ameisensäure',
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,6 +57,16 @@ describe('TreatmentsService', () => {
|
|||||||
findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
|
findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
|
apiary: { findUnique: jest.fn().mockResolvedValue({ id: 'apiary-1' }) },
|
||||||
|
veterinaryMedicine: { findUnique: jest.fn() },
|
||||||
|
veterinaryMedicineLot: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
veterinaryMedicineMovement: {
|
||||||
|
create: jest.fn(),
|
||||||
|
groupBy: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
@@ -615,4 +634,487 @@ describe('TreatmentsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('medicine ledger', () => {
|
||||||
|
const oxuvar = {
|
||||||
|
id: 'med-1',
|
||||||
|
name: 'Oxuvar',
|
||||||
|
active: true,
|
||||||
|
unit: 'ML',
|
||||||
|
};
|
||||||
|
const lot = { id: 'lot-1', medicineId: 'med-1' };
|
||||||
|
const base = { colonyId: 'colony-1', date: '2026-08-01' };
|
||||||
|
type CreateData = Record<string, unknown>;
|
||||||
|
type Movement = {
|
||||||
|
data: {
|
||||||
|
type: string;
|
||||||
|
quantity: number;
|
||||||
|
treatmentId: string;
|
||||||
|
lotId: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdData = (): CreateData =>
|
||||||
|
(tx.treatment.create.mock.calls as [{ data: CreateData }][])[0][0].data;
|
||||||
|
const movements = () =>
|
||||||
|
(tx.veterinaryMedicineMovement.create.mock.calls as [Movement][]).map(
|
||||||
|
([arg]) => arg.data,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx.veterinaryMedicine.findUnique.mockResolvedValue(oxuvar);
|
||||||
|
tx.veterinaryMedicineLot.findUnique.mockResolvedValue(lot);
|
||||||
|
tx.veterinaryMedicineLot.findMany.mockResolvedValue([lot]);
|
||||||
|
tx.treatment.create.mockImplementation(({ data }: { data: CreateData }) =>
|
||||||
|
Promise.resolve({ ...data, id: `t-${String(data.colonyId)}` }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create books the dose from the lot in the same transaction', async () => {
|
||||||
|
await service.create(
|
||||||
|
{ ...base, medicineLotId: 'lot-1', doseQuantity: 30 },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(movements()).toEqual([
|
||||||
|
{
|
||||||
|
type: 'TREATMENT',
|
||||||
|
quantity: -30,
|
||||||
|
treatmentId: 't-colony-1',
|
||||||
|
lotId: 'lot-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// After the treatment row, before the audit entry.
|
||||||
|
const createOrder = tx.treatment.create.mock.invocationCallOrder[0];
|
||||||
|
const bookOrder =
|
||||||
|
tx.veterinaryMedicineMovement.create.mock.invocationCallOrder[0];
|
||||||
|
const auditOrder = auditLog.record.mock.invocationCallOrder[0];
|
||||||
|
expect(createOrder).toBeLessThan(bookOrder);
|
||||||
|
expect(bookOrder).toBeLessThan(auditOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a lot of another medicine', async () => {
|
||||||
|
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||||
|
id: 'lot-1',
|
||||||
|
medicineId: 'med-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ ...base, medicineId: 'med-1', medicineLotId: 'lot-1' },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(tx.treatment.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects supplier text together with a lot', async () => {
|
||||||
|
for (const field of [
|
||||||
|
'supplier',
|
||||||
|
'purchaseProof',
|
||||||
|
'batchNumber',
|
||||||
|
'prescribingVet',
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ ...base, medicineLotId: 'lot-1', [field]: 'Text' },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Diese Angabe kommt aus der Packung');
|
||||||
|
}
|
||||||
|
expect(tx.treatment.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the product name from the catalogue', async () => {
|
||||||
|
await service.create({ ...base, medicineId: 'med-1' }, 'user-1');
|
||||||
|
|
||||||
|
expect(createdData()).toMatchObject({
|
||||||
|
product: 'Oxuvar',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineLotId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects medicine and product that differ ignoring case', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ ...base, medicineId: 'med-1', product: 'Api Bioxal' },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Mittel und Katalogeintrag passen nicht zusammen');
|
||||||
|
await service.create(
|
||||||
|
{ ...base, medicineId: 'med-1', product: ' oxuvar ' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(createdData().product).toBe('Oxuvar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an inactive catalogue entry on create but allows editing an existing treatment of it', async () => {
|
||||||
|
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||||
|
...oxuvar,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.create({ ...base, medicineId: 'med-1' }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
const existing = {
|
||||||
|
...treatment,
|
||||||
|
product: 'Oxuvar',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineLotId: null,
|
||||||
|
doseQuantity: null,
|
||||||
|
};
|
||||||
|
tx.treatment.findUnique.mockResolvedValue(existing);
|
||||||
|
tx.treatment.update.mockResolvedValue(existing);
|
||||||
|
await expect(
|
||||||
|
service.update('treatment-1', { notes: 'neu' }, 'user-1'),
|
||||||
|
).resolves.toBe(existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets medicineId from the lot when only the lot is given', async () => {
|
||||||
|
await service.create({ ...base, medicineLotId: 'lot-1' }, 'user-1');
|
||||||
|
|
||||||
|
expect(createdData()).toMatchObject({
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineLotId: 'lot-1',
|
||||||
|
product: 'Oxuvar',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never fills withdrawal or ingredient from the catalogue', async () => {
|
||||||
|
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||||
|
...oxuvar,
|
||||||
|
activeIngredient: 'Oxalsäure',
|
||||||
|
withdrawalPeriodDays: 0,
|
||||||
|
applicationMethod: 'Träufeln',
|
||||||
|
dosePerColony: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.create({ ...base, medicineId: 'med-1' }, 'user-1');
|
||||||
|
|
||||||
|
const data = createdData();
|
||||||
|
expect(data.activeIngredient).toBeUndefined();
|
||||||
|
expect(data.withdrawalPeriodDays).toBeUndefined();
|
||||||
|
expect(data.method).toBeUndefined();
|
||||||
|
expect(data.doseQuantity).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dose without a lot is stored and books nothing', async () => {
|
||||||
|
await service.create(
|
||||||
|
{ ...base, product: 'Ameisensäure', doseQuantity: 12.3456 },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createdData().doseQuantity).toBe(12.346);
|
||||||
|
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a dose below one thousandth', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{ ...base, product: 'X', doseQuantity: 0.0004 },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a lot without a dose books nothing', async () => {
|
||||||
|
await service.create({ ...base, medicineLotId: 'lot-1' }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown lot and an unknown medicine with 404', async () => {
|
||||||
|
tx.veterinaryMedicineLot.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.create({ ...base, medicineLotId: 'nope' }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
tx.veterinaryMedicine.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.create({ ...base, medicineId: 'nope' }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing product when no medicine is chosen', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({ ...base }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk books one movement per colony', async () => {
|
||||||
|
tx.colony.findMany.mockResolvedValue([
|
||||||
|
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||||
|
{ id: 'colony-2', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.createBulk(
|
||||||
|
{
|
||||||
|
date: '2026-08-01',
|
||||||
|
medicineLotId: 'lot-1',
|
||||||
|
doseQuantity: 5,
|
||||||
|
colonyIds: ['colony-1', 'colony-2'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(movements()).toEqual([
|
||||||
|
expect.objectContaining({ treatmentId: 't-colony-1', quantity: -5 }),
|
||||||
|
expect.objectContaining({ treatmentId: 't-colony-2', quantity: -5 }),
|
||||||
|
]);
|
||||||
|
expect(tx.veterinaryMedicineLot.findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk rolls back all bookings when one colony fails', async () => {
|
||||||
|
tx.colony.findMany.mockResolvedValue([
|
||||||
|
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||||
|
{ id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createBulk(
|
||||||
|
{
|
||||||
|
date: '2026-08-01',
|
||||||
|
medicineLotId: 'lot-1',
|
||||||
|
doseQuantity: 5,
|
||||||
|
colonyIds: ['colony-1', 'colony-2'],
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||||
|
expect(tx.treatment.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
const booked = {
|
||||||
|
...treatment,
|
||||||
|
product: 'Oxuvar',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
medicineLotId: 'lot-1',
|
||||||
|
doseQuantity: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx.treatment.findUnique.mockResolvedValue(booked);
|
||||||
|
tx.treatment.update.mockImplementation(
|
||||||
|
({ data }: { data: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve({
|
||||||
|
...booked,
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([, v]) => v !== undefined),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
tx.veterinaryMedicineMovement.groupBy.mockResolvedValue([
|
||||||
|
{ lotId: 'lot-1', _sum: { quantity: -30 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('books only the delta when the dose changes and moves the booking when the lot changes', async () => {
|
||||||
|
await service.update('treatment-1', { doseQuantity: 20 }, 'user-1');
|
||||||
|
expect(movements()).toEqual([
|
||||||
|
expect.objectContaining({ lotId: 'lot-1', quantity: 10 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
tx.veterinaryMedicineMovement.create.mockClear();
|
||||||
|
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||||
|
id: 'lot-2',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
});
|
||||||
|
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||||
|
{ id: 'lot-1', medicineId: 'med-1' },
|
||||||
|
{ id: 'lot-2', medicineId: 'med-1' },
|
||||||
|
]);
|
||||||
|
await service.update(
|
||||||
|
'treatment-1',
|
||||||
|
{ medicineLotId: 'lot-2' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(movements()).toEqual([
|
||||||
|
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
|
||||||
|
expect.objectContaining({ lotId: 'lot-2', quantity: -30 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('books nothing when neither lot nor dose change', async () => {
|
||||||
|
await service.update('treatment-1', { notes: 'neu' }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update with dose null or lot null reverses the booking', async () => {
|
||||||
|
await service.update('treatment-1', { doseQuantity: null }, 'user-1');
|
||||||
|
expect(movements()).toEqual([
|
||||||
|
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
tx.veterinaryMedicineMovement.create.mockClear();
|
||||||
|
await service.update('treatment-1', { medicineLotId: null }, 'user-1');
|
||||||
|
expect(movements()).toEqual([
|
||||||
|
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
|
||||||
|
]);
|
||||||
|
expect(tx.treatment.update).toHaveBeenLastCalledWith({
|
||||||
|
where: { id: 'treatment-1' },
|
||||||
|
data: expect.objectContaining({ medicineLotId: null }) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update rejects medicineId null while a lot is set', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('treatment-1', { medicineId: null }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(tx.treatment.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update checks the merged state: supplier text for a treatment with a lot is rejected', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('treatment-1', { supplier: 'Apotheke' }, 'user-1'),
|
||||||
|
).rejects.toThrow('Diese Angabe kommt aus der Packung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update reads before after locking Treatment and then the lots in ascending id order', async () => {
|
||||||
|
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||||
|
id: 'lot-0',
|
||||||
|
medicineId: 'med-1',
|
||||||
|
});
|
||||||
|
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||||
|
{ id: 'lot-0', medicineId: 'med-1' },
|
||||||
|
{ id: 'lot-1', medicineId: 'med-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
'treatment-1',
|
||||||
|
{ medicineLotId: 'lot-0' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const locks = (
|
||||||
|
tx.$queryRaw.mock.calls as [
|
||||||
|
{ strings: string[]; values: unknown[] },
|
||||||
|
][]
|
||||||
|
).map(([sql]) => ({
|
||||||
|
table: sql.strings.join('?'),
|
||||||
|
values: sql.values,
|
||||||
|
}));
|
||||||
|
const treatmentLock = locks.findIndex((l) =>
|
||||||
|
l.table.includes('"Treatment"'),
|
||||||
|
);
|
||||||
|
const lotLock = locks.findIndex((l) =>
|
||||||
|
l.table.includes('"VeterinaryMedicineLot"'),
|
||||||
|
);
|
||||||
|
expect(treatmentLock).toBe(0);
|
||||||
|
expect(lotLock).toBeGreaterThan(treatmentLock);
|
||||||
|
expect(locks[lotLock].values).toEqual([['lot-0', 'lot-1']]);
|
||||||
|
const orders = tx.$queryRaw.mock.invocationCallOrder;
|
||||||
|
expect(orders[treatmentLock]).toBeLessThan(
|
||||||
|
tx.treatment.findUnique.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tx.treatment.findUnique.mock.invocationCallOrder[0],
|
||||||
|
).toBeLessThan(orders[lotLock]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-filing to another colony leaves the stock untouched', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' });
|
||||||
|
|
||||||
|
await service.update('treatment-1', { colonyId: 'colony-2' }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the colony before the treatment, then the medicine, then the lots', async () => {
|
||||||
|
tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' });
|
||||||
|
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||||
|
{ id: 'lot-1', medicineId: 'med-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
'treatment-1',
|
||||||
|
{ colonyId: 'colony-2', doseQuantity: 20 },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tables = (
|
||||||
|
tx.$queryRaw.mock.calls as [{ strings: string[] }][]
|
||||||
|
).map(([sql]) => sql.strings.join('?'));
|
||||||
|
const order = [
|
||||||
|
'"Colony"',
|
||||||
|
'"Treatment"',
|
||||||
|
'"VeterinaryMedicine"',
|
||||||
|
'"VeterinaryMedicineLot"',
|
||||||
|
].map((table) => tables.findIndex((sql) => sql.includes(table)));
|
||||||
|
expect(order.every((index) => index >= 0)).toBe(true);
|
||||||
|
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a notes-only edit succeeds although the lot moved to another medicine', async () => {
|
||||||
|
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||||
|
id: 'lot-1',
|
||||||
|
medicineId: 'med-9',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update('treatment-1', { notes: 'nur Notiz' }, 'user-1');
|
||||||
|
|
||||||
|
expect(tx.veterinaryMedicineLot.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||||
|
const [[updateArg]] = tx.treatment.update.mock.calls as [
|
||||||
|
[{ data: Record<string, unknown> }],
|
||||||
|
];
|
||||||
|
expect(updateArg.data).not.toHaveProperty('medicineLotId');
|
||||||
|
expect(updateArg.data).not.toHaveProperty('product');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update accepts apiaryId and rejects an unknown apiary with 404', async () => {
|
||||||
|
tx.apiary.findUnique.mockResolvedValue({ id: 'apiary-9' });
|
||||||
|
await service.update('treatment-1', { apiaryId: 'apiary-9' }, 'user-1');
|
||||||
|
expect(tx.treatment.update).toHaveBeenLastCalledWith({
|
||||||
|
where: { id: 'treatment-1' },
|
||||||
|
data: expect.objectContaining({ apiaryId: 'apiary-9' }) as unknown,
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.apiary.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.update('treatment-1', { apiaryId: 'nope' }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conflicts when the lot changed its medicine meanwhile', async () => {
|
||||||
|
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||||
|
{ id: 'lot-1', medicineId: 'med-9' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('treatment-1', { doseQuantity: 20 }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-sets the catalogue name when the medicine changes without a product', async () => {
|
||||||
|
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||||
|
id: 'med-2',
|
||||||
|
name: 'Api Bioxal',
|
||||||
|
active: true,
|
||||||
|
unit: 'ML',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
'treatment-1',
|
||||||
|
{ medicineId: 'med-2', medicineLotId: null, doseQuantity: null },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.treatment.update).toHaveBeenLastCalledWith({
|
||||||
|
where: { id: 'treatment-1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
product: 'Api Bioxal',
|
||||||
|
medicineId: 'med-2',
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
} from '../common/bulk/bulk-create';
|
} from '../common/bulk/bulk-create';
|
||||||
import { runBulk } from '../common/bulk/run-bulk';
|
import { runBulk } from '../common/bulk/run-bulk';
|
||||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import {
|
||||||
|
bookTreatmentIn,
|
||||||
|
MedicineInput,
|
||||||
|
resolveMedicineIn,
|
||||||
|
ResolvedMedicine,
|
||||||
|
} from './treatment-medicine';
|
||||||
import { berlinDayOf, dayToDate } from '../common/dates';
|
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||||
import {
|
import {
|
||||||
lastBlockedDay,
|
lastBlockedDay,
|
||||||
@@ -41,14 +47,19 @@ export interface WithdrawalStatus {
|
|||||||
// The row a new treatment is stored as - for create and createBulk alike, so
|
// The row a new treatment is stored as - for create and createBulk alike, so
|
||||||
// a bulk entry stores exactly what single entries would. Where the colony
|
// a bulk entry stores exactly what single entries would. Where the colony
|
||||||
// stands today is where it was treated: the apiary is kept on the treatment
|
// stands today is where it was treated: the apiary is kept on the treatment
|
||||||
// for the Bestandsbuch, the colony may be moved later.
|
// for the Bestandsbuch, the colony may be moved later. Catalogue, pack and
|
||||||
|
// dose come from resolveMedicineIn; with a pack, supplier, proof, batch
|
||||||
|
// number and vet stay empty (they are read from the pack, never copied).
|
||||||
function treatmentRow(
|
function treatmentRow(
|
||||||
dto: CreateTreatmentDto,
|
dto: CreateTreatmentDto,
|
||||||
apiaryId: string,
|
apiaryId: string,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
|
medicine: ResolvedMedicine,
|
||||||
): Prisma.TreatmentUncheckedCreateInput {
|
): Prisma.TreatmentUncheckedCreateInput {
|
||||||
return {
|
return {
|
||||||
...dto,
|
...dto,
|
||||||
|
...medicineColumns(medicine),
|
||||||
|
...(medicine.medicineLotId === null ? {} : LOT_TEXT_CLEARED),
|
||||||
apiaryId,
|
apiaryId,
|
||||||
date: new Date(dto.date),
|
date: new Date(dto.date),
|
||||||
startDate: optionalDate(dto.startDate),
|
startDate: optionalDate(dto.startDate),
|
||||||
@@ -57,6 +68,81 @@ function treatmentRow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The patch fields that make medicine, lot and dose consistency matter.
|
||||||
|
const MEDICINE_FIELDS = [
|
||||||
|
'product',
|
||||||
|
'medicineId',
|
||||||
|
'medicineLotId',
|
||||||
|
'doseQuantity',
|
||||||
|
'supplier',
|
||||||
|
'purchaseProof',
|
||||||
|
'batchNumber',
|
||||||
|
'prescribingVet',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LOT_TEXT_CLEARED = {
|
||||||
|
supplier: null,
|
||||||
|
purchaseProof: null,
|
||||||
|
batchNumber: null,
|
||||||
|
prescribingVet: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function medicineColumns(medicine: ResolvedMedicine) {
|
||||||
|
return {
|
||||||
|
product: medicine.product,
|
||||||
|
medicineId: medicine.medicineId,
|
||||||
|
medicineLotId: medicine.medicineLotId,
|
||||||
|
doseQuantity: medicine.doseQuantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function medicineInputOf(dto: CreateTreatmentDto): MedicineInput {
|
||||||
|
return {
|
||||||
|
product: dto.product,
|
||||||
|
medicineId: dto.medicineId,
|
||||||
|
medicineLotId: dto.medicineLotId,
|
||||||
|
doseQuantity: dto.doseQuantity,
|
||||||
|
supplier: dto.supplier,
|
||||||
|
purchaseProof: dto.purchaseProof,
|
||||||
|
batchNumber: dto.batchNumber,
|
||||||
|
prescribingVet: dto.prescribingVet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state an edit leaves behind: what the patch does not mention is what
|
||||||
|
// the treatment had. A missing medicineId stays missing so that a lot alone
|
||||||
|
// can set it; an explicit null stays null so that it is refused with a lot.
|
||||||
|
function mergedMedicineInput(
|
||||||
|
before: Treatment,
|
||||||
|
dto: UpdateTreatmentDto,
|
||||||
|
): MedicineInput {
|
||||||
|
return {
|
||||||
|
product: dto.product,
|
||||||
|
currentProduct: before.product,
|
||||||
|
medicineId:
|
||||||
|
dto.medicineId !== undefined
|
||||||
|
? dto.medicineId
|
||||||
|
: (before.medicineId ?? undefined),
|
||||||
|
medicineLotId:
|
||||||
|
dto.medicineLotId !== undefined
|
||||||
|
? dto.medicineLotId
|
||||||
|
: before.medicineLotId,
|
||||||
|
doseQuantity:
|
||||||
|
dto.doseQuantity !== undefined ? dto.doseQuantity : before.doseQuantity,
|
||||||
|
supplier: dto.supplier !== undefined ? dto.supplier : before.supplier,
|
||||||
|
purchaseProof:
|
||||||
|
dto.purchaseProof !== undefined
|
||||||
|
? dto.purchaseProof
|
||||||
|
: before.purchaseProof,
|
||||||
|
batchNumber:
|
||||||
|
dto.batchNumber !== undefined ? dto.batchNumber : before.batchNumber,
|
||||||
|
prescribingVet:
|
||||||
|
dto.prescribingVet !== undefined
|
||||||
|
? dto.prescribingVet
|
||||||
|
: before.prescribingVet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TreatmentsService {
|
export class TreatmentsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -145,9 +231,14 @@ export class TreatmentsService {
|
|||||||
if (!colony) {
|
if (!colony) {
|
||||||
throw new NotFoundException('Colony not found');
|
throw new NotFoundException('Colony not found');
|
||||||
}
|
}
|
||||||
|
const medicine = await resolveMedicineIn(
|
||||||
|
tx,
|
||||||
|
medicineInputOf(dto),
|
||||||
|
'create',
|
||||||
|
);
|
||||||
return this.recordIn(
|
return this.recordIn(
|
||||||
tx,
|
tx,
|
||||||
treatmentRow(dto, colony.apiaryId, actingUserId),
|
treatmentRow(dto, colony.apiaryId, actingUserId, medicine),
|
||||||
actingUserId,
|
actingUserId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -164,21 +255,39 @@ export class TreatmentsService {
|
|||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<BulkCreateResult<Treatment>> {
|
): Promise<BulkCreateResult<Treatment>> {
|
||||||
const { colonyIds, ...shared } = dto;
|
const { colonyIds, ...shared } = dto;
|
||||||
|
// The shared fields are checked once, in the bulk transaction after the
|
||||||
|
// colonies are locked; every colony then books its own dose. Valid
|
||||||
|
// because runBulk is ONE transaction without retries: the closure lives
|
||||||
|
// for exactly one attempt, so the memoised check can never outlive the
|
||||||
|
// transaction (and the locks) it was made under.
|
||||||
|
let medicine: ResolvedMedicine | undefined;
|
||||||
return runBulk(
|
return runBulk(
|
||||||
this.prisma,
|
this.prisma,
|
||||||
colonyIds.map((colonyId) => ({ colonyId })),
|
colonyIds.map((colonyId) => ({ colonyId })),
|
||||||
(tx, { colonyId }, colony, bulkId) =>
|
async (tx, { colonyId }, colony, bulkId) => {
|
||||||
this.recordIn(
|
medicine ??= await resolveMedicineIn(
|
||||||
tx,
|
tx,
|
||||||
treatmentRow({ ...shared, colonyId }, colony.apiaryId, actingUserId),
|
medicineInputOf({ ...shared, colonyId }),
|
||||||
|
'create',
|
||||||
|
);
|
||||||
|
return this.recordIn(
|
||||||
|
tx,
|
||||||
|
treatmentRow(
|
||||||
|
{ ...shared, colonyId },
|
||||||
|
colony.apiaryId,
|
||||||
|
actingUserId,
|
||||||
|
medicine,
|
||||||
|
),
|
||||||
actingUserId,
|
actingUserId,
|
||||||
bulkId,
|
bulkId,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treatment, audit entry and TreatmentRecorded in the caller's
|
// Treatment, its booking from the pack, audit entry and TreatmentRecorded
|
||||||
// transaction; calendar entry and follow-up check follow from the event.
|
// in the caller's transaction; calendar entry and follow-up check follow
|
||||||
|
// from the event.
|
||||||
private async recordIn(
|
private async recordIn(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
data: Prisma.TreatmentUncheckedCreateInput,
|
data: Prisma.TreatmentUncheckedCreateInput,
|
||||||
@@ -186,6 +295,7 @@ export class TreatmentsService {
|
|||||||
bulkId?: string,
|
bulkId?: string,
|
||||||
): Promise<Treatment> {
|
): Promise<Treatment> {
|
||||||
const treatment = await tx.treatment.create({ data });
|
const treatment = await tx.treatment.create({ data });
|
||||||
|
await bookTreatmentIn(tx, treatment, actingUserId, []);
|
||||||
await this.auditLog.record(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
entityType: 'Treatment',
|
entityType: 'Treatment',
|
||||||
@@ -214,6 +324,12 @@ export class TreatmentsService {
|
|||||||
// TreatmentCorrected moves the calendar event (changed date, colony or
|
// TreatmentCorrected moves the calendar event (changed date, colony or
|
||||||
// product) but starts no follow-up check.
|
// product) but starts no follow-up check.
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Lock order follows LOCKABLE_TABLES: the colony the treatment may be
|
||||||
|
// re-filed to first, then the treatment, then (below) the medicine and
|
||||||
|
// the lots in ascending id order.
|
||||||
|
if (dto.colonyId !== undefined) {
|
||||||
|
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
|
||||||
|
}
|
||||||
// The audit "before" is read under the row lock, not before the
|
// The audit "before" is read under the row lock, not before the
|
||||||
// transaction: a concurrent edit cannot slip in between.
|
// transaction: a concurrent edit cannot slip in between.
|
||||||
await lockRowForUpdate(tx, 'Treatment', id);
|
await lockRowForUpdate(tx, 'Treatment', id);
|
||||||
@@ -224,22 +340,55 @@ export class TreatmentsService {
|
|||||||
// Re-filed to another colony: take that colony's apiary as the location.
|
// Re-filed to another colony: take that colony's apiary as the location.
|
||||||
let movedColony: { apiaryId: string | null } | null = null;
|
let movedColony: { apiaryId: string | null } | null = null;
|
||||||
if (dto.colonyId !== undefined && dto.colonyId !== before.colonyId) {
|
if (dto.colonyId !== undefined && dto.colonyId !== before.colonyId) {
|
||||||
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
|
|
||||||
movedColony = await tx.colony.findUnique({
|
movedColony = await tx.colony.findUnique({
|
||||||
where: { id: dto.colonyId },
|
where: { id: dto.colonyId },
|
||||||
select: { apiaryId: true },
|
select: { apiaryId: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// An explicit apiary corrects the stand on the day; it wins over the
|
||||||
|
// new colony apiary.
|
||||||
|
if (dto.apiaryId !== undefined) {
|
||||||
|
const apiary = await tx.apiary.findUnique({
|
||||||
|
where: { id: dto.apiaryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!apiary) {
|
||||||
|
throw new NotFoundException('Apiary not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Medicine, lot and dose are checked and booked only when the edit
|
||||||
|
// touches them: a notes-only edit of an old treatment must succeed
|
||||||
|
// even if its lot has since moved to another medicine. The name only
|
||||||
|
// follows the catalogue in that case, too.
|
||||||
|
const touchesMedicine = MEDICINE_FIELDS.some(
|
||||||
|
(field) => dto[field] !== undefined,
|
||||||
|
);
|
||||||
|
const medicine = touchesMedicine
|
||||||
|
? await resolveMedicineIn(
|
||||||
|
tx,
|
||||||
|
mergedMedicineInput(before, dto),
|
||||||
|
'update',
|
||||||
|
before.medicineId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const treatment = await tx.treatment.update({
|
const treatment = await tx.treatment.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...dto,
|
...dto,
|
||||||
apiaryId: movedColony ? movedColony.apiaryId : undefined,
|
...(medicine ? medicineColumns(medicine) : {}),
|
||||||
|
...(medicine && medicine.medicineLotId !== null
|
||||||
|
? LOT_TEXT_CLEARED
|
||||||
|
: {}),
|
||||||
|
apiaryId:
|
||||||
|
dto.apiaryId ?? (movedColony ? movedColony.apiaryId : undefined),
|
||||||
date: dto.date ? new Date(dto.date) : undefined,
|
date: dto.date ? new Date(dto.date) : undefined,
|
||||||
startDate: optionalDate(dto.startDate),
|
startDate: optionalDate(dto.startDate),
|
||||||
endDate: optionalDate(dto.endDate),
|
endDate: optionalDate(dto.endDate),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (medicine) {
|
||||||
|
await bookTreatmentIn(tx, treatment, actingUserId);
|
||||||
|
}
|
||||||
await this.auditLog.record(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
entityType: 'Treatment',
|
entityType: 'Treatment',
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { loadHerdApiaryRows } from '../src/compliance/herd-snapshot';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
|
||||||
|
interface NoticeBody {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
totalColonies: number;
|
||||||
|
sentOn: string | null;
|
||||||
|
openKey: string | null;
|
||||||
|
existingId?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Authority notices (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = Date.now();
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
const beekeeperEmail = `notices-e2e-beekeeper-${suffix}@example.com`;
|
||||||
|
const staffEmail = `notices-e2e-staff-${suffix}@example.com`;
|
||||||
|
let beekeeperToken: string;
|
||||||
|
let staffToken: string;
|
||||||
|
let noRightsToken: string;
|
||||||
|
let noRightsRoleId: string;
|
||||||
|
const noRightsEmail = `notices-e2e-norights-${suffix}@example.com`;
|
||||||
|
let apiaryId: string;
|
||||||
|
let keyCounter = 0;
|
||||||
|
|
||||||
|
const server = () => app.getHttpServer();
|
||||||
|
const as = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||||
|
const nextKey = () =>
|
||||||
|
`notices-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
for (const [email, roleKey] of [
|
||||||
|
[beekeeperEmail, 'BEEKEEPER'],
|
||||||
|
[staffEmail, 'STAFF'],
|
||||||
|
]) {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
name: roleKey,
|
||||||
|
role: { connect: { key: roleKey } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: { key: `NOTICES_E2E_${suffix}`, name: 'Ohne Rechte' },
|
||||||
|
});
|
||||||
|
noRightsRoleId = role.id;
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: noRightsEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'NoRights',
|
||||||
|
roleId: role.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
noRightsToken = (await login(app, noRightsEmail, password)).accessToken;
|
||||||
|
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
||||||
|
staffToken = (await login(app, staffEmail, password)).accessToken;
|
||||||
|
const apiary = await prisma.apiary.create({
|
||||||
|
data: { name: `Notices Stand ${suffix}` },
|
||||||
|
});
|
||||||
|
apiaryId = apiary.id;
|
||||||
|
await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `N-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// The test database is shared and not empty: the notices are this file's
|
||||||
|
// own table to clear.
|
||||||
|
await prisma.authorityNotice.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.authorityNotice.deleteMany();
|
||||||
|
await prisma.colony.deleteMany({ where: { apiaryId } });
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { user: { roleId: noRightsRoleId } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { roleId: noRightsRoleId } });
|
||||||
|
await prisma.role.deleteMany({ where: { id: noRightsRoleId } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function livingCount(): Promise<number> {
|
||||||
|
const rows = await loadHerdApiaryRows(prisma);
|
||||||
|
return rows.reduce((sum, row) => sum + row.colonyTypes.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('two parallel creates of the same draft: one 201, one 409 with existingId', async () => {
|
||||||
|
const send = () =>
|
||||||
|
request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.set('Idempotency-Key', nextKey())
|
||||||
|
.send({ kind: 'REGISTRATION' });
|
||||||
|
|
||||||
|
const responses = await Promise.all([send(), send()]);
|
||||||
|
|
||||||
|
const statuses = responses.map((response) => response.status).sort();
|
||||||
|
expect(statuses).toEqual([201, 409]);
|
||||||
|
const created = responses.find((response) => response.status === 201)!
|
||||||
|
.body as NoticeBody;
|
||||||
|
const conflict = responses.find((response) => response.status === 409)!
|
||||||
|
.body as NoticeBody;
|
||||||
|
expect(conflict.code).toBe('DRAFT_EXISTS');
|
||||||
|
expect(conflict.existingId).toBe(created.id);
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
status: 'DRAFT',
|
||||||
|
openKey: 'REGISTRATION',
|
||||||
|
});
|
||||||
|
expect(created.totalColonies).toBeGreaterThan(0);
|
||||||
|
expect(await prisma.authorityNotice.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated POST with the same Idempotency-Key returns the stored answer', async () => {
|
||||||
|
const key = nextKey();
|
||||||
|
const send = () =>
|
||||||
|
request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.set('Idempotency-Key', key)
|
||||||
|
.send({ kind: 'REGISTRATION' });
|
||||||
|
|
||||||
|
const first = await send();
|
||||||
|
const second = await send();
|
||||||
|
|
||||||
|
expect(first.status).toBe(201);
|
||||||
|
expect(second.status).toBe(201);
|
||||||
|
expect((second.body as NoticeBody).id).toBe((first.body as NoticeBody).id);
|
||||||
|
expect(await prisma.authorityNotice.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BEEKEEPER manages notices, STAFF may read but gets 403 on POST', async () => {
|
||||||
|
const created = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'REGISTRATION' })
|
||||||
|
.expect(201);
|
||||||
|
const id = (created.body as NoticeBody).id;
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(staffToken))
|
||||||
|
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||||
|
.expect(403);
|
||||||
|
await request(server())
|
||||||
|
.post(`/api/v1/authority-notices/${id}/cancel`)
|
||||||
|
.set(as(staffToken))
|
||||||
|
.expect(403);
|
||||||
|
await request(server())
|
||||||
|
.patch('/api/v1/compliance/settings')
|
||||||
|
.set(as(staffToken))
|
||||||
|
.send({ noticeOnApiaryChange: false })
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
const list = await request(server())
|
||||||
|
.get('/api/v1/authority-notices?kind=REGISTRATION')
|
||||||
|
.set(as(staffToken))
|
||||||
|
.expect(200);
|
||||||
|
expect((list.body as NoticeBody[]).map((notice) => notice.id)).toContain(
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
await request(server())
|
||||||
|
.get(`/api/v1/authority-notices/${id}`)
|
||||||
|
.set(as(staffToken))
|
||||||
|
.expect(200);
|
||||||
|
await request(server())
|
||||||
|
.get('/api/v1/compliance/settings')
|
||||||
|
.set(as(staffToken))
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a SENT registration with sentOn in the past is the baseline of the next herd change', async () => {
|
||||||
|
await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'HERD_CHANGE' })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
const registration = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'REGISTRATION', sentOn: '2026-01-10' })
|
||||||
|
.expect(201);
|
||||||
|
expect(registration.body).toMatchObject({
|
||||||
|
status: 'SENT',
|
||||||
|
openKey: null,
|
||||||
|
sentOn: '2026-01-10T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'REGISTRATION' })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
// The shared database may change meanwhile: the count lies between the
|
||||||
|
// counts taken before and after the request.
|
||||||
|
const before = await livingCount();
|
||||||
|
const change = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'HERD_CHANGE' })
|
||||||
|
.expect(201);
|
||||||
|
const after = await livingCount();
|
||||||
|
const body = change.body as NoticeBody;
|
||||||
|
expect(body).toMatchObject({ kind: 'HERD_CHANGE', status: 'DRAFT' });
|
||||||
|
expect(body.totalColonies).toBeGreaterThanOrEqual(Math.min(before, after));
|
||||||
|
expect(body.totalColonies).toBeLessThanOrEqual(Math.max(before, after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks a draft through mark-sent, reopen and cancel', async () => {
|
||||||
|
const created = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||||
|
.expect(201);
|
||||||
|
const id = (created.body as NoticeBody).id;
|
||||||
|
const url = `/api/v1/authority-notices/${id}`;
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.post(`${url}/mark-sent`)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ sentOn: '2027-02-01', sentVia: 'E-Mail' })
|
||||||
|
.expect(201);
|
||||||
|
await request(server())
|
||||||
|
.post(`${url}/recount`)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.expect(409);
|
||||||
|
await request(server())
|
||||||
|
.patch(url)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ totalColonies: 42 })
|
||||||
|
.expect(200);
|
||||||
|
const reopened = await request(server())
|
||||||
|
.post(`${url}/reopen`)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.expect(201);
|
||||||
|
expect(reopened.body).toMatchObject({
|
||||||
|
status: 'DRAFT',
|
||||||
|
totalColonies: 42,
|
||||||
|
openKey: 'TSK_REPORT:2027',
|
||||||
|
});
|
||||||
|
await request(server())
|
||||||
|
.post(`${url}/cancel`)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.expect(201);
|
||||||
|
await request(server())
|
||||||
|
.post(`${url}/mark-sent`)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({})
|
||||||
|
.expect(409);
|
||||||
|
await request(server())
|
||||||
|
.patch(url)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ notes: 'x' })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
const audit = await prisma.auditLog.findMany({
|
||||||
|
where: { entityType: 'AuthorityNotice', entityId: id },
|
||||||
|
});
|
||||||
|
expect(audit.map((entry) => entry.action).sort()).toEqual([
|
||||||
|
'CREATE',
|
||||||
|
'UPDATE',
|
||||||
|
'UPDATE',
|
||||||
|
'UPDATE',
|
||||||
|
'UPDATE',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopen answers 409 while another draft of the kind exists', async () => {
|
||||||
|
const sent = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
|
||||||
|
.expect(201);
|
||||||
|
const draft = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const reopen = await request(server())
|
||||||
|
.post(`/api/v1/authority-notices/${(sent.body as NoticeBody).id}/reopen`)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
expect((reopen.body as NoticeBody).existingId).toBe(
|
||||||
|
(draft.body as NoticeBody).id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a notice recorded as sent is created although a draft is open', async () => {
|
||||||
|
await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user without compliance rights sees neither notices nor addresses', async () => {
|
||||||
|
const created = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'REGISTRATION' })
|
||||||
|
.expect(201);
|
||||||
|
const id = (created.body as NoticeBody).id;
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.get('/api/v1/authority-notices')
|
||||||
|
.set(as(noRightsToken))
|
||||||
|
.expect(403);
|
||||||
|
await request(server())
|
||||||
|
.get(`/api/v1/authority-notices/${id}`)
|
||||||
|
.set(as(noRightsToken))
|
||||||
|
.expect(403);
|
||||||
|
await request(server())
|
||||||
|
.get('/api/v1/compliance/settings')
|
||||||
|
.set(as(noRightsToken))
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates the compliance settings', async () => {
|
||||||
|
await request(server())
|
||||||
|
.patch('/api/v1/compliance/settings')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ tskReferenceDay: '02-29' })
|
||||||
|
.expect(400);
|
||||||
|
await request(server())
|
||||||
|
.patch('/api/v1/compliance/settings')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ noticeOnApiaryChange: null })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const settings = await request(server())
|
||||||
|
.patch('/api/v1/compliance/settings')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ tskReferenceDay: '04-15', tskReportDueDay: null })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(settings.body).toMatchObject({
|
||||||
|
tskReferenceDay: '04-15',
|
||||||
|
tskReportDueDay: null,
|
||||||
|
});
|
||||||
|
await request(server())
|
||||||
|
.patch('/api/v1/compliance/settings')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ tskReferenceDay: '01-01' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -148,6 +148,39 @@ describe('Colonies (e2e)', () => {
|
|||||||
.expect(403);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts restrictionOverrideReason without a restriction, ignores it and never stores it', async () => {
|
||||||
|
const reason = 'Ohne Sperrbezirk wird die Begründung nur ignoriert';
|
||||||
|
const code = `V-${Date.now()}-R`;
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/colonies')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
code,
|
||||||
|
apiaryId: apiaryAId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
restrictionOverrideReason: reason,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const created = response.body as ColonyBody;
|
||||||
|
splitColonyIds.push(created.id);
|
||||||
|
expect(JSON.stringify(created)).not.toContain(reason);
|
||||||
|
const stored = await prisma.colony.findUniqueOrThrow({
|
||||||
|
where: { id: created.id },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(stored)).not.toContain(reason);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/colonies')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
code: `${code}2`,
|
||||||
|
apiaryId: apiaryAId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
restrictionOverrideReason: 'zu kurz',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('lets a BEEKEEPER create a colony', async () => {
|
it('lets a BEEKEEPER create a colony', async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/api/v1/colonies')
|
.post('/api/v1/colonies')
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { INestApplication } from '@nestjs/common';
|
|||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
|
import {
|
||||||
|
buildTreatmentLog,
|
||||||
|
treatmentLogGaps,
|
||||||
|
} from '../src/export/treatment-log';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { pdfText } from './utils/pdf-text';
|
||||||
import { login } from './utils/api';
|
import { login } from './utils/api';
|
||||||
|
|
||||||
describe('Export (e2e)', () => {
|
describe('Export (e2e)', () => {
|
||||||
@@ -214,4 +219,234 @@ describe('Export (e2e)', () => {
|
|||||||
'%PDF',
|
'%PDF',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Bestandsbuch from the catalogue and a lot', () => {
|
||||||
|
const suffix = Date.now();
|
||||||
|
const year = 2031;
|
||||||
|
let medicineId: string;
|
||||||
|
let lotId: string;
|
||||||
|
let treatmentColonyId: string;
|
||||||
|
let treatmentApiaryId: string;
|
||||||
|
let treatmentId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
treatmentApiaryId = (
|
||||||
|
await prisma.apiary.create({ data: { name: `Bestandsbuch ${suffix}` } })
|
||||||
|
).id;
|
||||||
|
treatmentColonyId = (
|
||||||
|
await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `BB-${suffix}`,
|
||||||
|
apiaryId: treatmentApiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
medicineId = (
|
||||||
|
await prisma.veterinaryMedicine.create({
|
||||||
|
data: {
|
||||||
|
name: `Bestand-Mittel ${suffix}`,
|
||||||
|
unit: 'ML',
|
||||||
|
prescriptionOnly: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
lotId = (
|
||||||
|
await prisma.veterinaryMedicineLot.create({
|
||||||
|
data: {
|
||||||
|
medicineId,
|
||||||
|
purchasedOn: new Date(`${year}-01-10`),
|
||||||
|
quantity: 100,
|
||||||
|
batchNumber: 'CH-4711',
|
||||||
|
supplier: 'Apotheke Bestand, Hauptstr. 9',
|
||||||
|
purchaseProof: 'Rechnung B-77',
|
||||||
|
prescribingVet: 'Dr. Vet Bestand',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||||
|
where: { lotId },
|
||||||
|
});
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateType: 'Treatment', aggregateId: treatmentId },
|
||||||
|
});
|
||||||
|
await prisma.treatment.deleteMany({
|
||||||
|
where: { colonyId: treatmentColonyId },
|
||||||
|
});
|
||||||
|
await prisma.veterinaryMedicineLot.deleteMany({ where: { id: lotId } });
|
||||||
|
await prisma.veterinaryMedicine.deleteMany({
|
||||||
|
where: { id: medicineId },
|
||||||
|
});
|
||||||
|
await prisma.colony.deleteMany({ where: { id: treatmentColonyId } });
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: treatmentApiaryId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a complete catalogue entry and lot give a Bestandsbuch PDF without FEHLT and treatmentLogGaps empty for every row', async () => {
|
||||||
|
const created = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/treatments')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
colonyId: treatmentColonyId,
|
||||||
|
date: `${year}-08-01`,
|
||||||
|
medicineLotId: lotId,
|
||||||
|
doseQuantity: 30,
|
||||||
|
withdrawalPeriodDays: 0,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
treatmentId = (created.body as { id: string }).id;
|
||||||
|
|
||||||
|
const log = await buildTreatmentLog(prisma, year, treatmentApiaryId);
|
||||||
|
const rows = log.sheets.flatMap((sheet) => sheet.rows);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
supplier: 'Apotheke Bestand, Hauptstr. 9',
|
||||||
|
purchaseProof: 'Rechnung B-77',
|
||||||
|
batchNumber: 'CH-4711',
|
||||||
|
prescribingVet: 'Dr. Vet Bestand',
|
||||||
|
amount: '30 ml',
|
||||||
|
prescriptionOnly: true,
|
||||||
|
lotId,
|
||||||
|
});
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(treatmentLogGaps(row)).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/api/v1/export/treatment-log.pdf?year=${year}&apiaryId=${treatmentApiaryId}`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.expect(200);
|
||||||
|
// Wrapped cells come out as several lines; a soft hyphen marks the
|
||||||
|
// wrap point.
|
||||||
|
const lines = pdfText(response.body as Buffer)
|
||||||
|
.replaceAll('', '')
|
||||||
|
.split('\n');
|
||||||
|
const text = lines.join(' ').replace(/ {2,}/g, ' ');
|
||||||
|
expect(text).toContain('Rechnung B-77');
|
||||||
|
expect(text).toContain('Apotheke Bestand');
|
||||||
|
expect(text).toContain('Tierarzt: Dr. Vet Bestand');
|
||||||
|
expect(text).toContain('30 ml');
|
||||||
|
// Only the footnote and the holder line (settings of the test DB) may
|
||||||
|
// name FEHLT, no table cell does.
|
||||||
|
const cells = lines
|
||||||
|
.filter(
|
||||||
|
(line) =>
|
||||||
|
!line.startsWith('"FEHLT" = ') &&
|
||||||
|
!line.startsWith('Name und Anschrift des Bienenhalters'),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
expect(cells).not.toContain('FEHLT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints FEHLT for a lot without supplier, proof and vet, matching treatmentLogGaps', async () => {
|
||||||
|
const bareYear = year + 1;
|
||||||
|
const bareLot = await prisma.veterinaryMedicineLot.create({
|
||||||
|
data: {
|
||||||
|
medicineId,
|
||||||
|
purchasedOn: new Date(`${bareYear}-01-10`),
|
||||||
|
quantity: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/treatments')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
colonyId: treatmentColonyId,
|
||||||
|
date: `${bareYear}-08-01`,
|
||||||
|
medicineLotId: bareLot.id,
|
||||||
|
doseQuantity: 5,
|
||||||
|
withdrawalPeriodDays: 0,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const bareTreatmentId = (created.body as { id: string }).id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const log = await buildTreatmentLog(
|
||||||
|
prisma,
|
||||||
|
bareYear,
|
||||||
|
treatmentApiaryId,
|
||||||
|
);
|
||||||
|
const rows = log.sheets.flatMap((sheet) => sheet.rows);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(treatmentLogGaps(rows[0])).toEqual(['SUPPLIER', 'PROOF', 'VET']);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/api/v1/export/treatment-log.pdf?year=${bareYear}&apiaryId=${treatmentApiaryId}`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const lines = pdfText(response.body as Buffer)
|
||||||
|
.replaceAll('', '')
|
||||||
|
.split('\n')
|
||||||
|
.filter(
|
||||||
|
(line) =>
|
||||||
|
!line.startsWith('"FEHLT" = ') &&
|
||||||
|
!line.startsWith('Name und Anschrift des Bienenhalters'),
|
||||||
|
);
|
||||||
|
const text = lines.join(' ').replace(/ {2,}/g, ' ');
|
||||||
|
// Supplier, vet and proof: exactly three gaps, no more.
|
||||||
|
expect(text.split('FEHLT').length - 1).toBe(3);
|
||||||
|
expect(text).toContain('Tierarzt: FEHLT');
|
||||||
|
expect(text).toContain('5 ml');
|
||||||
|
} finally {
|
||||||
|
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||||
|
where: { lotId: bareLot.id },
|
||||||
|
});
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateType: 'Treatment', aggregateId: bareTreatmentId },
|
||||||
|
});
|
||||||
|
await prisma.treatment.deleteMany({ where: { id: bareTreatmentId } });
|
||||||
|
await prisma.veterinaryMedicineLot.deleteMany({
|
||||||
|
where: { id: bareLot.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Bestandsbuch only with veterinary-medicines:read in addition to export:read', async () => {
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `EXPORT_ONLY_${suffix}`,
|
||||||
|
name: 'Export only',
|
||||||
|
permissions: {
|
||||||
|
create: [
|
||||||
|
{ permission: { connect: { key: 'export:read' } } },
|
||||||
|
{ permission: { connect: { key: 'colonies:read' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const email = `export-e2e-exportonly-${suffix}@example.com`;
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: 'Export only',
|
||||||
|
role: { connect: { id: role.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const token = (await login(app, email, password)).accessToken;
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/export/treatment-log.pdf?year=${year}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(403);
|
||||||
|
// Other exports are unchanged.
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/api/v1/export/colony-record-card.pdf?colonyId=${treatmentColonyId}`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200);
|
||||||
|
} finally {
|
||||||
|
await prisma.refreshToken.deleteMany({ where: { userId: user.id } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: user.id } });
|
||||||
|
await prisma.role.deleteMany({ where: { id: role.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -193,6 +193,36 @@ describe('Import (e2e)', () => {
|
|||||||
expect(report.rows[0].message).toContain('nicht gefunden');
|
expect(report.rows[0].message).toContain('nicht gefunden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses to import a colony into an apiary in a restricted zone', async () => {
|
||||||
|
const code = `CSV-V-Zone-${Date.now()}`;
|
||||||
|
const csv = `code,apiaryName,colonyType
|
||||||
|
${code},${apiaryName},PRODUCTION_COLONY`;
|
||||||
|
const restriction = await prisma.apiaryRestriction.create({
|
||||||
|
data: {
|
||||||
|
apiaryId,
|
||||||
|
disease: 'Faulbrut',
|
||||||
|
startsOn: new Date('2000-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/import/colonies')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.attach('file', csvFile(csv), {
|
||||||
|
filename: 'colonies.csv',
|
||||||
|
contentType: 'text/csv',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const report = response.body as ImportReportBody;
|
||||||
|
expect(report.created).toBe(0);
|
||||||
|
expect(report.errors).toBe(1);
|
||||||
|
expect(report.rows[0].message).toContain('Sperrbezirk');
|
||||||
|
expect(await prisma.colony.count({ where: { code } })).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await prisma.apiaryRestriction.delete({ where: { id: restriction.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('imports queens', async () => {
|
it('imports queens', async () => {
|
||||||
const code = `CSV-Q-${Date.now()}`;
|
const code = `CSV-Q-${Date.now()}`;
|
||||||
const csv = `code,breed\n${code},Carnica`;
|
const csv = `code,breed\n${code},Carnica`;
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
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 GapBody {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
interface ConflictBody {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
gaps: GapBody[];
|
||||||
|
}
|
||||||
|
interface CheckBody {
|
||||||
|
printable: boolean;
|
||||||
|
sample: boolean;
|
||||||
|
gaps: GapBody[];
|
||||||
|
warnings: GapBody[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 80;
|
||||||
|
|
||||||
|
function textElement(
|
||||||
|
id: string,
|
||||||
|
y: number,
|
||||||
|
placeholderKey: string,
|
||||||
|
fontSize = 9,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'text',
|
||||||
|
x: 2,
|
||||||
|
y,
|
||||||
|
width: SIZE,
|
||||||
|
height: 8,
|
||||||
|
text: '',
|
||||||
|
placeholderKey,
|
||||||
|
fontSize,
|
||||||
|
fontFamily: 'sans',
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
color: '#000000',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every mandatory particular, the net quantity big enough for 500 g.
|
||||||
|
const COMPLETE_ELEMENTS = [
|
||||||
|
textElement('legal', 1, 'legalName'),
|
||||||
|
textElement('net', 10, 'jarSize', 16),
|
||||||
|
textElement('mhd', 20, 'bestBeforeLine'),
|
||||||
|
textElement('lot', 29, 'lotLine'),
|
||||||
|
textElement('origin', 38, 'originCountry'),
|
||||||
|
textElement('firm', 47, 'foodBusinessName'),
|
||||||
|
textElement('address', 56, 'foodBusinessAddress'),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Label compliance (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = Date.now();
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
const printerEmail = `label-compliance-printer-${suffix}@example.com`;
|
||||||
|
const readerEmail = `label-compliance-reader-${suffix}@example.com`;
|
||||||
|
let printerId: string;
|
||||||
|
let readerId: string;
|
||||||
|
let readerRoleId: string;
|
||||||
|
let labelOnlyId: string;
|
||||||
|
let labelOnlyRoleId: string;
|
||||||
|
let labelOnlyToken: string;
|
||||||
|
const labelOnlyEmail = `label-compliance-labelonly-${suffix}@example.com`;
|
||||||
|
let printerToken: string;
|
||||||
|
let readerToken: string;
|
||||||
|
let productId: string;
|
||||||
|
let templateId: string;
|
||||||
|
let honeyBatchId: string;
|
||||||
|
let extractionId: string;
|
||||||
|
let bottlingId: string;
|
||||||
|
let settingsBefore: {
|
||||||
|
foodBusinessName: string | null;
|
||||||
|
foodBusinessAddress: string | null;
|
||||||
|
originCountry: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
async function setMasterData(complete: boolean): Promise<void> {
|
||||||
|
// Other suites clear this table; the check refuses without the row.
|
||||||
|
await prisma.complianceSettings.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
update: {},
|
||||||
|
create: { id: 'singleton' },
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
foodBusinessName: complete ? 'Imkerei Muster' : null,
|
||||||
|
foodBusinessAddress: complete ? 'Bühlweg 10, 86450 Altenmünster' : null,
|
||||||
|
originCountry: 'Deutschland',
|
||||||
|
};
|
||||||
|
await prisma.siteSettings.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
update: data,
|
||||||
|
create: { id: 'singleton', ...data },
|
||||||
|
});
|
||||||
|
await prisma.product.update({
|
||||||
|
where: { id: productId },
|
||||||
|
data: { legalName: complete ? 'Blütenhonig' : null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const printUrl = (query = '') =>
|
||||||
|
`/api/v1/label-templates/${templateId}/print/${bottlingId}${query}`;
|
||||||
|
|
||||||
|
const printAuditCount = () =>
|
||||||
|
prisma.auditLog.count({
|
||||||
|
where: {
|
||||||
|
entityType: 'BottlingBatch',
|
||||||
|
entityId: bottlingId,
|
||||||
|
action: 'PRINT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const beekeeper = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: printerEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Printer',
|
||||||
|
role: { connect: { key: 'ADMIN' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
printerId = beekeeper.id;
|
||||||
|
// The test database grants the label permissions to ADMIN only, so the
|
||||||
|
// "can look but not print" user gets a role of its own.
|
||||||
|
const [readPermission, bottlingRead] = await Promise.all([
|
||||||
|
prisma.permission.findUniqueOrThrow({
|
||||||
|
where: { key: 'label-templates:read' },
|
||||||
|
}),
|
||||||
|
prisma.permission.findUniqueOrThrow({ where: { key: 'bottling:read' } }),
|
||||||
|
]);
|
||||||
|
const readerRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `label-compliance-reader-${suffix}`,
|
||||||
|
name: 'Label and bottling reader',
|
||||||
|
permissions: {
|
||||||
|
create: [
|
||||||
|
{ permissionId: readPermission.id },
|
||||||
|
{ permissionId: bottlingRead.id },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readerRoleId = readerRole.id;
|
||||||
|
const reader = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: readerEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Reader',
|
||||||
|
roleId: readerRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readerId = reader.id;
|
||||||
|
// Sees label templates, but not bottlings.
|
||||||
|
const labelOnlyRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `label-compliance-labelonly-${suffix}`,
|
||||||
|
name: 'Label reader',
|
||||||
|
permissions: { create: { permissionId: readPermission.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
labelOnlyRoleId = labelOnlyRole.id;
|
||||||
|
const labelOnly = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: labelOnlyEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Label only',
|
||||||
|
roleId: labelOnlyRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
labelOnlyId = labelOnly.id;
|
||||||
|
labelOnlyToken = (await login(app, labelOnlyEmail, password)).accessToken;
|
||||||
|
printerToken = (await login(app, printerEmail, password)).accessToken;
|
||||||
|
readerToken = (await login(app, readerEmail, password)).accessToken;
|
||||||
|
|
||||||
|
settingsBefore = await prisma.siteSettings.findUnique({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
select: {
|
||||||
|
foodBusinessName: true,
|
||||||
|
foodBusinessAddress: true,
|
||||||
|
originCountry: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = await prisma.product.create({
|
||||||
|
data: { name: `Label-Compliance-Glas ${suffix}` },
|
||||||
|
});
|
||||||
|
productId = product.id;
|
||||||
|
const extraction = await prisma.extraction.create({
|
||||||
|
data: { date: new Date(), userId: printerId },
|
||||||
|
});
|
||||||
|
extractionId = extraction.id;
|
||||||
|
const honeyBatch = await prisma.honeyBatch.create({
|
||||||
|
data: {
|
||||||
|
batchNumber: `LC-${suffix}`,
|
||||||
|
extractionId,
|
||||||
|
amount: 25,
|
||||||
|
initialAmount: 25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
honeyBatchId = honeyBatch.id;
|
||||||
|
const bottling = await prisma.bottlingBatch.create({
|
||||||
|
data: {
|
||||||
|
honeyBatchId,
|
||||||
|
productId,
|
||||||
|
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||||
|
jarSize: 500,
|
||||||
|
jarCount: 2,
|
||||||
|
totalAmount: 1,
|
||||||
|
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||||
|
lotNumber: `L-${suffix}`,
|
||||||
|
userId: printerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
bottlingId = bottling.id;
|
||||||
|
const template = await prisma.labelTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: `Label-Compliance ${suffix}`,
|
||||||
|
elements: COMPLETE_ELEMENTS,
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
templateId = template.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.auditLog.deleteMany({
|
||||||
|
where: { entityType: 'BottlingBatch', entityId: bottlingId },
|
||||||
|
});
|
||||||
|
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingId } });
|
||||||
|
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
|
||||||
|
await prisma.extraction.deleteMany({ where: { id: extractionId } });
|
||||||
|
await prisma.labelTemplate.deleteMany({ where: { id: templateId } });
|
||||||
|
await prisma.product.deleteMany({ where: { id: productId } });
|
||||||
|
if (settingsBefore) {
|
||||||
|
await prisma.siteSettings.update({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
data: settingsBefore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: { in: [printerId, readerId, labelOnlyId] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { id: { in: [printerId, readerId, labelOnlyId] } },
|
||||||
|
});
|
||||||
|
await prisma.role.deleteMany({
|
||||||
|
where: { id: { in: [readerRoleId, labelOnlyRoleId] } },
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409 with the gap list, then 200 and an audit PRINT after the master data is complete', async () => {
|
||||||
|
await setMasterData(false);
|
||||||
|
|
||||||
|
const refused = await request(app.getHttpServer())
|
||||||
|
.get(printUrl())
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(409);
|
||||||
|
const body = refused.body as ConflictBody;
|
||||||
|
expect(body.code).toBe('LABEL_PARTICULARS_MISSING');
|
||||||
|
expect(body.gaps.map((gap) => gap.code).sort()).toEqual([
|
||||||
|
'FOOD_BUSINESS',
|
||||||
|
'LEGAL_NAME',
|
||||||
|
]);
|
||||||
|
expect(await printAuditCount()).toBe(0);
|
||||||
|
|
||||||
|
await setMasterData(true);
|
||||||
|
const printed = await request(app.getHttpServer())
|
||||||
|
.get(printUrl())
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(printed.headers['content-type']).toContain('application/pdf');
|
||||||
|
expect((printed.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
|
||||||
|
'%PDF',
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = await prisma.auditLog.findMany({
|
||||||
|
where: {
|
||||||
|
entityType: 'BottlingBatch',
|
||||||
|
entityId: bottlingId,
|
||||||
|
action: 'PRINT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].userId).toBe(printerId);
|
||||||
|
expect(entries[0].afterData).toEqual({
|
||||||
|
templateId,
|
||||||
|
copies: 2,
|
||||||
|
proof: false,
|
||||||
|
gapCodes: [],
|
||||||
|
warningCodes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET print with proof=true answers 200 although a particular is missing', async () => {
|
||||||
|
await setMasterData(false);
|
||||||
|
const before = await printAuditCount();
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(printUrl('?proof=true'))
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
|
||||||
|
'%PDF',
|
||||||
|
);
|
||||||
|
expect(await printAuditCount()).toBe(before + 1);
|
||||||
|
const latest = await prisma.auditLog.findFirst({
|
||||||
|
where: { entityType: 'BottlingBatch', entityId: bottlingId },
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
});
|
||||||
|
expect(latest?.afterData).toMatchObject({
|
||||||
|
proof: true,
|
||||||
|
gapCodes: expect.arrayContaining(['LEGAL_NAME']) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed proof flag with 400', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(printUrl('?proof=maybe'))
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET check answers the same gaps the print refuses with', async () => {
|
||||||
|
await setMasterData(false);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
|
||||||
|
.set('Authorization', `Bearer ${readerToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const body = response.body as CheckBody;
|
||||||
|
expect(body.printable).toBe(false);
|
||||||
|
expect(body.sample).toBe(false);
|
||||||
|
expect(body.gaps.map((gap) => gap.code).sort()).toEqual([
|
||||||
|
'FOOD_BUSINESS',
|
||||||
|
'LEGAL_NAME',
|
||||||
|
]);
|
||||||
|
expect(response.headers['cache-control']).toContain('no-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST check without a bottling uses the sample values', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/label-templates/check')
|
||||||
|
.set('Authorization', `Bearer ${readerToken}`)
|
||||||
|
.send({
|
||||||
|
elements: [textElement('legal', 1, 'legalName')],
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const body = response.body as CheckBody;
|
||||||
|
expect(body.sample).toBe(true);
|
||||||
|
expect(body.gaps.map((gap) => gap.code)).not.toContain('LEGAL_NAME');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST check rejects an image element with 400', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/label-templates/check')
|
||||||
|
.set('Authorization', `Bearer ${readerToken}`)
|
||||||
|
.send({
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 'img',
|
||||||
|
type: 'image',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
fontSize: 9,
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST check with 101 elements answers 400', async () => {
|
||||||
|
const elements = Array.from({ length: 101 }, (_, index) =>
|
||||||
|
textElement(`el-${index}`, 1, 'legalName'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/label-templates/check')
|
||||||
|
.set('Authorization', `Bearer ${readerToken}`)
|
||||||
|
.send({ elements, widthMm: 90, heightMm: 70, minXHeightMm: 1.2 })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('print needs label-templates:print, check needs label-templates:read', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(printUrl('?proof=true'))
|
||||||
|
.set('Authorization', `Bearer ${readerToken}`)
|
||||||
|
.expect(403);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
|
||||||
|
.expect(401);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/label-templates/check')
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('?proof=false with a gap answers 409, ?proof=0 answers 400', async () => {
|
||||||
|
await setMasterData(false);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(printUrl('?proof=false'))
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(409);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(printUrl('?proof=0'))
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['0', '-1', '1001', 'x'])(
|
||||||
|
'startPosition=%s answers 400',
|
||||||
|
async (value) => {
|
||||||
|
await setMasterData(true);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(printUrl(`?startPosition=${value}`))
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(400);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('a label-only reader gets 403 on check and print, and on a draft check with a bottling', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
|
||||||
|
.set('Authorization', `Bearer ${labelOnlyToken}`)
|
||||||
|
.expect(403);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/label-templates/check')
|
||||||
|
.set('Authorization', `Bearer ${labelOnlyToken}`)
|
||||||
|
.send({
|
||||||
|
elements: [textElement('legal', 1, 'legalName')],
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
bottlingBatchId: bottlingId,
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a label-only reader may check a draft with the sample values', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/label-templates/check')
|
||||||
|
.set('Authorization', `Bearer ${labelOnlyToken}`)
|
||||||
|
.send({
|
||||||
|
elements: [textElement('legal', 1, 'legalName')],
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST check rejects duplicate element ids and out-of-range sizes with 400', async () => {
|
||||||
|
const draft = (elements: unknown[]) => ({
|
||||||
|
elements,
|
||||||
|
widthMm: 90,
|
||||||
|
heightMm: 70,
|
||||||
|
minXHeightMm: 1.2,
|
||||||
|
});
|
||||||
|
const element = textElement('same', 1, 'legalName');
|
||||||
|
|
||||||
|
for (const body of [
|
||||||
|
draft([element, element]),
|
||||||
|
draft([{ ...element, width: 5000 }]),
|
||||||
|
draft([{ ...element, x: -5000 }]),
|
||||||
|
{ ...draft([element]), widthMm: 5000 },
|
||||||
|
]) {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/label-templates/check')
|
||||||
|
.set('Authorization', `Bearer ${readerToken}`)
|
||||||
|
.send(body)
|
||||||
|
.expect(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('print and check answer 409 when the compliance settings row is missing', async () => {
|
||||||
|
await setMasterData(true);
|
||||||
|
const saved = await prisma.complianceSettings.findUniqueOrThrow({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
});
|
||||||
|
const restore = {
|
||||||
|
...saved,
|
||||||
|
netQuantityDigitRules: saved.netQuantityDigitRules as object,
|
||||||
|
};
|
||||||
|
await prisma.complianceSettings.delete({ where: { id: 'singleton' } });
|
||||||
|
try {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(printUrl())
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(409);
|
||||||
|
expect((response.body as { code: string }).code).toBe(
|
||||||
|
'COMPLIANCE_SETTINGS_INVALID',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.complianceSettings.create({ data: restore });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the response of a print is no-store', async () => {
|
||||||
|
await setMasterData(true);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(printUrl())
|
||||||
|
.set('Authorization', `Bearer ${printerToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toContain('no-store');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { addDays, berlinToday, dayToDate } from '../src/common/dates';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { lockRowsForShare } from '../src/prisma/row-lock';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
|
||||||
|
interface ColonyBody {
|
||||||
|
id: string;
|
||||||
|
apiaryId: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestrictedBody {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
restrictionIds: string[];
|
||||||
|
restrictions: {
|
||||||
|
id: string;
|
||||||
|
apiaryId: string;
|
||||||
|
apiaryName: string;
|
||||||
|
disease: string;
|
||||||
|
startsOn: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestrictionBody {
|
||||||
|
id: string;
|
||||||
|
apiaryId: string;
|
||||||
|
endsOn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIFECYCLE_PERMISSIONS = [
|
||||||
|
'colonies:read',
|
||||||
|
'colonies:create',
|
||||||
|
'colonies:update',
|
||||||
|
'colonies:manage-lifecycle',
|
||||||
|
'apiaries:read',
|
||||||
|
];
|
||||||
|
const REASON = 'Amtstierarzt hat die Verbringung am Telefon genehmigt';
|
||||||
|
const DISEASE = 'Amerikanische Faulbrut';
|
||||||
|
|
||||||
|
describe('Restricted zone (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = Date.now();
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
const userIds: string[] = [];
|
||||||
|
const roleIds: string[] = [];
|
||||||
|
let beekeeperToken: string;
|
||||||
|
let lifecycleToken: string;
|
||||||
|
let staffToken: string;
|
||||||
|
let readonlyToken: string;
|
||||||
|
let apiariesOnlyToken: string;
|
||||||
|
let apiaryRestrictedId: string;
|
||||||
|
let apiaryFreeId: string;
|
||||||
|
let apiaryOtherId: string;
|
||||||
|
const colonyIds: string[] = [];
|
||||||
|
|
||||||
|
async function user(
|
||||||
|
label: string,
|
||||||
|
role: { connect: { key: string } } | { connect: { id: string } },
|
||||||
|
): Promise<string> {
|
||||||
|
const email = `restricted-zone-${label}-${suffix}@example.com`;
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: label,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userIds.push(created.id);
|
||||||
|
return (await login(app, email, password)).accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function colonyIn(apiaryId: string): Promise<string> {
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `RZ-${suffix}-${colonyIds.length}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
colonyIds.push(colony.id);
|
||||||
|
return colony.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restrict(apiaryId: string, endsOn: string | null = null) {
|
||||||
|
return prisma.apiaryRestriction.create({
|
||||||
|
data: {
|
||||||
|
apiaryId,
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: dayToDate('2000-01-01'),
|
||||||
|
endsOn: endsOn ? dayToDate(endsOn) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiaryIds = (): string[] => [
|
||||||
|
apiaryRestrictedId,
|
||||||
|
apiaryFreeId,
|
||||||
|
apiaryOtherId,
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `RZ_LIFECYCLE_${suffix}`,
|
||||||
|
name: 'Voelker ohne Behoerden',
|
||||||
|
permissions: {
|
||||||
|
create: LIFECYCLE_PERMISSIONS.map((key) => ({
|
||||||
|
permission: { connect: { key } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
roleIds.push(role.id);
|
||||||
|
beekeeperToken = await user('beekeeper', { connect: { key: 'BEEKEEPER' } });
|
||||||
|
lifecycleToken = await user('lifecycle', { connect: { id: role.id } });
|
||||||
|
staffToken = await user('staff', { connect: { key: 'STAFF' } });
|
||||||
|
readonlyToken = await user('readonly', { connect: { key: 'READONLY' } });
|
||||||
|
const apiariesOnly = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `RZ_APIARIES_ONLY_${suffix}`,
|
||||||
|
name: 'Nur Staende',
|
||||||
|
permissions: {
|
||||||
|
create: [{ permission: { connect: { key: 'apiaries:read' } } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
roleIds.push(apiariesOnly.id);
|
||||||
|
apiariesOnlyToken = await user('apiaries-only', {
|
||||||
|
connect: { id: apiariesOnly.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
apiaryRestrictedId = (
|
||||||
|
await prisma.apiary.create({ data: { name: `RZ Sperre ${suffix}` } })
|
||||||
|
).id;
|
||||||
|
apiaryFreeId = (
|
||||||
|
await prisma.apiary.create({ data: { name: `RZ Frei ${suffix}` } })
|
||||||
|
).id;
|
||||||
|
apiaryOtherId = (
|
||||||
|
await prisma.apiary.create({ data: { name: `RZ Weitere ${suffix}` } })
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.apiaryRestriction.deleteMany({
|
||||||
|
where: { apiaryId: { in: apiaryIds() } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.apiaryRestriction.deleteMany({
|
||||||
|
where: { apiaryId: { in: apiaryIds() } },
|
||||||
|
});
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
const harvests = await prisma.harvest.findMany({
|
||||||
|
where: { apiaryId: { in: apiaryIds() } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const harvestIds = harvests.map((harvest) => harvest.id);
|
||||||
|
await prisma.harvestColony.deleteMany({
|
||||||
|
where: { harvestId: { in: harvestIds } },
|
||||||
|
});
|
||||||
|
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
|
||||||
|
await prisma.colonyLineage.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fromColonyId: { in: colonyIds } },
|
||||||
|
{ toColonyId: { in: colonyIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.colonyApiaryHistory.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.colony.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: { in: colonyIds } }, { apiaryId: { in: apiaryIds() } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: { in: apiaryIds() } } });
|
||||||
|
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 app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE', async () => {
|
||||||
|
await restrict(apiaryRestrictedId);
|
||||||
|
const inFree = await colonyIn(apiaryFreeId);
|
||||||
|
const inRestricted = await colonyIn(apiaryRestrictedId);
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||||
|
|
||||||
|
const created = await http()
|
||||||
|
.post('/api/v1/colonies')
|
||||||
|
.set(auth)
|
||||||
|
.send({
|
||||||
|
code: `RZ-new-${suffix}`,
|
||||||
|
apiaryId: apiaryRestrictedId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
})
|
||||||
|
.expect(409);
|
||||||
|
const body = created.body as RestrictedBody;
|
||||||
|
expect(body.code).toBe('RESTRICTED_ZONE');
|
||||||
|
expect(body.restrictionIds).toHaveLength(1);
|
||||||
|
expect(body.restrictions[0]).toMatchObject({
|
||||||
|
apiaryId: apiaryRestrictedId,
|
||||||
|
apiaryName: `RZ Sperre ${suffix}`,
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: '2000-01-01',
|
||||||
|
});
|
||||||
|
expect(body.message).toContain(`RZ Sperre ${suffix}`);
|
||||||
|
expect(
|
||||||
|
await prisma.colony.count({ where: { code: `RZ-new-${suffix}` } }),
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.patch(`/api/v1/colonies/${inFree}`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ apiaryId: apiaryRestrictedId })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/api/v1/colonies/${inFree}/move`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ apiaryId: apiaryRestrictedId })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/api/v1/colonies/${inRestricted}/move`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ apiaryId: apiaryFreeId })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/api/v1/colonies/${inRestricted}`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ apiaryId: apiaryFreeId })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/api/v1/colonies/${inFree}/merge`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ mergedColonyIds: [inRestricted] })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
const after = await prisma.colony.findMany({
|
||||||
|
where: { id: { in: [inFree, inRestricted] } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
after.map((colony) => [colony.id, colony.apiaryId, colony.status]),
|
||||||
|
).toEqual(
|
||||||
|
[
|
||||||
|
[inFree, apiaryFreeId, 'ACTIVE'],
|
||||||
|
[inRestricted, apiaryRestrictedId, 'ACTIVE'],
|
||||||
|
].sort(([a], [b]) => (a < b ? -1 : 1)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an override with compliance:manage answers 200, writes audit and the history note, and never stores the reason on the colony', async () => {
|
||||||
|
const restriction = await restrict(apiaryRestrictedId);
|
||||||
|
const colonyId = await colonyIn(apiaryFreeId);
|
||||||
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||||
|
|
||||||
|
const patched = await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/colonies/${colonyId}`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON })
|
||||||
|
.expect(200);
|
||||||
|
expect((patched.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId);
|
||||||
|
const moved = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set(auth)
|
||||||
|
.send({
|
||||||
|
apiaryId: apiaryFreeId,
|
||||||
|
notes: 'Rueckfuehrung',
|
||||||
|
restrictionOverrideReason: REASON,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect((moved.body as ColonyBody).apiaryId).toBe(apiaryFreeId);
|
||||||
|
|
||||||
|
const colony = await prisma.colony.findUniqueOrThrow({
|
||||||
|
where: { id: colonyId },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(colony)).not.toContain(REASON);
|
||||||
|
const history = await prisma.colonyApiaryHistory.findMany({
|
||||||
|
where: { colonyId },
|
||||||
|
orderBy: { movedAt: 'asc' },
|
||||||
|
});
|
||||||
|
expect(history.map((entry) => entry.notes)).toEqual([
|
||||||
|
`Ausnahme Sperrbezirk: ${REASON}\nKorrektur über Volk bearbeiten`,
|
||||||
|
`Ausnahme Sperrbezirk: ${REASON}\nRueckfuehrung`,
|
||||||
|
]);
|
||||||
|
const audits = await prisma.auditLog.findMany({
|
||||||
|
where: { entityType: 'Colony', entityId: colonyId, action: 'UPDATE' },
|
||||||
|
});
|
||||||
|
expect(audits).toHaveLength(2);
|
||||||
|
for (const audit of audits) {
|
||||||
|
expect(
|
||||||
|
(audit.afterData as { restrictionOverride: unknown })
|
||||||
|
.restrictionOverride,
|
||||||
|
).toEqual({ reason: REASON, restrictionIds: [restriction.id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an override without compliance:manage answers 403 and an override reason of 5 characters answers 400', async () => {
|
||||||
|
await restrict(apiaryRestrictedId);
|
||||||
|
const colonyId = await colonyIn(apiaryFreeId);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set('Authorization', `Bearer ${lifecycleToken}`)
|
||||||
|
.send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON })
|
||||||
|
.expect(403);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
apiaryId: apiaryRestrictedId,
|
||||||
|
restrictionOverrideReason: 'kurz!',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
apiaryId: apiaryRestrictedId,
|
||||||
|
restrictionOverrideReason: ' '.repeat(12),
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
// A reason without compliance:manage is a 403 even when nothing is restricted.
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set('Authorization', `Bearer ${lifecycleToken}`)
|
||||||
|
.send({ apiaryId: apiaryOtherId, restrictionOverrideReason: REASON })
|
||||||
|
.expect(403);
|
||||||
|
expect(
|
||||||
|
(await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }))
|
||||||
|
.apiaryId,
|
||||||
|
).toBe(apiaryFreeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('split inside the apiary and a harvest at a restricted apiary succeed', async () => {
|
||||||
|
await restrict(apiaryRestrictedId);
|
||||||
|
const colonyId = await colonyIn(apiaryRestrictedId);
|
||||||
|
|
||||||
|
const split = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/split`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({ code: `RZ-split-${suffix}`, colonyType: 'NUCLEUS_COLONY' })
|
||||||
|
.expect(201);
|
||||||
|
colonyIds.push((split.body as ColonyBody).id);
|
||||||
|
expect((split.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/harvests')
|
||||||
|
.set('Authorization', `Bearer ${staffToken}`)
|
||||||
|
.send({
|
||||||
|
apiaryId: apiaryRestrictedId,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
colonyIds: [colonyId],
|
||||||
|
netWeight: 12,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a restriction ending yesterday no longer blocks, one ending today still does', async () => {
|
||||||
|
const today = berlinToday();
|
||||||
|
const colonyId = await colonyIn(apiaryFreeId);
|
||||||
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||||
|
await restrict(apiaryRestrictedId, today);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ apiaryId: apiaryRestrictedId })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
await prisma.apiaryRestriction.updateMany({
|
||||||
|
where: { apiaryId: apiaryRestrictedId },
|
||||||
|
data: { endsOn: dayToDate(addDays(today, -1)) },
|
||||||
|
});
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ apiaryId: apiaryRestrictedId })
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /public routes never contain restriction data', async () => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
});
|
||||||
|
const lotNumber = `RZ-LOT-${suffix}`;
|
||||||
|
await restrict(apiaryRestrictedId);
|
||||||
|
const colonyId = await colonyIn(apiaryRestrictedId);
|
||||||
|
const harvest = await prisma.harvest.create({
|
||||||
|
data: {
|
||||||
|
apiaryId: apiaryRestrictedId,
|
||||||
|
date: new Date('2026-08-12'),
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.harvestColony.create({
|
||||||
|
data: { harvestId: harvest.id, colonyId },
|
||||||
|
});
|
||||||
|
const extraction = await prisma.extraction.create({
|
||||||
|
data: { date: new Date('2026-08-13'), userId: user.id },
|
||||||
|
});
|
||||||
|
await prisma.extractionHarvest.create({
|
||||||
|
data: { extractionId: extraction.id, harvestId: harvest.id },
|
||||||
|
});
|
||||||
|
const honeyBatch = await prisma.honeyBatch.create({
|
||||||
|
data: {
|
||||||
|
batchNumber: `RZ-HC-${suffix}`,
|
||||||
|
extractionId: extraction.id,
|
||||||
|
amount: 25,
|
||||||
|
honeyType: 'Sommerhonig',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const product = await prisma.product.create({
|
||||||
|
data: { name: `RZ Honig ${suffix}` },
|
||||||
|
});
|
||||||
|
const bottling = await prisma.bottlingBatch.create({
|
||||||
|
data: {
|
||||||
|
honeyBatchId: honeyBatch.id,
|
||||||
|
productId: product.id,
|
||||||
|
date: new Date('2026-08-20'),
|
||||||
|
jarSize: 500,
|
||||||
|
jarCount: 10,
|
||||||
|
totalAmount: 5,
|
||||||
|
bestBeforeDate: new Date('2028-08-20'),
|
||||||
|
lotNumber,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/public/batches/${lotNumber}`)
|
||||||
|
.expect(200);
|
||||||
|
const text = JSON.stringify(response.body).toLowerCase();
|
||||||
|
for (const secret of [DISEASE, 'faulbrut', 'restriction', 'sperr']) {
|
||||||
|
expect(text).not.toContain(secret.toLowerCase());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await prisma.bottlingBatch.delete({ where: { id: bottling.id } });
|
||||||
|
await prisma.product.delete({ where: { id: product.id } });
|
||||||
|
await prisma.honeyBatch.delete({ where: { id: honeyBatch.id } });
|
||||||
|
await prisma.extractionHarvest.deleteMany({
|
||||||
|
where: { extractionId: extraction.id },
|
||||||
|
});
|
||||||
|
await prisma.extraction.delete({ where: { id: extraction.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('READONLY may read restrictions but not create them (403)', async () => {
|
||||||
|
const auth = { Authorization: `Bearer ${readonlyToken}` };
|
||||||
|
const restriction = await restrict(apiaryRestrictedId);
|
||||||
|
|
||||||
|
const active = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/apiary-restrictions?active=true')
|
||||||
|
.set(auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
(active.body as RestrictedBody['restrictions']).map((entry) => entry.id),
|
||||||
|
).toContain(restriction.id);
|
||||||
|
const ofApiary = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/apiaries/${apiaryRestrictedId}/restrictions`)
|
||||||
|
.set(auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
(ofApiary.body as RestrictionBody[]).map((entry) => entry.id),
|
||||||
|
).toEqual([restriction.id]);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/apiary-restrictions')
|
||||||
|
.set(auth)
|
||||||
|
.send({
|
||||||
|
apiaryIds: [apiaryFreeId],
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/apiary-restrictions/${restriction.id}`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ endsOn: '2026-12-01' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one restriction per apiary, ends it by endsOn and keeps the row', async () => {
|
||||||
|
const auth = { Authorization: `Bearer ${beekeeperToken}` };
|
||||||
|
|
||||||
|
const created = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/apiary-restrictions')
|
||||||
|
.set(auth)
|
||||||
|
.send({
|
||||||
|
apiaryIds: [apiaryFreeId, apiaryOtherId],
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
authority: 'Veterinaeramt',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const rows = created.body as RestrictionBody[];
|
||||||
|
expect(rows.map((row) => row.apiaryId).sort()).toEqual(
|
||||||
|
[apiaryFreeId, apiaryOtherId].sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/apiary-restrictions')
|
||||||
|
.set(auth)
|
||||||
|
.send({
|
||||||
|
apiaryIds: [apiaryFreeId, 'does-not-exist'],
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
expect(
|
||||||
|
await prisma.apiaryRestriction.count({
|
||||||
|
where: { apiaryId: { in: apiaryIds() } },
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/apiary-restrictions')
|
||||||
|
.set(auth)
|
||||||
|
.send({
|
||||||
|
apiaryIds: [apiaryFreeId],
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: '2026-10-05',
|
||||||
|
endsOn: '2026-10-01',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const ended = await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ endsOn: '2026-10-20' })
|
||||||
|
.expect(200);
|
||||||
|
expect((ended.body as RestrictionBody).endsOn).toContain('2026-10-20');
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ endsOn: '2026-09-01' })
|
||||||
|
.expect(400);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/apiary-restrictions/${rows[0].id}`)
|
||||||
|
.set(auth)
|
||||||
|
.send({ endsOn: null })
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
await prisma.auditLog.count({
|
||||||
|
where: { entityType: 'ApiaryRestriction', entityId: rows[0].id },
|
||||||
|
}),
|
||||||
|
).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a restriction created while a move into that apiary is in flight: the move commits first, the restriction waits (FOR UPDATE vs FOR SHARE)', async () => {
|
||||||
|
let letMoveCommit!: () => void;
|
||||||
|
const commitSignal = new Promise<void>((resolve) => {
|
||||||
|
letMoveCommit = resolve;
|
||||||
|
});
|
||||||
|
let moveHoldsLock!: () => void;
|
||||||
|
const lockHeld = new Promise<void>((resolve) => {
|
||||||
|
moveHoldsLock = resolve;
|
||||||
|
});
|
||||||
|
// What a move into the apiary holds between its lock and its commit.
|
||||||
|
const moveInFlight = prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await lockRowsForShare(tx, 'Apiary', [apiaryFreeId]);
|
||||||
|
moveHoldsLock();
|
||||||
|
await commitSignal;
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
await lockHeld;
|
||||||
|
|
||||||
|
let answered = false;
|
||||||
|
const creating = request(app.getHttpServer())
|
||||||
|
.post('/api/v1/apiary-restrictions')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
apiaryIds: [apiaryFreeId],
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: '2026-10-01',
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
answered = true;
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
expect(answered).toBe(false);
|
||||||
|
|
||||||
|
letMoveCommit();
|
||||||
|
await moveInFlight;
|
||||||
|
expect((await creating).status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('details (authority, reference, notes, creator) need compliance:read; apiaries:read alone gets the summary', async () => {
|
||||||
|
const restriction = await prisma.apiaryRestriction.create({
|
||||||
|
data: {
|
||||||
|
apiaryId: apiaryRestrictedId,
|
||||||
|
disease: DISEASE,
|
||||||
|
authority: 'Veterinaeramt Geheim',
|
||||||
|
reference: 'AZ-GEHEIM-1',
|
||||||
|
notes: 'interne Notiz',
|
||||||
|
startsOn: dayToDate('2000-01-01'),
|
||||||
|
createdByUserId: userIds[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const paths = [
|
||||||
|
'/api/v1/apiary-restrictions',
|
||||||
|
`/api/v1/apiaries/${apiaryRestrictedId}/restrictions`,
|
||||||
|
];
|
||||||
|
for (const path of paths) {
|
||||||
|
const minimal = await request(app.getHttpServer())
|
||||||
|
.get(path)
|
||||||
|
.set('Authorization', `Bearer ${apiariesOnlyToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const rows = (minimal.body as RestrictionBody[]).filter(
|
||||||
|
(row) => row.id === restriction.id,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(Object.keys(rows[0]).sort()).toEqual([
|
||||||
|
'apiaryId',
|
||||||
|
'disease',
|
||||||
|
'endsOn',
|
||||||
|
'id',
|
||||||
|
'startsOn',
|
||||||
|
]);
|
||||||
|
const text = JSON.stringify(minimal.body);
|
||||||
|
for (const secret of ['Geheim', 'AZ-GEHEIM-1', 'interne Notiz']) {
|
||||||
|
expect(text).not.toContain(secret);
|
||||||
|
}
|
||||||
|
const full = await request(app.getHttpServer())
|
||||||
|
.get(path)
|
||||||
|
.set('Authorization', `Bearer ${readonlyToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const detailed = (full.body as Record<string, unknown>[]).find(
|
||||||
|
(row) => row.id === restriction.id,
|
||||||
|
);
|
||||||
|
expect(detailed).toMatchObject({
|
||||||
|
authority: 'Veterinaeramt Geheim',
|
||||||
|
reference: 'AZ-GEHEIM-1',
|
||||||
|
notes: 'interne Notiz',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a move and a restriction on the target apiary racing each other end consistently', async () => {
|
||||||
|
for (let round = 0; round < 4; round += 1) {
|
||||||
|
await prisma.apiaryRestriction.deleteMany({
|
||||||
|
where: { apiaryId: { in: apiaryIds() } },
|
||||||
|
});
|
||||||
|
const colonyId = await colonyIn(apiaryFreeId);
|
||||||
|
const [move, created] = await Promise.all([
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/move`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({ apiaryId: apiaryOtherId }),
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post('/api/v1/apiary-restrictions')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
apiaryIds: [apiaryOtherId],
|
||||||
|
disease: DISEASE,
|
||||||
|
startsOn: '2000-01-01',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// Either the move committed first and the restriction followed, or the
|
||||||
|
// restriction was first and the move was refused - never a move that
|
||||||
|
// slipped past a restriction that was already in force.
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
expect([201, 409]).toContain(move.status);
|
||||||
|
const colony = await prisma.colony.findUniqueOrThrow({
|
||||||
|
where: { id: colonyId },
|
||||||
|
});
|
||||||
|
expect(colony.apiaryId).toBe(
|
||||||
|
move.status === 201 ? apiaryOtherId : apiaryFreeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -648,4 +648,443 @@ describe('Treatments (e2e)', () => {
|
|||||||
403,
|
403,
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('treatments from a veterinary medicine lot', () => {
|
||||||
|
const suffix = Date.now();
|
||||||
|
let medicineId: string;
|
||||||
|
let otherMedicineId: string;
|
||||||
|
let lotA: string;
|
||||||
|
let lotB: string;
|
||||||
|
let otherMedicineLot: string;
|
||||||
|
let emptyLot: string;
|
||||||
|
let expiredLot: string;
|
||||||
|
let colonyIds: string[];
|
||||||
|
let restrictedRoleId: string;
|
||||||
|
let restrictedUserId: string;
|
||||||
|
let restrictedToken: string;
|
||||||
|
const restrictedEmail = `treatments-lot-e2e-restricted-${suffix}@example.com`;
|
||||||
|
|
||||||
|
const post = (token: string, path: string, body: object, key?: string) => {
|
||||||
|
const call = request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/treatments${path}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
return (key === undefined ? call : call.set('Idempotency-Key', key)).send(
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const patch = (id: string, body: object) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/treatments/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send(body);
|
||||||
|
const movementsOf = async (lotId: string) =>
|
||||||
|
(
|
||||||
|
await prisma.veterinaryMedicineMovement.findMany({
|
||||||
|
where: { lotId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
).map((movement) => movement.quantity);
|
||||||
|
const restOf = async (lotId: string) => {
|
||||||
|
const lot = await prisma.veterinaryMedicineLot.findUniqueOrThrow({
|
||||||
|
where: { id: lotId },
|
||||||
|
});
|
||||||
|
const sum = (await movementsOf(lotId)).reduce((a, b) => a + b, 0);
|
||||||
|
return Math.round((lot.quantity + sum) * 1000) / 1000;
|
||||||
|
};
|
||||||
|
const lotData = (id: string, extra: object = {}) => ({
|
||||||
|
medicineId: id,
|
||||||
|
purchasedOn: new Date('2026-01-10'),
|
||||||
|
quantity: 100,
|
||||||
|
supplier: 'Apotheke Z, Teststr. 1',
|
||||||
|
purchaseProof: 'Rechnung 42',
|
||||||
|
batchNumber: 'CH-LOT',
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const medicine = await prisma.veterinaryMedicine.create({
|
||||||
|
data: { name: `Lot-Mittel ${suffix}`, unit: 'ML' },
|
||||||
|
});
|
||||||
|
medicineId = medicine.id;
|
||||||
|
otherMedicineId = (
|
||||||
|
await prisma.veterinaryMedicine.create({
|
||||||
|
data: { name: `Anderes Mittel ${suffix}`, unit: 'ML' },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
lotA = (
|
||||||
|
await prisma.veterinaryMedicineLot.create({ data: lotData(medicineId) })
|
||||||
|
).id;
|
||||||
|
lotB = (
|
||||||
|
await prisma.veterinaryMedicineLot.create({ data: lotData(medicineId) })
|
||||||
|
).id;
|
||||||
|
otherMedicineLot = (
|
||||||
|
await prisma.veterinaryMedicineLot.create({
|
||||||
|
data: lotData(otherMedicineId),
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
emptyLot = (
|
||||||
|
await prisma.veterinaryMedicineLot.create({
|
||||||
|
data: lotData(medicineId, { quantity: 10 }),
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
expiredLot = (
|
||||||
|
await prisma.veterinaryMedicineLot.create({
|
||||||
|
data: lotData(medicineId, { expiresOn: new Date('2025-01-31') }),
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
colonyIds = [];
|
||||||
|
for (const code of ['TL-1', 'TL-2', 'TL-3']) {
|
||||||
|
colonyIds.push(
|
||||||
|
(
|
||||||
|
await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `${code}-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `TREATMENT_ONLY_${suffix}`,
|
||||||
|
name: 'Treatment only',
|
||||||
|
permissions: {
|
||||||
|
create: [{ permission: { connect: { key: 'treatments:create' } } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
restrictedRoleId = role.id;
|
||||||
|
restrictedUserId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: restrictedEmail,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: 'Treatment only',
|
||||||
|
role: { connect: { id: role.id } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
restrictedToken = (await login(app, restrictedEmail, password))
|
||||||
|
.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const treatmentIds = (
|
||||||
|
await prisma.treatment.findMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((treatment) => treatment.id);
|
||||||
|
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||||
|
where: { treatmentId: { in: treatmentIds } },
|
||||||
|
});
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: {
|
||||||
|
aggregateType: 'Treatment',
|
||||||
|
aggregateId: { in: treatmentIds },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.treatment.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.veterinaryMedicineLot.deleteMany({
|
||||||
|
where: { medicineId: { in: [medicineId, otherMedicineId] } },
|
||||||
|
});
|
||||||
|
await prisma.veterinaryMedicine.deleteMany({
|
||||||
|
where: { id: { in: [medicineId, otherMedicineId] } },
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||||
|
await prisma.idempotencyRecord.deleteMany({
|
||||||
|
where: { userId: { in: [beekeeperId, restrictedUserId] } },
|
||||||
|
});
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: restrictedUserId },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: restrictedUserId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: restrictedUserId } });
|
||||||
|
await prisma.role.deleteMany({ where: { id: restrictedRoleId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editing dose and lot books only the delta and the movements sum to the new dose', async () => {
|
||||||
|
const created = await post(beekeeperToken, '', {
|
||||||
|
colonyId: colonyIds[0],
|
||||||
|
date: '2026-08-01',
|
||||||
|
medicineLotId: lotA,
|
||||||
|
doseQuantity: 30,
|
||||||
|
}).expect(201);
|
||||||
|
const body = created.body as {
|
||||||
|
id: string;
|
||||||
|
product: string;
|
||||||
|
medicineId: string;
|
||||||
|
supplier: string | null;
|
||||||
|
batchNumber: string | null;
|
||||||
|
};
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
product: `Lot-Mittel ${suffix}`,
|
||||||
|
medicineId,
|
||||||
|
supplier: null,
|
||||||
|
batchNumber: null,
|
||||||
|
});
|
||||||
|
expect(await movementsOf(lotA)).toEqual([-30]);
|
||||||
|
|
||||||
|
await patch(body.id, { doseQuantity: 20 }).expect(200);
|
||||||
|
expect(await movementsOf(lotA)).toEqual([-30, 10]);
|
||||||
|
expect(await restOf(lotA)).toBe(80);
|
||||||
|
|
||||||
|
await patch(body.id, { notes: 'nur Notiz' }).expect(200);
|
||||||
|
expect(await movementsOf(lotA)).toEqual([-30, 10]);
|
||||||
|
|
||||||
|
await patch(body.id, { medicineLotId: lotB }).expect(200);
|
||||||
|
expect(await movementsOf(lotA)).toEqual([-30, 10, 20]);
|
||||||
|
expect(await movementsOf(lotB)).toEqual([-20]);
|
||||||
|
expect(await restOf(lotA)).toBe(100);
|
||||||
|
expect(await restOf(lotB)).toBe(80);
|
||||||
|
|
||||||
|
await patch(body.id, { doseQuantity: null }).expect(200);
|
||||||
|
expect(await restOf(lotB)).toBe(100);
|
||||||
|
const stored = await prisma.treatment.findUniqueOrThrow({
|
||||||
|
where: { id: body.id },
|
||||||
|
});
|
||||||
|
expect(stored.medicineLotId).toBe(lotB);
|
||||||
|
expect(stored.doseQuantity).toBeNull();
|
||||||
|
|
||||||
|
// Dose again: books -15 on lot B, the counter-bookings stay.
|
||||||
|
await patch(body.id, { doseQuantity: 15 }).expect(200);
|
||||||
|
expect(await restOf(lotB)).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the invalid combinations with 400 and books nothing', async () => {
|
||||||
|
const before = await movementsOf(lotA);
|
||||||
|
const base = { colonyId: colonyIds[0], date: '2026-08-02' };
|
||||||
|
await post(beekeeperToken, '', {
|
||||||
|
...base,
|
||||||
|
medicineLotId: lotA,
|
||||||
|
supplier: 'Eigener Text',
|
||||||
|
}).expect(400);
|
||||||
|
await post(beekeeperToken, '', {
|
||||||
|
...base,
|
||||||
|
medicineId: medicineId,
|
||||||
|
medicineLotId: otherMedicineLot,
|
||||||
|
}).expect(400);
|
||||||
|
await post(beekeeperToken, '', {
|
||||||
|
...base,
|
||||||
|
medicineLotId: lotA,
|
||||||
|
doseQuantity: 0,
|
||||||
|
}).expect(400);
|
||||||
|
await post(beekeeperToken, '', {
|
||||||
|
...base,
|
||||||
|
medicineId,
|
||||||
|
product: 'Etwas anderes',
|
||||||
|
}).expect(400);
|
||||||
|
await post(beekeeperToken, '', { ...base }).expect(400);
|
||||||
|
expect(await movementsOf(lotA)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects medicineId null while the treatment has a lot', async () => {
|
||||||
|
const created = await post(beekeeperToken, '', {
|
||||||
|
colonyId: colonyIds[1],
|
||||||
|
date: '2026-08-03',
|
||||||
|
medicineLotId: lotA,
|
||||||
|
}).expect(201);
|
||||||
|
await patch((created.body as { id: string }).id, {
|
||||||
|
medicineId: null,
|
||||||
|
}).expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated bulk request with the same Idempotency-Key books once', async () => {
|
||||||
|
const key = `treatments-lot-bulk-${suffix}`;
|
||||||
|
const body = {
|
||||||
|
date: '2026-08-05',
|
||||||
|
medicineLotId: lotB,
|
||||||
|
doseQuantity: 5,
|
||||||
|
colonyIds: [colonyIds[1], colonyIds[2]],
|
||||||
|
};
|
||||||
|
const restBefore = await restOf(lotB);
|
||||||
|
|
||||||
|
const first = await post(beekeeperToken, '/bulk', body, key).expect(201);
|
||||||
|
const replay = await post(beekeeperToken, '/bulk', body, key).expect(201);
|
||||||
|
|
||||||
|
expect((replay.body as { bulkId: string }).bulkId).toBe(
|
||||||
|
(first.body as { bulkId: string }).bulkId,
|
||||||
|
);
|
||||||
|
expect(await restOf(lotB)).toBe(restBefore - 10);
|
||||||
|
expect(
|
||||||
|
await prisma.veterinaryMedicineMovement.count({
|
||||||
|
where: {
|
||||||
|
lotId: lotB,
|
||||||
|
treatment: { colonyId: { in: [colonyIds[1], colonyIds[2]] } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a bulk request with an unknown colony books nothing', async () => {
|
||||||
|
const restBefore = await restOf(lotB);
|
||||||
|
await post(beekeeperToken, '/bulk', {
|
||||||
|
date: '2026-08-06',
|
||||||
|
medicineLotId: lotB,
|
||||||
|
doseQuantity: 5,
|
||||||
|
colonyIds: [colonyIds[1], 'does-not-exist'],
|
||||||
|
}).expect(400);
|
||||||
|
expect(await restOf(lotB)).toBe(restBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a treatment from an empty lot is stored and the rest turns negative', async () => {
|
||||||
|
await post(beekeeperToken, '', {
|
||||||
|
colonyId: colonyIds[2],
|
||||||
|
date: '2026-08-07',
|
||||||
|
medicineLotId: emptyLot,
|
||||||
|
doseQuantity: 12,
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
expect(await restOf(emptyLot)).toBe(-2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a treatment after the lot expired is stored', async () => {
|
||||||
|
await post(beekeeperToken, '', {
|
||||||
|
colonyId: colonyIds[2],
|
||||||
|
date: '2026-08-08',
|
||||||
|
medicineLotId: expiredLot,
|
||||||
|
doseQuantity: 3,
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
expect(await restOf(expiredLot)).toBe(97);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user with treatments:create but without veterinary-medicines:read can book from a lot', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/veterinary-medicine-lots')
|
||||||
|
.set('Authorization', `Bearer ${restrictedToken}`)
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
await post(restrictedToken, '', {
|
||||||
|
colonyId: colonyIds[0],
|
||||||
|
date: '2026-08-09',
|
||||||
|
medicineLotId: lotA,
|
||||||
|
doseQuantity: 1,
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
expect((await movementsOf(lotA)).at(-1)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a long edit sequence keeps every lot and the treatment total consistent', async () => {
|
||||||
|
const [lotX, lotY, lotZ] = await Promise.all(
|
||||||
|
[1, 2, 3].map(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await prisma.veterinaryMedicineLot.create({
|
||||||
|
data: lotData(medicineId),
|
||||||
|
})
|
||||||
|
).id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const lots = [lotX, lotY, lotZ];
|
||||||
|
const created = await post(beekeeperToken, '', {
|
||||||
|
colonyId: colonyIds[0],
|
||||||
|
date: '2026-08-12',
|
||||||
|
medicineLotId: lotX,
|
||||||
|
doseQuantity: 10,
|
||||||
|
}).expect(201);
|
||||||
|
const id = (created.body as { id: string }).id;
|
||||||
|
|
||||||
|
// After every step: the movements per lot sum to -dose on the current
|
||||||
|
// lot and to 0 on all others; over all lots they sum to -dose (0 when
|
||||||
|
// the lot is null).
|
||||||
|
const expectState = async (
|
||||||
|
currentLot: string | null,
|
||||||
|
dose: number | null,
|
||||||
|
) => {
|
||||||
|
const wanted = currentLot !== null && dose !== null ? dose : 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const lot of lots) {
|
||||||
|
const sum = Math.round(
|
||||||
|
(await movementsOf(lot)).reduce((a, b) => a + b, 0) * 1000,
|
||||||
|
);
|
||||||
|
expect(sum / 1000).toBe(lot === currentLot ? 0 - wanted : 0);
|
||||||
|
total += sum;
|
||||||
|
}
|
||||||
|
expect(total / 1000).toBe(0 - wanted);
|
||||||
|
const stored = await prisma.treatment.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
expect(stored.medicineLotId).toBe(currentLot);
|
||||||
|
expect(stored.doseQuantity).toBe(dose);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expectState(lotX, 10);
|
||||||
|
await patch(id, { doseQuantity: 12.5 }).expect(200);
|
||||||
|
await expectState(lotX, 12.5);
|
||||||
|
await patch(id, { medicineLotId: lotY }).expect(200);
|
||||||
|
await expectState(lotY, 12.5);
|
||||||
|
await patch(id, { medicineLotId: lotZ }).expect(200);
|
||||||
|
await expectState(lotZ, 12.5);
|
||||||
|
await patch(id, { medicineLotId: null }).expect(200);
|
||||||
|
await expectState(null, 12.5);
|
||||||
|
await patch(id, { medicineLotId: lotX }).expect(200);
|
||||||
|
await expectState(lotX, 12.5);
|
||||||
|
await patch(id, { doseQuantity: 4 }).expect(200);
|
||||||
|
await expectState(lotX, 4);
|
||||||
|
await patch(id, { doseQuantity: null }).expect(200);
|
||||||
|
await expectState(lotX, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated single POST with the same Idempotency-Key books exactly one movement', async () => {
|
||||||
|
const key = `treatments-lot-single-${suffix}`;
|
||||||
|
const body = {
|
||||||
|
colonyId: colonyIds[1],
|
||||||
|
date: '2026-08-13',
|
||||||
|
medicineLotId: expiredLot,
|
||||||
|
doseQuantity: 1.5,
|
||||||
|
};
|
||||||
|
const restBefore = await restOf(expiredLot);
|
||||||
|
|
||||||
|
const first = await post(beekeeperToken, '', body, key).expect(201);
|
||||||
|
const replay = await post(beekeeperToken, '', body, key).expect(201);
|
||||||
|
|
||||||
|
expect((replay.body as { id: string }).id).toBe(
|
||||||
|
(first.body as { id: string }).id,
|
||||||
|
);
|
||||||
|
expect(await restOf(expiredLot)).toBe(restBefore - 1.5);
|
||||||
|
expect(
|
||||||
|
await prisma.veterinaryMedicineMovement.count({
|
||||||
|
where: { treatmentId: (first.body as { id: string }).id },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-filing the treatment to another colony leaves the stock untouched', async () => {
|
||||||
|
const created = await post(beekeeperToken, '', {
|
||||||
|
colonyId: colonyIds[0],
|
||||||
|
date: '2026-08-10',
|
||||||
|
medicineLotId: lotA,
|
||||||
|
doseQuantity: 2,
|
||||||
|
}).expect(201);
|
||||||
|
const restBefore = await restOf(lotA);
|
||||||
|
|
||||||
|
await patch((created.body as { id: string }).id, {
|
||||||
|
colonyId: colonyIds[1],
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
expect(await restOf(lotA)).toBe(restBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('corrects the apiary of a treatment and answers 404 for an unknown one', async () => {
|
||||||
|
const created = await post(beekeeperToken, '', {
|
||||||
|
colonyId: colonyIds[0],
|
||||||
|
date: '2026-08-11',
|
||||||
|
product: 'Ameisensäure',
|
||||||
|
}).expect(201);
|
||||||
|
const id = (created.body as { id: string }).id;
|
||||||
|
|
||||||
|
await patch(id, { apiaryId: 'does-not-exist' }).expect(404);
|
||||||
|
await patch(id, { apiaryId: null }).expect(400);
|
||||||
|
const fixed = await patch(id, { apiaryId }).expect(200);
|
||||||
|
expect((fixed.body as { apiaryId: string }).apiaryId).toBe(apiaryId);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { inflateSync } from 'node:zlib';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text a pdfkit document prints, for assertions in tests: inflates the
|
||||||
|
* content streams and joins the strings of every text-showing operator
|
||||||
|
* (hex strings of the standard fonts, one line per operator). Not a PDF
|
||||||
|
* parser - it reads what pdfkit writes with the built-in Helvetica.
|
||||||
|
*/
|
||||||
|
export function pdfText(pdf: Buffer): string {
|
||||||
|
const source = pdf.toString('latin1');
|
||||||
|
const lines: string[] = [];
|
||||||
|
const streams = source.matchAll(/stream\r?\n([\s\S]*?)\r?\nendstream/g);
|
||||||
|
for (const [, raw] of streams) {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = inflateSync(Buffer.from(raw, 'latin1')).toString('latin1');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const operator of content.matchAll(
|
||||||
|
/\[([^\]]*)\]\s*TJ|<([0-9a-fA-F]+)>\s*Tj/g,
|
||||||
|
)) {
|
||||||
|
const parts = operator[1] ?? `<${operator[2]}>`;
|
||||||
|
const text = [...parts.matchAll(/<([0-9a-fA-F]*)>/g)]
|
||||||
|
.map(([, hex]) => Buffer.from(hex, 'hex').toString('latin1'))
|
||||||
|
.join('');
|
||||||
|
lines.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -494,8 +494,8 @@ Gezählt werden lebende Völker über `LIVING_COLONY_WHERE` (Annahme 21) an akti
|
|||||||
|
|
||||||
### 5.6 Sperrbezirk
|
### 5.6 Sperrbezirk
|
||||||
|
|
||||||
- **Aktiv** ist eine Sperre, wenn `startsOn` ≤ Tag ≤ `endsOn` gilt (oder `endsOn` leer ist). „Tag“ ist der UTC-Tag wie in der täglichen Auswertung (`utcDay`).
|
- **Aktiv** ist eine Sperre, wenn `startsOn` ≤ Tag ≤ `endsOn` gilt (oder `endsOn` leer ist). „Tag“ ist der **Berliner Kalendertag** (`berlinToday()`, Inhaber-Entscheidung 2026-10-15; nicht UTC). Es gibt keinen `dbUtcDay`.
|
||||||
- **Gemeinsamer Helfer** `assertNoActiveRestriction(tx, apiaryIds, day, override)` in `src/compliance/restricted-zone.ts`. Er wirft `409 { code: 'RESTRICTED_ZONE', message, restrictionIds }`.
|
- **Gemeinsamer Helfer** `assertNoActiveRestriction(tx, apiaryIds, day, override)` in `src/compliance/restricted-zone.ts`. Er wirft `409 { code: 'RESTRICTED_ZONE', message, restrictionIds, restrictions }`.
|
||||||
|
|
||||||
| Vorgang | Wirkung |
|
| Vorgang | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -507,9 +507,12 @@ Gezählt werden lebende Völker über `LIVING_COLONY_WHERE` (Annahme 21) an akti
|
|||||||
| Ernte am gesperrten Stand | nur Warnung im Formular (Inhaber 2026-10-13) |
|
| Ernte am gesperrten Stand | nur Warnung im Formular (Inhaber 2026-10-13) |
|
||||||
|
|
||||||
- **Ausnahme:** Feld `restrictionOverrideReason` (10–500 Zeichen) an `CreateColonyDto`, `UpdateColonyDto`, `MoveColonyDto` und `MergeColonyDto`.
|
- **Ausnahme:** Feld `restrictionOverrideReason` (10–500 Zeichen) an `CreateColonyDto`, `UpdateColonyDto`, `MoveColonyDto` und `MergeColonyDto`.
|
||||||
- Nur mit `compliance:manage`. Der Service prüft das über `PermissionResolverService.getPermissions`, sonst 403.
|
- Nur mit `compliance:manage`. Der Service prüft das über `PermissionResolverService.getPermissions`, sonst 403 - **immer**, auch wenn gar keine Sperre aktiv ist, und gleich zu Beginn der Anfrage. Ohne Passwort-Abfrage (Inhaber-Entscheidung 2026-10-15).
|
||||||
- Die Begründung landet im Audit-Eintrag und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“).
|
- Die Begründung landet im Audit-Eintrag und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“).
|
||||||
- Das Feld wird nicht am Volk gespeichert. Für die Null-Regel gilt `null` wie weggelassen. `update-dto-null-handling.spec.ts` hält das fest.
|
- Das Feld wird nicht am Volk gespeichert. Für die Null-Regel gilt `null` wie weggelassen. `update-dto-null-handling.spec.ts` hält das fest.
|
||||||
|
- **Gleichzeitigkeit:** Die Völker-Pfade sperren zuerst die Stände (`FOR SHARE`, aufsteigend), dann das Volk; ändert ein Volk zwischen ungesperrtem Lesen und Sperre seinen Stand, antwortet der Server 409 „Der Stand wurde gerade geändert. Bitte neu laden.“ (kein automatischer Neuversuch). `ApiaryRestrictionsService` sperrt die Stände `FOR UPDATE`.
|
||||||
|
- **CSV-Import:** Völker, die der Import in einem gesperrten Stand anlegen würde, werden als Zeilenfehler gemeldet (Sperrbezirk-Meldung, nichts geschrieben); der Import hat keine Ausnahme.
|
||||||
|
- **Lesen:** `GET /apiary-restrictions` und `GET /apiaries/:id/restrictions` liefern Behörde, Aktenzeichen, Notiz und Ersteller nur mit `compliance:read`; mit nur `apiaries:read` gibt es `id`, `apiaryId`, `disease`, `startsOn`, `endsOn`.
|
||||||
- **Erfassen:** `POST /apiary-restrictions` mit `apiaryIds` (1–50) legt eine Zeile je Stand in einer Transaktion an. Beendet wird eine Sperre über `endsOn`. Gelöscht wird nichts.
|
- **Erfassen:** `POST /apiary-restrictions` mit `apiaryIds` (1–50) legt eine Zeile je Stand in einer Transaktion an. Beendet wird eine Sperre über `endsOn`. Gelöscht wird nichts.
|
||||||
|
|
||||||
### 5.7 Prüfbereitschaft
|
### 5.7 Prüfbereitschaft
|
||||||
|
|||||||
Reference in new issue
Block a user