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 |
|
||||
| `HoneyBatchesModule` | Honigchargen, Honigmessungen | Extractions |
|
||||
| `StorageModule` | Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) | HoneyBatches |
|
||||
| `VeterinaryMedicinesModule` | Tierarzneimittel-Katalog, Packungen, Bewegungsbuch (T6, Abschnitt 12.2); exportiert `VeterinaryMedicinesService` und `VeterinaryMedicineLotsService` für die Behandlung (Task 3) | – |
|
||||
| `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. |
|
||||
| `SearchModule` | globale Suche, Filter-Bausteine | 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.
|
||||
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.
|
||||
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).
|
||||
|
||||
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.
|
||||
48. **Autopilot A4–A10: Annahmen**: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind `PLAN`-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt `HERBST` ist der Beginn von `SPAETSOMMER` daher genähert: der Kalendertag von `TRACHTENDE` (`PHENOLOGY.calendarFallback`), höchstens der Beginn von `HERBST` (`lateSummerStart`). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte `lateSummerSince` an `ApiaryPhenology` (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab `springSince` des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn `reportedOn` vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. `syncApiaryIn` gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (`src/suggestions/suggesting-triggers.spec.ts`) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen `reasonCode` einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.
|
||||
49. **Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden**: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, `src/notifications/delivery-time.ts`). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (`TASK_DUE`: eine Minute nach der Morgenzeit, `DIGEST`: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (`templateCode`) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus `requiredPermissions` (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. `TASK_DUE` und `DIGEST` sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine `TASK_DUE`-Zustellung braucht die Aufgabe noch offen. Sonst `SKIPPED` mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.
|
||||
50. **Sperrreihenfolge: Stand vor Volk**: Die Karte `LOCKABLE_TABLES` (`LOCK_ORDER`, `src/prisma/row-lock.ts`) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), `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.
|
||||
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.
|
||||
@@ -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).
|
||||
63. **Wiederherstellung ersetzt das Packungsbuch**: Eine versiegelte Datei überschreibt Mittel, Packungen und Bewegungen nach Id; sie führt das Buch nicht mit dem vorhandenen zusammen. Aus unversiegelten Dateien entsteht kein Packungsbezug einer Behandlung (Abschnitt 8). Kosten bei falscher Annahme: Buchungen, die nach der Sicherung entstanden sind, fehlen nach dem Einspielen und müssen nachgetragen werden.
|
||||
64. **Beim Wiederherstellen gewinnt der offene Entwurf im Zielsystem**: Hält dort ein Entwurf (`openKey`, etwa der vom Tagesjob angelegte TSK-Entwurf) den Schlüssel unter anderer Id, entfällt die Anzeige der Datei mit ihren Dokumenten; der Restore scheitert nicht am Unique-Constraint. Kosten bei falscher Annahme: Der Entwurf aus der Datei fehlt und muss neu erfasst werden (die Ids stehen in `summary.skippedNoticeIds`).
|
||||
65. **Etikett-Pflichtangaben prüft eine reine Funktion am aufgelösten Inhalt**: `checkLabel` (`src/label-templates/label-particulars.ts`) bewertet alle Text-Elemente mit eingesetzten Platzhaltern, normalisiert mit NFKC, Kleinschreibung und „alles außer Buchstaben und Ziffern wird zum Leerzeichen“. Ein Text, der nicht in sein Feld passt (gemessene Höhe größer als Feldhöhe plus 0,5 pt), zählt nicht und löst `TEXT_CLIPPED` aus. Nicht geprüft werden die „gleiches Sichtfeld“-Regel und die Nährwertdeklaration (für Honig befreit – Annahme, vom Inhaber zu prüfen). Das Datum auf dem Etikett und das erwartete MHD der Prüfung kommen beide aus `labelDayOf` (Berliner Kalendertag, unabhängig von der Prozess-Zeitzone). Eine Zahl direkt vor der Menge („1,250 g“) gilt nicht als Treffer für 250 g. Eine Anschrift wird auch erkannt, wenn sie auf mehrere Elemente verteilt ist, weil eine unerkannte Anschrift den Druck sperrt. Abschnitt 12.4.
|
||||
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.
|
||||
67. **Verkehrsbezeichnung: „honig“ in einem beliebigen Wort genügt**: Für `LEGAL_NAME` reicht ein Wort mit „honig“ („Blütenhonig“, auch „Honigtau“); ob die Bezeichnung inhaltlich stimmt, prüft die Software nicht. Kosten bei falscher Annahme: Ein falsch benannter Honig sperrt den Druck nicht.
|
||||
68. **Losnummer mit „L“ am Anfang erfüllt die Präfix-Regel**: Beginnt die Losnummer schon mit „L“ („L-001“), stellt der Platzhalter `lotLine` kein weiteres „L“ voran und `LOT_PREFIX` warnt nicht. Die Warnung sperrt nie.
|
||||
69. **`compliance:manage` schließt `compliance:read` nicht ein**: Die Rechte sind getrennt; die Rollenvergabe koppelt sie (nur ADMIN und BEEKEEPER haben `manage`, und diese haben auch `read`). Ein Nutzer mit nur `manage` darf bei Dokumenten hochladen, aber die Liste nicht lesen (403). Kosten bei falscher Annahme: Eine handgebaute Rolle mit nur `manage` sieht nichts, was sie angelegt hat.
|
||||
70. **Dokumente gelöschter Datensätze bleiben bestehen**: Sie bleiben durch das Recht der Art geschützt und werden nicht mitgelöscht; die löschenden Pfade der späteren Tasks (Entwürfe, Sperrbezirke: Tasks 8 und 9) entfernen ihre Dokumente selbst. Kosten bei falscher Annahme: Bis dahin bleiben Dateien ohne Datensatz im Speicher.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
**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):
|
||||
|
||||
@@ -727,9 +736,9 @@ Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (F
|
||||
| `compliance:read` | Prüfbereitschaft & Behörden (3800) | ✓ | ✓ | ✓ | ✓ | – |
|
||||
| `compliance:manage` | Prüfbereitschaft & Behörden (3801) | ✓ | ✓ | – | – | – |
|
||||
|
||||
Eine Behandlung aus einer Packung bucht mit `treatments:create` ab, es gibt kein eigenes Recht. Sperrbezirke lesen mit `apiaries:read` und schreiben mit `compliance:manage`. `AuditAction` kennt `PRINT` (Etikettendruck, Task 6).
|
||||
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).
|
||||
|
||||
@@ -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 }`.
|
||||
- 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
|
||||
|
||||
@@ -756,11 +771,18 @@ Für `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` gilt zusätzlic
|
||||
|
||||
### 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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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).
|
||||
|
||||
`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.
|
||||
Reference in new issue
Block a user